Compare commits

..

9 Commits

Author SHA1 Message Date
sunpeiyang.996
73ea222ba7 feat: inject Doubao docs scene
Change-Id: Id06f24174c8f4922cc1e029cd82828fbc72e89b4
2026-05-09 16:34:06 +08:00
yangjunzhou
d8e08736f1 feat: request thread roots for chat message list
Change-Id: I3901b27e70b0e4db506ff199eb03c96fcf98671d
2026-04-23 21:42:24 +08:00
tuxedomm
138a2ef785 test: cover flag-completion gate and shortcut enum/format registration
- Extract configureFlagCompletions helper in cmd/root.go so the gate
  between normal invocations and __complete requests can be unit-tested.
- Add TestConfigureFlagCompletions with table cases for plain commands,
  --help, __complete, and the completion subcommand.
- Add TestShortcutMount_FlagCompletions{Registered,Disabled} in
  shortcuts/common to exercise the two RegisterFlagCompletion call
  sites in registerShortcutFlagsWithContext (enum flag and --format).

Change-Id: Idcb302bb40045b1c5b34580ec90d586eae8b8707
2026-04-22 15:02:00 +08:00
tuxedomm
0cb6cdf818 fix: skip flag-completion registration outside completion path
Cobra keeps completion callbacks in a package-global map keyed by
*pflag.Flag with no removal path, so registrations made during Build()
outlive the command itself. Route all seven call sites through
cmdutil.RegisterFlagCompletion and enable registration only when the
invocation actually serves a __complete request.

Measured over 30 dropped Builds: ~202 KB / 2180 retained objects per
Build before, ~0 after.

Change-Id: I734d598a4c91a92c33b02e0f292f640cc0e224c6
2026-04-22 15:02:00 +08:00
chenjinxiong.03
5d9b3d305f fix(output): recognise typed slices in array field detection
ExtractItems / FindArrayField only matched []interface{}, so shortcuts
that collected results into []map[string]interface{} fell through to
the key/value envelope render under --format table/csv/ndjson. Accept
any reflect.Slice via a new asGenericSlice helper, and register the
chats/messages/tasks/created_tasks known fields. Adds regression tests
covering typed map slices, typed struct slices, and raw typed slices.

Change-Id: I4562645bfeb9bb45e11273f491eb1ab0080118fe
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:10:24 +08:00
sunpeiyang.996
9229c50fcf feat(doc): add v2 API support for +create/+fetch/+update
Introduce an OpenAPI-backed v2 path for docs shortcuts alongside the
existing MCP-backed v1 path, selectable via --api-version v1|v2. v1
stays default and prints a deprecation notice; v2 is auto-detected
when v2-only flags are present.

v2 highlights:
- +create / +update take --content (XML or Markdown via --doc-format);
  +update adds structured --command ops (str_replace, block_*, append,
  overwrite, ...).
- +fetch adds --detail (simple/with-ids/full), partial read --scope
  (outline/range/keyword/section) with context + max-depth controls.
- XML output is no longer HTML-escaped (OutRaw / OutFormatRaw).
- Versioned --help hides flags that don't apply to the selected API
  version via a new Shortcut.PostMount hook.

Docs/skills updated to v2 usage; block-id based comments replace
--selection-with-ellipsis in drive +add-comment references.

Change-Id: I4b3c35fe967920f9e2546d5b6ca0ec7dcfd415fc
2026-04-21 10:41:33 +08:00
chenjinxiong.03
d25f79bb64 docs(rebase-420): add conflict resolution report for dd05477
Change-Id: I25a8d1ce7f731cb3514be4b4106326be82700720
2026-04-20 11:29:24 +08:00
tuxedomm
4d84994ce6 feat: add SetDefaultFS to allow replacing the global filesystem implementation
Change-Id: If5c3e50e84859f9ac4ffceeb0ac3dc7b7330b274
2026-04-20 01:06:20 +08:00
tuxedomm
6b56e0fdde feat(schema): filter methods by strict mode in schema output
When strict mode is active, schema output now excludes methods that
are incompatible with the forced identity. This applies to both
pretty and JSON output formats at the resource and method levels.

Change-Id: I39647d5578466c3e23dc545bfb917ae075203ad7
2026-04-20 00:44:59 +08:00
160 changed files with 3563 additions and 13979 deletions

3
.gitignore vendored
View File

@@ -33,8 +33,7 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
internal/registry/meta_data.json
cmd/api/download.bin
app.log
/sidecar-server-demo
/server-demo

View File

@@ -2,43 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.16] - 2026-04-21
### Features
- **mail**: Support large email attachments (#537)
- **mail**: Add draft preview URL to draft operations (#438)
- **doc**: Add pre-write semantic warnings to `docs +update` (#569)
- **doc**: Add `--selection-with-ellipsis` position flag to `+media-insert` (#335)
- **calendar**: Support event share link and error details (#583)
### Bug Fixes
- **doc**: Preserve round-trip formatting in `+fetch` output (#469)
- **docs**: Validate `--selection-by-title` format early (#256)
- **whiteboard**: Register `+media-upload` shortcut and add whiteboard parent type
### Refactor
- Split `Execute` into `Build` + `Execute` with explicit IO and keychain injection (#371)
- **auth**: Simplify scope reporting in login flow (#582)
## [v1.0.15] - 2026-04-20
### Features
- **sheets**: Add float image shortcuts (#494)
- **approval**: Document `remind` and `initiated` methods in skill (#554)
### Bug Fixes
- **base**: Preserve attachment metadata on base uploads (#563)
- **base**: Fix role view and record default permission on edit (#530)
- **sheets**: Normalize single-cell range in `+set-style` and `+batch-set-style` (#548)
- **im**: Cap `basic_batch` user_ids at 10 per API limit (#551)
- **install**: Refine install wizard messages (#529)
- **whiteboard**: Deprecate old `lark-whiteboard-cli` skill (#547)
## [v1.0.14] - 2026-04-17
### Features
@@ -441,8 +404,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.16]: https://github.com/larksuite/cli/releases/tag/v1.0.16
[v1.0.15]: https://github.com/larksuite/cli/releases/tag/v1.0.15
[v1.0.14]: https://github.com/larksuite/cli/releases/tag/v1.0.14
[v1.0.13]: https://github.com/larksuite/cli/releases/tag/v1.0.13
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12

View File

@@ -201,7 +201,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --title "Weekly Report" --markdown "# Progress\n- Completed feature X"
lark-cli docs +create --doc-format markdown --content "<title>Weekly Report</title>\n# Progress\n- Completed feature X"
```
Run `lark-cli <service> --help` to see all shortcut commands.

View File

@@ -202,7 +202,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --title "周报" --markdown "# 本周进展\n- 完成了 X 功能"
lark-cli docs +create --doc-format markdown --content "<title>周报</title>\n# 本周进展\n- 完成了 X 功能"
```
运行 `lark-cli <service> --help` 查看所有快捷命令。

View File

@@ -57,10 +57,6 @@ func normalisePath(raw string) string {
// NewCmdApi creates the api command. If runF is non-nil it is called instead of apiRun (test hook).
func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
return NewCmdApiWithContext(context.Background(), f, runF)
}
func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
opts := &APIOptions{Factory: f}
var asStr string
@@ -83,7 +79,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)")
@@ -100,7 +96,10 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
}
return nil, cobra.ShellCompDirectiveNoFileComp
}
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})

View File

@@ -180,24 +180,6 @@ func TestApiValidArgsFunction(t *testing.T) {
}
}
func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := NewCmdApi(f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if !flag.Hidden {
t.Fatal("expected --as flag to be hidden in strict mode")
}
if got := flag.DefValue; got != "bot" {
t.Fatalf("default value = %q, want %q", got, "bot")
}
}
func TestApiCmd_PageLimitDefault(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,

View File

@@ -72,7 +72,7 @@ browser. Run it in the background and retrieve the verification URL from its out
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
_ = cmd.RegisterFlagCompletionFunc("domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp
})

View File

@@ -29,6 +29,7 @@ type loginMsg struct {
ScopeHint string
RequestedScopes string
NewlyGrantedScopes string
MissingScopes string
NoScopes string
StatusHint string
@@ -58,13 +59,14 @@ var loginMsgZh = &loginMsg{
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
AuthSuccess: "授权已完成,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
ScopeMismatch: "授权结果异常: 以下请求 scopes 未被授予: %s",
ScopeMismatch: "授权结果异常以下请求 scopes 未被授予: %s",
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
RequestedScopes: " 本次请求 scopes: %s\n",
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
MissingScopes: " 本次未授予 scopes: %s\n",
NoScopes: "(空)",
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
@@ -93,13 +95,14 @@ var loginMsgEn = &loginMsg{
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
AuthSuccess: "Authorization completed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",
ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s",
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
RequestedScopes: " Requested scopes: %s\n",
NewlyGrantedScopes: " Newly granted scopes: %s\n",
MissingScopes: " Not granted scopes: %s\n",
NoScopes: "(none)",
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",

View File

@@ -128,7 +128,7 @@ func emptyIfNil(s []string) []string {
return s
}
// writeLoginScopeBreakdown renders the requested/newly granted scope
// writeLoginScopeBreakdown renders the requested/newly granted/missing scope
// breakdown to stderr.
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
if summary == nil {
@@ -136,6 +136,7 @@ func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary
}
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
fmt.Fprintf(errOut.ErrOut, msg.MissingScopes, formatScopeList(summary.Missing, msg.NoScopes))
}
// writeLoginSuccess emits the successful login payload in either JSON or text

View File

@@ -363,7 +363,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
Message: "授权结果异常: 以下请求 scopes 未被授予: im:message:send",
Message: "授权结果异常以下请求 scopes 未被授予: im:message:send",
Hint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
Summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
@@ -376,10 +376,11 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
}
got := stderr.String()
for _, want := range []string{
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
"授权结果异常以下请求 scopes 未被授予: im:message:send",
"当前授权账号: tester (ou_user)",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
@@ -394,9 +395,6 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
if strings.Contains(got, "授权成功") {
t.Fatalf("stderr should not contain success wording, got:\n%s", got)
}
if strings.Contains(got, "本次未授予 scopes:") {
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
}
}
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
@@ -474,10 +472,10 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
"授权成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: im:message:send",
"本次未授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"本次未授予 scopes:",
"最终已授权 scopes:",
"已有 scopes:",
},
@@ -492,10 +490,10 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
expectedPresent: []string{
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"本次未授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"本次未授予 scopes:",
"最终已授权 scopes:",
"已有 scopes:",
},
@@ -510,9 +508,9 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
expectedPresent: []string{
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: (空)",
"本次未授予 scopes: im:message:send",
},
expectedAbsent: []string{
"本次未授予 scopes:",
"已有 scopes:",
"最终已授权 scopes:",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
@@ -621,9 +619,10 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
}
got := stderr.String()
for _, want := range []string{
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
"授权结果异常以下请求 scopes 未被授予: im:message:send",
"当前授权账号: tester (ou_user)",
"本次请求 scopes: im:message:send",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
@@ -638,9 +637,6 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
if strings.Contains(got, "OK: 授权成功") {
t.Fatalf("stderr should not contain success prefix when scopes are missing, got:\n%s", got)
}
if strings.Contains(got, "本次未授予 scopes:") {
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
}
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
}
@@ -781,15 +777,13 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
"Authorization successful! User: tester (ou_user)",
"Requested scopes: im:message:send",
"Newly granted scopes: im:message:send",
"Not granted scopes: (none)",
"Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
if strings.Contains(got, "Not granted scopes:") {
t.Fatalf("stderr should not contain not granted scopes, got:\n%s", got)
}
}
func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {

View File

@@ -6,6 +6,9 @@ package cmd
import (
"context"
"io"
"os"
"golang.org/x/term"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
@@ -29,14 +32,16 @@ type BuildOption func(*buildConfig)
type buildConfig struct {
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
// Terminal detection is delegated to cmdutil.NewIOStreams.
// WithIO sets the IO streams for the CLI. If not provided, os.Stdin/Stdout/Stderr are used.
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
return func(c *buildConfig) {
c.streams = cmdutil.NewIOStreams(in, out, errOut)
isTerminal := false
if f, ok := in.(*os.File); ok {
isTerminal = term.IsTerminal(int(f.Fd()))
}
c.streams = &cmdutil.IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
}
}
@@ -47,16 +52,6 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
}
}
// HideProfile sets the visibility policy for the root-level --profile flag.
// When hide is true the flag stays registered (so existing invocations still
// parse) but is omitted from help and shell completion. Typically called as
// HideProfile(isSingleAppMode()).
func HideProfile(hide bool) BuildOption {
return func(c *buildConfig) {
c.globals.HideProfile = hide
}
}
// Build constructs the full command tree without executing.
// Returns only the cobra.Command; Factory is internal.
// Use Execute for the standard production entry point.
@@ -65,30 +60,21 @@ func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOpti
return rootCmd
}
// buildInternal is a pure assembly function: it wires the command tree from
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
// env) belongs in the caller and must be threaded in via BuildOption.
// buildInternal is the internal constructor that also returns Factory for error handling.
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
// cfg.globals.Profile is left zero here; it's bound to the --profile
// flag in RegisterGlobalFlags and filled by cobra's parse step.
cfg := &buildConfig{}
for _, o := range opts {
if o != nil {
o(cfg)
}
cfg := &buildConfig{
streams: cmdutil.SystemIO(),
}
// Default streams when WithIO is not supplied so the root command's
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
// partial streams internally; keep both in sync so cfg.streams reflects
// the same values the Factory ends up using.
if cfg.streams == nil {
cfg.streams = cmdutil.SystemIO()
for _, o := range opts {
o(cfg)
}
f := cmdutil.NewDefault(cfg.streams, inv)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
globals := &GlobalOptions{Profile: inv.Profile}
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
@@ -104,7 +90,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
@@ -113,12 +99,12 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
// Prune commands incompatible with strict mode.
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"context"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/vfs"
)
// noopKeychain is a zero-side-effect KeychainAccess for exercising
// WithKeychain without touching the platform keychain.
type noopKeychain struct{}
func (noopKeychain) Get(service, account string) (string, error) { return "", nil }
func (noopKeychain) Set(service, account, value string) error { return nil }
func (noopKeychain) Remove(service, account string) error { return nil }
// TestBuild_ExternalAPI asserts the library surface that external consumers
// (e.g. cli-server) depend on: Build composes a root command from an
// InvocationContext plus BuildOptions (WithIO, WithKeychain, HideProfile),
// and SetDefaultFS swaps the global VFS. This test is the contract guard.
func TestBuild_ExternalAPI(t *testing.T) {
// Exercise SetDefaultFS both directions. Passing nil restores the OS FS.
SetDefaultFS(vfs.OsFs{})
SetDefaultFS(nil)
var in, out, errOut bytes.Buffer
rootCmd := Build(
context.Background(),
cmdutil.InvocationContext{},
WithIO(&in, &out, &errOut),
WithKeychain(noopKeychain{}),
HideProfile(true),
)
if rootCmd == nil {
t.Fatal("Build returned nil root command")
}
if rootCmd.Use != "lark-cli" {
t.Errorf("rootCmd.Use = %q, want %q", rootCmd.Use, "lark-cli")
}
if len(rootCmd.Commands()) == 0 {
t.Error("Build produced a root command with no subcommands")
}
}
// TestBuild_NoOptions guards against regression of the nil-streams panic:
// calling Build without WithIO must fall back to SystemIO rather than
// deref nil at rootCmd.SetIn/Out/Err.
func TestBuild_NoOptions(t *testing.T) {
rootCmd := Build(context.Background(), cmdutil.InvocationContext{})
if rootCmd == nil {
t.Fatal("Build returned nil root command")
}
if rootCmd.Use != "lark-cli" {
t.Errorf("rootCmd.Use = %q, want %q", rootCmd.Use, "lark-cli")
}
}

View File

@@ -3,38 +3,15 @@
package cmd
import (
"github.com/larksuite/cli/internal/core"
"github.com/spf13/pflag"
)
import "github.com/spf13/pflag"
// GlobalOptions are the root-level flags shared by bootstrap parsing and the
// actual Cobra command tree. Profile is the parsed --profile value; HideProfile
// is a build-time policy — when true, --profile stays parseable but is marked
// hidden from help and shell completion.
// actual Cobra command tree.
type GlobalOptions struct {
Profile string
HideProfile bool
Profile string
}
// RegisterGlobalFlags registers the root-level persistent flags on fs and
// applies any visibility policy encoded in opts. Pure function: no disk,
// network, or environment reads — the caller decides HideProfile.
// RegisterGlobalFlags registers the root-level persistent flags.
func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
fs.StringVar(&opts.Profile, "profile", "", "use a specific profile")
if opts.HideProfile {
_ = fs.MarkHidden("profile")
}
}
// isSingleAppMode reports whether the on-disk config has at most one app.
// Missing configs are treated as single-app since --profile is meaningless
// until at least two profiles exist. Intended for the Execute entry point —
// buildInternal must not call this directly to stay state-free.
func isSingleAppMode() bool {
raw, err := core.LoadMultiAppConfig()
if err != nil || raw == nil {
return true
}
return len(raw.Apps) <= 1
}

View File

@@ -1,110 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"os"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/pflag"
)
func testStreams() BuildOption { return WithIO(os.Stdin, os.Stdout, os.Stderr) }
func TestRegisterGlobalFlags_PolicyVisible(t *testing.T) {
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
opts := &GlobalOptions{}
RegisterGlobalFlags(fs, opts)
flag := fs.Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered")
}
if flag.Hidden {
t.Fatal("profile flag should be visible when HideProfile is false")
}
}
func TestRegisterGlobalFlags_PolicyHidden(t *testing.T) {
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
opts := &GlobalOptions{HideProfile: true}
RegisterGlobalFlags(fs, opts)
flag := fs.Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered")
}
if !flag.Hidden {
t.Fatal("profile flag should be hidden when HideProfile is true")
}
if err := fs.Parse([]string{"--profile", "x"}); err != nil {
t.Fatalf("Parse() error = %v; hidden flag should still parse", err)
}
if opts.Profile != "x" {
t.Fatalf("opts.Profile = %q, want %q", opts.Profile, "x")
}
}
func TestIsSingleAppMode_NoConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if !isSingleAppMode() {
t.Fatal("isSingleAppMode() = false, want true when no config exists")
}
}
func TestIsSingleAppMode_SingleApp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
saveAppsForTest(t, []core.AppConfig{
{Name: "default", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
})
if !isSingleAppMode() {
t.Fatal("isSingleAppMode() = false, want true for single-app config")
}
}
func TestIsSingleAppMode_MultiApp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
saveAppsForTest(t, []core.AppConfig{
{Name: "a", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
{Name: "b", AppId: "cli_b", AppSecret: core.PlainSecret("y"), Brand: core.BrandFeishu},
})
if isSingleAppMode() {
t.Fatal("isSingleAppMode() = true, want false for multi-app config")
}
}
func TestBuildInternal_HideProfileOption(t *testing.T) {
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
flag := root.PersistentFlags().Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered")
}
if !flag.Hidden {
t.Fatal("profile flag should be hidden when HideProfile(true) is applied")
}
}
func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
flag := root.PersistentFlags().Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered by default")
}
if flag.Hidden {
t.Fatal("profile flag should be visible by default")
}
}
func saveAppsForTest(t *testing.T, apps []core.AppConfig) {
t.Helper()
multi := &core.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}

View File

@@ -85,11 +85,9 @@ func Execute() int {
fmt.Fprintln(os.Stderr, "Error:", err)
return 1
}
f, rootCmd := buildInternal(
context.Background(), inv,
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
)
configureFlagCompletions(os.Args)
f, rootCmd := buildInternal(context.Background(), inv)
// --- Update check (non-blocking) ---
if !isCompletionCommand(os.Args) {
@@ -153,6 +151,12 @@ func isCompletionCommand(args []string) bool {
return false
}
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
// the invocation will actually serve a __complete request.
func configureFlagCompletions(args []string) {
cmdutil.SetFlagCompletionsDisabled(!isCompletionCommand(args))
}
// handleRootError dispatches a command error to the appropriate handler
// and returns the process exit code.
func handleRootError(f *cmdutil.Factory, err error) int {
@@ -240,19 +244,10 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
}
// installTipsHelpFunc wraps the default help function to append a TIPS section
// when a command has tips set via cmdutil.SetTips. It also force-shows global
// flags that are normally hidden in single-app mode (currently --profile)
// when rendering the root command's own help, so users discovering the CLI
// still see them at `lark-cli --help`.
// when a command has tips set via cmdutil.SetTips.
func installTipsHelpFunc(root *cobra.Command) {
defaultHelp := root.HelpFunc()
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd == root {
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
f.Hidden = false
defer func() { f.Hidden = true }()
}
}
defaultHelp(cmd, args)
tips := cmdutil.GetTips(cmd)
if len(tips) == 0 {

View File

@@ -135,12 +135,10 @@ func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictM
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := cmdutil.NewDefault(nil, cmdutil.InvocationContext{Profile: profile})
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
f := cmdutil.NewDefault(
cmdutil.NewIOStreams(&bytes.Buffer{}, stdout, stderr),
cmdutil.InvocationContext{Profile: profile},
)
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}
return f, stdout, stderr
}

View File

@@ -196,3 +196,28 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
}
}
func TestConfigureFlagCompletions(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
tests := []struct {
name string
args []string
wantDisabled bool
}{
{"plain command", []string{"im", "+send"}, true},
{"help flag", []string{"im", "--help"}, true},
{"no args", []string{}, true},
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
{"completion subcommand", []string{"completion", "bash"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmdutil.SetFlagCompletionsDisabled(!tc.wantDisabled)
configureFlagCompletions(tc.args)
if got := cmdutil.FlagCompletionsDisabled(); got != tc.wantDisabled {
t.Fatalf("FlagCompletionsDisabled() = %v, want %v", got, tc.wantDisabled)
}
})
}
}

View File

@@ -375,9 +375,9 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
}
cmdutil.DisableAuthCheck(cmd)
cmd.ValidArgsFunction = completeSchemaPath(f)
cmd.ValidArgsFunction = completeSchemaPath
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
})
@@ -387,81 +387,74 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
// completeSchemaPath provides tab-completion for the schema path argument.
// It handles dotted resource names (e.g. app.table.fields) by iterating all
// resources and classifying each as a prefix-match or fully-matched.
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
parts := strings.Split(toComplete, ".")
// Level 1: complete service names
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
mode := f.ResolveStrictMode(cmd.Context())
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
afterService := strings.Join(parts[1:], ".")
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
}
}
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
}
func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string {
parts := strings.Split(toComplete, ".")
// Level 1: complete service names
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// afterService = everything user typed after "serviceName."
afterService := strings.Join(parts[1:], ".")
var completions []string
for resName, resVal := range resources {
if strings.HasPrefix(resName, afterService) {
// afterService is a prefix of this resource name → resource candidate
completions = append(completions, serviceName+"."+resName+".")
continue
}
if !strings.HasPrefix(afterService, resName+".") {
continue
}
methodPrefix := afterService[len(resName)+1:]
resMap, _ := resVal.(map[string]interface{})
if resMap == nil {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
for methodName := range methods {
if strings.HasPrefix(methodName, methodPrefix) {
completions = append(completions, serviceName+"."+resName+"."+methodName)
} else if strings.HasPrefix(afterService, resName+".") {
// This resource is fully matched; remainder is method prefix
methodPrefix := afterService[len(resName)+1:]
resMap, _ := resVal.(map[string]interface{})
if resMap == nil {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
for methodName := range methods {
if strings.HasPrefix(methodName, methodPrefix) {
completions = append(completions, serviceName+"."+resName+"."+methodName)
}
}
}
}
sort.Strings(completions)
return completions
// If all completions end with ".", user is still navigating resources → NoSpace
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
}
}
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
}
func schemaRun(opts *SchemaOptions) error {

View File

@@ -182,49 +182,3 @@ func TestHasFileFields(t *testing.T) {
})
}
}
func TestCompleteSchemaPathForSpec(t *testing.T) {
resources := map[string]interface{}{
"records": map[string]interface{}{
"methods": map[string]interface{}{
"create": map[string]interface{}{},
"list": map[string]interface{}{},
},
},
"record_permissions": map[string]interface{}{
"methods": map[string]interface{}{
"get": map[string]interface{}{},
},
},
}
got := completeSchemaPathForSpec("base", resources, "records.cr")
if len(got) != 1 || got[0] != "base.records.create" {
t.Fatalf("completions = %v, want [base.records.create]", got)
}
got = completeSchemaPathForSpec("base", resources, "record")
if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." {
t.Fatalf("resource completions = %v", got)
}
}
func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) {
spec := map[string]interface{}{
"resources": map[string]interface{}{
"records": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}},
"create": map[string]interface{}{"accessTokens": []interface{}{"user"}},
},
},
},
}
filtered := filterSpecByStrictMode(spec, core.StrictModeBot)
resources, _ := filtered["resources"].(map[string]interface{})
got := completeSchemaPathForSpec("base", resources, "records.")
if len(got) != 1 || got[0] != "base.records.list" {
t.Fatalf("filtered completions = %v, want [base.records.list]", got)
}
}

View File

@@ -24,10 +24,6 @@ import (
// RegisterServiceCommands registers all service commands from from_meta specs.
func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
RegisterServiceCommandsWithContext(context.Background(), parent, f)
}
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
for _, project := range registry.ListFromMetaProjects() {
spec := registry.LoadFromMeta(project)
if spec == nil {
@@ -42,15 +38,11 @@ func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Comma
if resources == nil {
continue
}
registerServiceWithContext(ctx, parent, spec, resources, f)
registerService(parent, spec, resources, f)
}
}
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
registerServiceWithContext(context.Background(), parent, spec, resources, f)
}
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
specName := registry.GetStrFromMap(spec, "name")
specDesc := registry.GetServiceDescription(specName, "en")
if specDesc == "" {
@@ -78,11 +70,11 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec
if resMap == nil {
continue
}
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
registerResource(svc, spec, resName, resMap, f)
}
}
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
func registerResource(parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
res := &cobra.Command{
Use: name,
Short: name + " operations",
@@ -95,7 +87,7 @@ func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spe
if methodMap == nil {
continue
}
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
registerMethod(res, spec, methodMap, methodName, name, f)
}
}
@@ -128,16 +120,12 @@ func detectFileFields(method map[string]interface{}) []string {
return cmdutil.DetectFileFields(method)
}
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
func registerMethod(parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
parent.AddCommand(NewCmdServiceMethod(f, spec, method, name, resName, nil))
}
// NewCmdServiceMethod creates a command for a dynamically registered service method.
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
}
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
desc := registry.GetStrFromMap(method, "description")
httpMethod := registry.GetStrFromMap(method, "httpMethod")
specName := registry.GetStrFromMap(spec, "name")
@@ -171,7 +159,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
}
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
@@ -189,7 +177,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
}
}
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})

View File

@@ -121,24 +121,6 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) {
}
}
func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "copy", "files", nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if !flag.Hidden {
t.Fatal("expected --as flag to be hidden in strict mode")
}
if got := flag.DefValue; got != "bot" {
t.Fatalf("default value = %q, want %q", got, "bot")
}
}
// ── NewCmdServiceMethod flags ──
func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {

View File

@@ -3,10 +3,7 @@
package credential
import (
"sort"
"sync"
)
import "sync"
var (
mu sync.Mutex
@@ -14,28 +11,12 @@ var (
)
// Register registers a credential Provider.
// Providers are consulted in priority order (lowest value first).
// Providers that implement Priority() int are sorted accordingly;
// those that do not default to priority 10.
// Providers are consulted in registration order.
// Typically called from init() via blank import.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
providers = append(providers, p)
sort.SliceStable(providers, func(i, j int) bool {
return providerPriority(providers[i]) < providerPriority(providers[j])
})
}
// providerPriority returns the priority of a provider.
// If the provider implements interface{ Priority() int }, that value is used;
// otherwise 10 is returned as the default priority.
// Lower values are consulted first.
func providerPriority(p Provider) int {
if pp, ok := p.(interface{ Priority() int }); ok {
return pp.Priority()
}
return 10
}
// Providers returns all registered providers (snapshot).

View File

@@ -37,32 +37,6 @@ func TestRegisterAndProviders(t *testing.T) {
}
}
type priorityProvider struct {
stubProvider
priority int
}
func (p *priorityProvider) Priority() int { return p.priority }
func TestRegister_PriorityOrder(t *testing.T) {
mu.Lock()
old := providers
providers = nil
mu.Unlock()
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
Register(&stubProvider{name: "env"}) // priority 10 (default)
Register(&priorityProvider{stubProvider: stubProvider{name: "sidecar"}, priority: 0}) // priority 0 (first)
got := Providers()
if len(got) != 2 {
t.Fatalf("expected 2, got %d", len(got))
}
if got[0].Name() != "sidecar" || got[1].Name() != "env" {
t.Errorf("expected sidecar before env, got %s, %s", got[0].Name(), got[1].Name())
}
}
func TestProviders_ReturnsSnapshot(t *testing.T) {
mu.Lock()
old := providers

View File

@@ -1,131 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
// Package sidecar provides a noop credential provider for the auth sidecar
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set, this provider supplies
// placeholder credentials so the CLI's auth pipeline can proceed normally.
// Real tokens are never present in the sandbox; the sidecar transport
// interceptor routes requests to the trusted sidecar process instead.
package sidecar
import (
"context"
"fmt"
"os"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
// Provider is the noop credential provider for sidecar mode.
type Provider struct{}
func (p *Provider) Name() string { return "sidecar" }
func (p *Provider) Priority() int { return 0 }
// ResolveAccount returns a minimal Account when sidecar mode is active.
// The account contains AppID and Brand from environment variables, a
// placeholder secret, and SupportedIdentities derived from STRICT_MODE.
// Returns nil, nil when sidecar mode is not active (AUTH_PROXY not set).
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
if proxyAddr == "" {
return nil, nil // not in sidecar mode, skip
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q: %v", envvars.CliAuthProxy, proxyAddr, err),
}
}
appID := os.Getenv(envvars.CliAppID)
if appID == "" {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliAppID + " is missing",
}
}
if os.Getenv(envvars.CliProxyKey) == "" {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliProxyKey + " is missing",
}
}
brand := credential.Brand(os.Getenv(envvars.CliBrand))
if brand == "" {
brand = credential.BrandFeishu
}
acct := &credential.Account{
AppID: appID,
AppSecret: credential.NoAppSecret,
Brand: brand,
}
// Parse DefaultAs
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
case "", credential.IdentityAuto:
acct.DefaultAs = id
case credential.IdentityUser, credential.IdentityBot:
acct.DefaultAs = id
default:
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
}
}
// Parse SupportedIdentities from STRICT_MODE, default to SupportsAll.
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
case "bot":
acct.SupportedIdentities = credential.SupportsBot
case "user":
acct.SupportedIdentities = credential.SupportsUser
case "off", "":
acct.SupportedIdentities = credential.SupportsAll
default:
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
}
}
return acct, nil
}
// ResolveToken returns a sentinel token whose value encodes the token type.
// The transport interceptor reads this sentinel to determine the identity
// (user vs bot), strips it, and the sidecar injects the real token.
// Returns nil, nil when sidecar mode is not active.
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
if os.Getenv(envvars.CliAuthProxy) == "" {
return nil, nil
}
var sentinel string
switch req.Type {
case credential.TokenTypeUAT:
sentinel = sidecar.SentinelUAT
case credential.TokenTypeTAT:
sentinel = sidecar.SentinelTAT
default:
return nil, nil
}
return &credential.Token{
Value: sentinel,
Scopes: "", // empty → scope pre-check is skipped
Source: "sidecar",
}, nil
}
func init() {
credential.Register(&Provider{})
}

View File

@@ -1,188 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package sidecar
import (
"context"
"os"
"testing"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
func setEnv(t *testing.T, key, value string) {
t.Helper()
old, hadOld := os.LookupEnv(key)
os.Setenv(key, value)
t.Cleanup(func() {
if hadOld {
os.Setenv(key, old)
} else {
os.Unsetenv(key)
}
})
}
func unsetEnv(t *testing.T, key string) {
t.Helper()
old, hadOld := os.LookupEnv(key)
os.Unsetenv(key)
t.Cleanup(func() {
if hadOld {
os.Setenv(key, old)
}
})
}
func TestResolveAccount_NotActive(t *testing.T) {
unsetEnv(t, envvars.CliAuthProxy)
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if acct != nil {
t.Fatal("expected nil account when AUTH_PROXY not set")
}
}
func TestResolveAccount_Active(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envvars.CliAppID, "cli_test123")
setEnv(t, envvars.CliBrand, "lark")
unsetEnv(t, envvars.CliDefaultAs)
unsetEnv(t, envvars.CliStrictMode)
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if acct == nil {
t.Fatal("expected non-nil account")
}
if acct.AppID != "cli_test123" {
t.Errorf("AppID = %q, want %q", acct.AppID, "cli_test123")
}
if acct.Brand != credential.BrandLark {
t.Errorf("Brand = %q, want %q", acct.Brand, credential.BrandLark)
}
if acct.AppSecret != credential.NoAppSecret {
t.Errorf("AppSecret should be NoAppSecret, got %q", acct.AppSecret)
}
if acct.SupportedIdentities != credential.SupportsAll {
t.Errorf("SupportedIdentities = %d, want %d (SupportsAll)", acct.SupportedIdentities, credential.SupportsAll)
}
}
func TestResolveAccount_MissingProxyKey(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
unsetEnv(t, envvars.CliProxyKey)
setEnv(t, envvars.CliAppID, "cli_test")
p := &Provider{}
_, err := p.ResolveAccount(context.Background())
if err == nil {
t.Fatal("expected error when PROXY_KEY is missing")
}
if _, ok := err.(*credential.BlockError); !ok {
t.Fatalf("expected BlockError, got %T: %v", err, err)
}
}
func TestResolveAccount_MissingAppID(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
unsetEnv(t, envvars.CliAppID)
p := &Provider{}
_, err := p.ResolveAccount(context.Background())
if err == nil {
t.Fatal("expected error when APP_ID is missing")
}
if _, ok := err.(*credential.BlockError); !ok {
t.Fatalf("expected BlockError, got %T: %v", err, err)
}
}
func TestResolveAccount_StrictMode(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envvars.CliAppID, "cli_test")
tests := []struct {
mode string
want credential.IdentitySupport
}{
{"bot", credential.SupportsBot},
{"user", credential.SupportsUser},
{"off", credential.SupportsAll},
{"", credential.SupportsAll},
}
p := &Provider{}
for _, tt := range tests {
t.Run("strict_"+tt.mode, func(t *testing.T) {
if tt.mode == "" {
unsetEnv(t, envvars.CliStrictMode)
} else {
setEnv(t, envvars.CliStrictMode, tt.mode)
}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if acct.SupportedIdentities != tt.want {
t.Errorf("SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.want)
}
})
}
}
func TestResolveToken_NotActive(t *testing.T) {
unsetEnv(t, envvars.CliAuthProxy)
p := &Provider{}
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tok != nil {
t.Fatal("expected nil token when AUTH_PROXY not set")
}
}
func TestResolveToken_Sentinels(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
p := &Provider{}
// UAT
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
if err != nil {
t.Fatalf("UAT: unexpected error: %v", err)
}
if tok.Value != sidecar.SentinelUAT {
t.Errorf("UAT value = %q, want %q", tok.Value, sidecar.SentinelUAT)
}
if tok.Scopes != "" {
t.Errorf("UAT scopes should be empty, got %q", tok.Scopes)
}
// TAT
tok, err = p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
if err != nil {
t.Fatalf("TAT: unexpected error: %v", err)
}
if tok.Value != sidecar.SentinelTAT {
t.Errorf("TAT value = %q, want %q", tok.Value, sidecar.SentinelTAT)
}
}

View File

@@ -1,51 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"errors"
"fmt"
)
// ErrAborted is a sentinel matched by errors.Is on any extension-triggered
// round-trip abort. Callers that only need to know whether an error was
// caused by an extension interception should use:
//
// if errors.Is(err, transport.ErrAborted) { ... }
var ErrAborted = errors.New("round trip aborted by extension")
// AbortError is returned by the built-in middleware when an AbortableInterceptor
// short-circuits a request via PreRoundTripE. It wraps the extension's original
// reason and carries the extension's Provider.Name() for traceability.
//
// Use errors.As to recover the typed error:
//
// var aErr *transport.AbortError
// if errors.As(err, &aErr) {
// log.Printf("blocked by %s: %v", aErr.Extension, aErr.Reason)
// }
//
// errors.Is(err, transport.ErrAborted) also works, and errors.Is against the
// inner reason still works via Unwrap.
type AbortError struct {
// Extension is the name of the Provider whose interceptor aborted the
// request (from Provider.Name()). May be empty if the provider did not
// supply a name.
Extension string
// Reason is the original non-nil error returned by PreRoundTripE.
Reason error
}
func (e *AbortError) Error() string {
if e.Extension != "" {
return fmt.Sprintf("extension %q aborted round trip: %v", e.Extension, e.Reason)
}
return fmt.Sprintf("extension aborted round trip: %v", e.Reason)
}
// Unwrap lets errors.Is / errors.As traverse to the underlying Reason.
func (e *AbortError) Unwrap() error { return e.Reason }
// Is enables errors.Is(err, ErrAborted) at any nesting depth.
func (e *AbortError) Is(target error) bool { return target == ErrAborted }

View File

@@ -1,103 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"errors"
"fmt"
"testing"
)
func TestAbortError_Error(t *testing.T) {
tests := []struct {
name string
err *AbortError
want string
}{
{
name: "with extension name",
err: &AbortError{Extension: "audit", Reason: errors.New("bad")},
want: `extension "audit" aborted round trip: bad`,
},
{
name: "without extension name",
err: &AbortError{Reason: errors.New("bad")},
want: "extension aborted round trip: bad",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.err.Error(); got != tt.want {
t.Fatalf("Error() = %q, want %q", got, tt.want)
}
})
}
}
func TestAbortError_Unwrap(t *testing.T) {
reason := errors.New("bad")
e := &AbortError{Reason: reason}
if got := e.Unwrap(); got != reason {
t.Fatalf("Unwrap() = %v, want %v", got, reason)
}
}
func TestAbortError_IsErrAborted(t *testing.T) {
e := &AbortError{Reason: errors.New("bad")}
if !errors.Is(e, ErrAborted) {
t.Fatal("errors.Is(e, ErrAborted) = false, want true")
}
// Sanity: not matched by unrelated sentinels.
if errors.Is(e, errors.New("other")) {
t.Fatal("errors.Is matched unrelated sentinel")
}
}
func TestAbortError_UnwrapReachesInnerSentinel(t *testing.T) {
// Extensions often return typed/sentinel errors; callers should still be
// able to errors.Is against those after the middleware wraps them.
innerSentinel := errors.New("policy-deny-42")
e := &AbortError{Reason: fmt.Errorf("wrapped: %w", innerSentinel)}
if !errors.Is(e, innerSentinel) {
t.Fatal("errors.Is(e, innerSentinel) = false, want true (Unwrap chain broken)")
}
}
func TestAbortError_As(t *testing.T) {
reason := errors.New("bad")
base := &AbortError{Extension: "audit", Reason: reason}
// Direct As.
var aErr *AbortError
if !errors.As(base, &aErr) {
t.Fatal("errors.As(base, *AbortError) = false")
}
if aErr.Extension != "audit" || aErr.Reason != reason {
t.Fatalf("aErr = %+v, want {audit, bad}", aErr)
}
// Nested As: even when the *AbortError is wrapped in another error,
// errors.As must still find it via Unwrap chain.
wrapped := fmt.Errorf("outer: %w", base)
var aErr2 *AbortError
if !errors.As(wrapped, &aErr2) {
t.Fatal("errors.As(wrapped, *AbortError) = false")
}
if aErr2 != base {
t.Fatalf("aErr2 = %p, want %p", aErr2, base)
}
// errors.Is still matches the sentinel through the outer wrapper.
if !errors.Is(wrapped, ErrAborted) {
t.Fatal("errors.Is(wrapped, ErrAborted) = false via nested wrap")
}
}
func TestErrAborted_IsItselfSentinel(t *testing.T) {
// Guard against accidental re-assignment of ErrAborted: a bare ErrAborted
// value should still satisfy errors.Is(err, ErrAborted) for symmetry.
if !errors.Is(ErrAborted, ErrAborted) {
t.Fatal("errors.Is(ErrAborted, ErrAborted) = false")
}
}

View File

@@ -1,178 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
// Package sidecar provides a transport interceptor for the auth sidecar
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
// outgoing requests are rewritten to the sidecar address. The interceptor
// strips placeholder credentials, injects proxy headers, and signs each
// request with HMAC-SHA256. No custom DialContext is needed — Go's
// standard http.Transport connects to the sidecar via plain HTTP.
package sidecar
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
// Provider implements transport.Provider for the sidecar mode.
type Provider struct{}
func (p *Provider) Name() string { return "sidecar" }
// ResolveInterceptor returns a SidecarInterceptor when sidecar mode is active.
// Returns nil when sidecar mode is disabled or the proxy address is invalid;
// in the latter case a warning is emitted to stderr and requests fall back to
// the non-sidecar transport path (where the credential layer will typically
// block them for lack of a valid account).
func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
if proxyAddr == "" {
return nil
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envvars.CliAuthProxy, err)
return nil
}
key := os.Getenv(envvars.CliProxyKey)
return &Interceptor{
key: []byte(key),
sidecarHost: sidecar.ProxyHost(proxyAddr),
}
}
// Interceptor rewrites requests for the sidecar proxy.
type Interceptor struct {
key []byte // HMAC signing key
sidecarHost string // sidecar host:port for URL rewriting
}
// PreRoundTrip rewrites the request for sidecar routing when it carries a
// sentinel token. Requests without a sentinel token (e.g. pre-signed download
// URLs) are passed through unmodified.
//
// Supports two auth patterns:
// - Standard OpenAPI: Authorization: Bearer <sentinel>
// - MCP protocol: X-Lark-MCP-UAT/TAT: <sentinel>
func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response, err error) {
identity, authHeader := detectSentinel(req)
if identity == "" {
return nil // not a sidecar-managed request, pass through
}
// 1. Buffer the body first, before mutating any request state. A partial
// read would sign a truncated body and cause a misleading HMAC mismatch
// on the sidecar side; bail out early and let the request fall through
// unmodified so the credential layer can surface an actionable error.
var bodyBytes []byte
if req.Body != nil {
var err error
bodyBytes, err = io.ReadAll(req.Body)
_ = req.Body.Close() // release original body (fd/pipe/etc.) after buffering
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: sidecar interceptor failed to read request body: %v\n", err)
return nil
}
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
if req.GetBody != nil {
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
}
}
}
// 2. Save original target (scheme://host)
originalScheme := "https"
if req.URL.Scheme != "" {
originalScheme = req.URL.Scheme
}
originalHost := req.URL.Host
req.Header.Set(sidecar.HeaderProxyTarget, originalScheme+"://"+originalHost)
// 3. Set identity and tell sidecar which header to inject real token into
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
// 4. Strip placeholder auth header(s)
req.Header.Del("Authorization")
req.Header.Del(sidecar.HeaderMCPUAT)
req.Header.Del(sidecar.HeaderMCPTAT)
bodySHA := sidecar.BodySHA256(bodyBytes)
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
pathAndQuery := req.URL.RequestURI()
ts := sidecar.Timestamp()
// Cover identity and authHeader in the signature so an on-path attacker
// within the replay window cannot flip the injected token's identity or
// redirect the token into a different header.
sig := sidecar.Sign(i.key, sidecar.CanonicalRequest{
Version: sidecar.ProtocolV1,
Method: req.Method,
Host: originalHost,
PathAndQuery: pathAndQuery,
BodySHA256: bodySHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
})
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
req.Header.Set(sidecar.HeaderProxySignature, sig)
// 5. Rewrite URL to route through sidecar
req.URL.Scheme = "http"
req.URL.Host = i.sidecarHost
return nil // no post-hook needed
}
// detectSentinel checks both standard Authorization and MCP auth headers for
// sentinel tokens. Returns the identity ("user"/"bot") and the header name
// that carried the sentinel.
//
// Returns ("", "") when the request carries no sentinel token — typically
// requests that require no auth (e.g. pre-signed download URLs where the
// token is embedded in the URL query parameters).
func detectSentinel(req *http.Request) (identity, authHeader string) {
// Check standard Authorization: Bearer <sentinel>
if auth := req.Header.Get("Authorization"); auth != "" {
token := strings.TrimPrefix(auth, "Bearer ")
switch token {
case sidecar.SentinelUAT:
return sidecar.IdentityUser, "Authorization"
case sidecar.SentinelTAT:
return sidecar.IdentityBot, "Authorization"
}
}
// Check MCP headers: X-Lark-MCP-UAT/TAT: <sentinel>
if v := req.Header.Get(sidecar.HeaderMCPUAT); v == sidecar.SentinelUAT {
return sidecar.IdentityUser, sidecar.HeaderMCPUAT
}
if v := req.Header.Get(sidecar.HeaderMCPTAT); v == sidecar.SentinelTAT {
return sidecar.IdentityBot, sidecar.HeaderMCPTAT
}
return "", ""
}
func init() {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
if proxyAddr == "" {
return
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envvars.CliAuthProxy, err)
return
}
transport.Register(&Provider{})
}

View File

@@ -1,265 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package sidecar
import (
"bytes"
"errors"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/sidecar"
)
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
type failingBody struct {
err error
closed bool
readCall bool
}
func (b *failingBody) Read(p []byte) (int, error) {
b.readCall = true
return 0, b.err
}
func (b *failingBody) Close() error {
b.closed = true
return nil
}
func TestInterceptor_PreRoundTrip(t *testing.T) {
key := []byte("test-key-for-hmac-signing-32byte!")
interceptor := &Interceptor{key: key, sidecarHost: "127.0.0.1:16384"}
body := []byte(`{"msg":"hello"}`)
req, _ := http.NewRequest("POST", "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", io.NopCloser(bytes.NewReader(body)))
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
req.Header.Set("X-Cli-Source", "lark-cli")
post := interceptor.PreRoundTrip(req)
if post != nil {
t.Error("expected nil post hook")
}
// URL should be rewritten to sidecar
if req.URL.Scheme != "http" {
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "http")
}
if req.URL.Host != "127.0.0.1:16384" {
t.Errorf("host = %q, want %q", req.URL.Host, "127.0.0.1:16384")
}
// Original target should be preserved
target := req.Header.Get(sidecar.HeaderProxyTarget)
if target != "https://open.feishu.cn" {
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
}
// Identity should be user (from SentinelUAT)
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
}
// Authorization should be stripped
if auth := req.Header.Get("Authorization"); auth != "" {
t.Errorf("Authorization header should be stripped, got %q", auth)
}
// HMAC headers should be set
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
t.Error("signature header should be set")
}
if ts := req.Header.Get(sidecar.HeaderProxyTimestamp); ts == "" {
t.Error("timestamp header should be set")
}
if sha := req.Header.Get(sidecar.HeaderBodySHA256); sha == "" {
t.Error("body SHA256 header should be set")
}
if v := req.Header.Get(sidecar.HeaderProxyVersion); v != sidecar.ProtocolV1 {
t.Errorf("version header = %q, want %q", v, sidecar.ProtocolV1)
}
// Non-proxy headers should be preserved
if src := req.Header.Get("X-Cli-Source"); src != "lark-cli" {
t.Errorf("X-Cli-Source should be preserved, got %q", src)
}
// Body should still be readable
readBody, _ := io.ReadAll(req.Body)
if !bytes.Equal(readBody, body) {
t.Errorf("body should be preserved after PreRoundTrip")
}
}
func TestInterceptor_BotIdentity(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/calendar/v4/events", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
interceptor.PreRoundTrip(req)
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
}
}
func TestInterceptor_NonSentinelToken_PassThrough(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
origURL := "https://some-cdn.example.com/presigned-download?token=abc"
req, _ := http.NewRequest("GET", origURL, nil)
req.Header.Set("Authorization", "Bearer some-real-token")
post := interceptor.PreRoundTrip(req)
// Should NOT be rewritten — no sentinel token
if post != nil {
t.Error("expected nil post hook for pass-through")
}
if req.URL.String() != origURL {
t.Errorf("URL should be unchanged, got %q", req.URL.String())
}
if req.Header.Get(sidecar.HeaderProxyTarget) != "" {
t.Error("proxy target header should not be set for pass-through")
}
if req.Header.Get("Authorization") != "Bearer some-real-token" {
t.Error("Authorization should be preserved for pass-through")
}
}
func TestInterceptor_NoAuth_PassThrough(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
origURL := "https://cdn.feishu.cn/download/file"
req, _ := http.NewRequest("GET", origURL, nil)
interceptor.PreRoundTrip(req)
// No Authorization header at all — should pass through
if req.URL.String() != origURL {
t.Errorf("URL should be unchanged for no-auth request, got %q", req.URL.String())
}
}
func TestInterceptor_MCP_UAT(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{"jsonrpc":"2.0"}`)))
req.Header.Set(sidecar.HeaderMCPUAT, sidecar.SentinelUAT)
interceptor.PreRoundTrip(req)
// Should be intercepted and rewritten
if req.URL.Host != "127.0.0.1:16384" {
t.Errorf("host = %q, want sidecar host", req.URL.Host)
}
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
}
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPUAT {
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPUAT)
}
// MCP sentinel should be stripped
if v := req.Header.Get(sidecar.HeaderMCPUAT); v != "" {
t.Errorf("MCP-UAT should be stripped, got %q", v)
}
}
func TestInterceptor_MCP_TAT(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{}`)))
req.Header.Set(sidecar.HeaderMCPTAT, sidecar.SentinelTAT)
interceptor.PreRoundTrip(req)
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
}
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPTAT {
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPTAT)
}
}
func TestInterceptor_StandardAuth_SetsAuthorizationHeader(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
interceptor.PreRoundTrip(req)
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != "Authorization" {
t.Errorf("auth header = %q, want %q", ah, "Authorization")
}
}
// TestInterceptor_BodyReadError verifies that when io.ReadAll on the request
// body fails partway, PreRoundTrip skips the rewrite entirely rather than
// signing a truncated body (which would produce a misleading HMAC mismatch on
// the sidecar side) and releases the original body.
func TestInterceptor_BodyReadError(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
const origURL = "https://open.feishu.cn/open-apis/im/v1/messages"
body := &failingBody{err: errors.New("disk gremlin")}
req, _ := http.NewRequest("POST", origURL, body)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
post := interceptor.PreRoundTrip(req)
if post != nil {
t.Error("expected nil post hook on body read failure")
}
// Original body must be closed to avoid leaking fd/pipe-like resources.
if !body.readCall {
t.Error("expected ReadAll to have attempted reading from the body")
}
if !body.closed {
t.Error("expected original body to be Close()'d after read failure")
}
// URL must NOT be rewritten — request should fall through to the next
// layer (credential) which can surface a meaningful error.
if req.URL.String() != origURL {
t.Errorf("URL should be unchanged on read failure, got %q", req.URL.String())
}
// No proxy/HMAC headers should leak onto the request.
for _, h := range []string{
sidecar.HeaderProxyVersion,
sidecar.HeaderProxyTarget,
sidecar.HeaderProxySignature,
sidecar.HeaderProxyTimestamp,
sidecar.HeaderBodySHA256,
sidecar.HeaderProxyIdentity,
sidecar.HeaderProxyAuthHeader,
} {
if v := req.Header.Get(h); v != "" {
t.Errorf("%s should not be set on read failure, got %q", h, v)
}
}
}
func TestInterceptor_EmptyBody(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("GET", "https://open.feishu.cn/path", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
interceptor.PreRoundTrip(req)
sha := req.Header.Get(sidecar.HeaderBodySHA256)
expectedEmpty := sidecar.BodySHA256(nil)
if sha != expectedEmpty {
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
}
}

View File

@@ -27,31 +27,6 @@ type Provider interface {
//
// The returned function (if non-nil) is called after the built-in chain
// completes. Use it for logging, ending trace spans, or recording metrics.
//
// Body note: the middleware Clones the caller's request before invoking the
// interceptor, which copies headers/URL/etc. but shares the underlying
// io.ReadCloser. Extensions that read req.Body are responsible for restoring
// a replayable body (e.g. via req.GetBody) before returning, otherwise the
// built-in chain will see an exhausted stream.
type Interceptor interface {
PreRoundTrip(req *http.Request) func(resp *http.Response, err error)
}
// AbortableInterceptor is an optional extension of Interceptor that lets an
// extension reject a request before the built-in chain runs. Extensions that
// implement this interface are detected by the built-in middleware via a
// type assertion; both methods must be present, but when an extension
// implements PreRoundTripE the middleware will NOT call PreRoundTrip.
//
// Returning a non-nil error from PreRoundTripE aborts the request: the
// built-in chain is not executed and the middleware returns an *AbortError
// wrapping the reason. The returned post function (if non-nil) is still
// invoked with (nil, reason) so that extensions can unwind any state they
// created in the pre hook (spans, metrics, audit records).
//
// Extensions that only care about the abortable variant can provide a no-op
// PreRoundTrip method alongside PreRoundTripE to satisfy Interceptor.
type AbortableInterceptor interface {
Interceptor
PreRoundTripE(req *http.Request) (post func(resp *http.Response, err error), err error)
}

View File

@@ -200,7 +200,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
errStr := getStr(data, "error")
if errStr == "" && getStr(data, "access_token") != "" {
fmt.Fprintf(errOut, "[lark-cli] device-flow: token response received\n")
fmt.Fprintf(errOut, "[lark-cli] device-flow: token obtained successfully\n")
refreshToken := getStr(data, "refresh_token")
tokenExpiresIn := getInt(data, "expires_in", 7200)
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 604800)

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"sync/atomic"
"github.com/spf13/cobra"
)
// Cobra keeps completion callbacks in a package-global map keyed by
// *pflag.Flag with no removal path, so registrations made for a *cobra.Command
// outlive the command itself. Skip registration when the current invocation
// will not serve a completion request.
var flagCompletionsDisabled atomic.Bool
// SetFlagCompletionsDisabled switches RegisterFlagCompletion between
// registering and no-op. Typically set once at process start.
func SetFlagCompletionsDisabled(disabled bool) {
flagCompletionsDisabled.Store(disabled)
}
// FlagCompletionsDisabled reports the current switch state.
func FlagCompletionsDisabled() bool {
return flagCompletionsDisabled.Load()
}
// RegisterFlagCompletion wraps (*cobra.Command).RegisterFlagCompletionFunc
// and honors the package switch. The underlying error is swallowed to match
// the `_ = cmd.RegisterFlagCompletionFunc(...)` style already used here.
func RegisterFlagCompletion(cmd *cobra.Command, flagName string, fn cobra.CompletionFunc) {
if flagCompletionsDisabled.Load() {
return
}
_ = cmd.RegisterFlagCompletionFunc(flagName, fn)
}

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"runtime"
"sync/atomic"
"testing"
"time"
"github.com/spf13/cobra"
)
func TestSetFlagCompletionsDisabled_RoundTrip(t *testing.T) {
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
if FlagCompletionsDisabled() {
t.Fatal("expected default false")
}
SetFlagCompletionsDisabled(true)
if !FlagCompletionsDisabled() {
t.Fatal("expected true after Set(true)")
}
SetFlagCompletionsDisabled(false)
if FlagCompletionsDisabled() {
t.Fatal("expected false after Set(false)")
}
}
// When disabled, a *cobra.Command must be collectable after the caller drops
// its reference — i.e. the wrapper did not touch cobra's global map.
func TestRegisterFlagCompletion_Disabled_DoesNotRetainCommand(t *testing.T) {
SetFlagCompletionsDisabled(true)
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
const N = 5
var collected atomic.Int32
func() {
for range N {
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("foo", "", "")
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
})
runtime.SetFinalizer(cmd, func(_ *cobra.Command) { collected.Add(1) })
}
}()
// Finalizers run on a dedicated goroutine after GC; loop to give it time.
for range 30 {
runtime.GC()
time.Sleep(20 * time.Millisecond)
}
if got := collected.Load(); int(got) != N {
t.Fatalf("expected %d *cobra.Command finalizers to fire when completions disabled, got %d", N, got)
}
}
// When enabled, the registered completion must be reachable via cobra.
func TestRegisterFlagCompletion_Enabled_DoesRegister(t *testing.T) {
SetFlagCompletionsDisabled(false)
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("foo", "", "")
want := []cobra.Completion{"a", "b"}
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
return want, cobra.ShellCompDirectiveNoFileComp
})
fn, ok := cmd.GetFlagCompletionFunc("foo")
if !ok {
t.Fatal("expected completion func to be registered")
}
got, _ := fn(cmd, nil, "")
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("unexpected completion result: %v", got)
}
}

View File

@@ -33,7 +33,9 @@ import (
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
if streams == nil {
streams = SystemIO()
}
f := &Factory{
Keychain: keychain.Default(),
Invocation: inv,
@@ -92,7 +94,7 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
util.WarnIfProxied(f.IOStreams.ErrOut)
var transport http.RoundTripper = util.SharedTransport()
var transport http.RoundTripper = util.NewBaseTransport()
transport = &RetryTransport{Base: transport}
transport = &SecurityHeaderTransport{Base: transport}
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
@@ -129,7 +131,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = util.SharedTransport()
var sdkTransport http.RoundTripper = util.NewBaseTransport()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}

View File

@@ -6,10 +6,14 @@ package cmdutil
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
_ "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/extension/fileio"
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
@@ -116,6 +120,22 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi
}
}
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
ua, ok := sec.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
}
func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
t.Setenv(envvars.CliAppID, "env-app")
t.Setenv(envvars.CliAppSecret, "env-secret")
@@ -212,3 +232,170 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.
t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls)
}
}
type stubTransportProvider struct {
interceptor exttransport.Interceptor
}
func (s *stubTransportProvider) Name() string { return "stub" }
func (s *stubTransportProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
if s.interceptor != nil {
return s.interceptor
}
return &stubTransportImpl{}
}
type stubTransportImpl struct{}
func (s *stubTransportImpl) PreRoundTrip(req *http.Request) func(*http.Response, error) {
return nil
}
// headerCapturingInterceptor sets custom headers in PreRoundTrip and records
// whether PostRoundTrip was called, to verify execution order.
type headerCapturingInterceptor struct {
preCalled bool
postCalled bool
}
func (h *headerCapturingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
h.preCalled = true
// Set a custom header that should survive (no built-in override)
req.Header.Set("X-Custom-Trace", "ext-trace-123")
// Try to override a security header — should be overwritten by SecurityHeaderTransport
req.Header.Set(HeaderSource, "ext-tampered")
return func(resp *http.Response, err error) {
h.postCalled = true
}
}
func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
var receivedHeaders http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ic := &headerCapturingInterceptor{}
exttransport.Register(&stubTransportProvider{interceptor: ic})
t.Cleanup(func() { exttransport.Register(nil) })
// Use HTTP transport chain (has SecurityHeaderTransport)
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &SecurityHeaderTransport{Base: base}
transport := wrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
// PreRoundTrip was called
if !ic.preCalled {
t.Fatal("PreRoundTrip was not called")
}
// PostRoundTrip (closure) was called
if !ic.postCalled {
t.Fatal("PostRoundTrip closure was not called")
}
// Custom header set by extension survives (no built-in override)
if got := receivedHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
t.Fatalf("X-Custom-Trace = %q, want %q", got, "ext-trace-123")
}
// Security header overridden by extension is restored by SecurityHeaderTransport
if got := receivedHeaders.Get(HeaderSource); got != SourceValue {
t.Fatalf("%s = %q, want %q (built-in should override extension)", HeaderSource, got, SourceValue)
}
}
func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
type ctxKeyType string
const testKey ctxKeyType = "original"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
var ctxValue any
// Use a custom transport that captures the context value seen by the built-in chain
capturer := roundTripFunc(func(req *http.Request) (*http.Response, error) {
ctxValue = req.Context().Value(testKey)
return http.DefaultTransport.RoundTrip(req)
})
// Interceptor that tries to tamper with context
tamperIC := interceptorFunc(func(req *http.Request) func(*http.Response, error) {
// Try to replace context with a new one
*req = *req.WithContext(context.WithValue(req.Context(), testKey, "tampered"))
return nil
})
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
origCtx := context.WithValue(context.Background(), testKey, "original")
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
resp, err := mid.RoundTrip(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
// Built-in chain should see original context, not tampered
if ctxValue != "original" {
t.Fatalf("built-in chain saw context value %q, want %q", ctxValue, "original")
}
}
// interceptorFunc adapts a function to exttransport.Interceptor.
type interceptorFunc func(*http.Request) func(*http.Response, error)
func (f interceptorFunc) PreRoundTrip(req *http.Request) func(*http.Response, error) { return f(req) }
func TestBuildSDKTransport_WithExtension(t *testing.T) {
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
transport := buildSDKTransport()
// Chain: extensionMiddleware → SecurityPolicy → UserAgent → Retry → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
}
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
}
ua, ok := sec.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("transport type = %T, want *UserAgentTransport", sec.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
exttransport.Register(nil)
transport := buildSDKTransport()
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
ua, ok := sec.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
}

View File

@@ -1,68 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
)
// AddAPIIdentityFlag registers the standard --as flag shape used by api/service commands.
func AddAPIIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, target *string) {
addIdentityFlag(ctx, cmd, f, target, identityFlagConfig{
defaultValue: "auto",
usage: "identity type: user | bot | auto (default)",
completionValues: []string{"user", "bot"},
})
}
// AddShortcutIdentityFlag registers the standard --as flag shape used by shortcuts.
func AddShortcutIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, authTypes []string) {
if len(authTypes) == 0 {
authTypes = []string{"user"}
}
addIdentityFlag(ctx, cmd, f, nil, identityFlagConfig{
defaultValue: authTypes[0],
usage: "identity type: " + strings.Join(authTypes, " | "),
completionValues: authTypes,
})
}
type identityFlagConfig struct {
defaultValue string
usage string
completionValues []string
}
// addIdentityFlag centralizes --as registration and strict-mode UX.
// When strict mode is active, the flag is still accepted for compatibility
// but hidden from help/completion and locked to the forced identity by default.
func addIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, target *string, cfg identityFlagConfig) {
if forced := f.ResolveStrictMode(ctx).ForcedIdentity(); forced != "" {
// Keep registering --as in strict mode even though it is hidden.
// This preserves parser compatibility for existing invocations that still pass
// --as, and keeps downstream GetString("as") / ResolveAs paths stable.
// The usage text below is effectively placeholder text because the flag is hidden.
registerIdentityFlag(cmd, target, string(forced),
fmt.Sprintf("identity locked to %s by strict mode (admin-managed)", forced))
_ = cmd.Flags().MarkHidden("as")
return
}
registerIdentityFlag(cmd, target, cfg.defaultValue, cfg.usage)
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return cfg.completionValues, cobra.ShellCompDirectiveNoFileComp
})
}
func registerIdentityFlag(cmd *cobra.Command, target *string, defaultValue, usage string) {
if target != nil {
cmd.Flags().StringVar(target, "as", defaultValue, usage)
return
}
cmd.Flags().String("as", defaultValue, usage)
}

View File

@@ -1,68 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
func TestAddAPIIdentityFlag_NonStrictMode(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := &cobra.Command{Use: "test"}
AddAPIIdentityFlag(context.Background(), cmd, f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if flag.Hidden {
t.Fatal("expected --as flag to be visible outside strict mode")
}
if got := flag.DefValue; got != "auto" {
t.Fatalf("default value = %q, want %q", got, "auto")
}
}
func TestAddAPIIdentityFlag_StrictModeHidesFlagAndLocksDefault(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{
AppID: "a", AppSecret: "s", SupportedIdentities: 2,
})
cmd := &cobra.Command{Use: "test"}
AddAPIIdentityFlag(context.Background(), cmd, f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if !flag.Hidden {
t.Fatal("expected --as flag to be hidden in strict mode")
}
if got := flag.DefValue; got != "bot" {
t.Fatalf("default value = %q, want %q", got, "bot")
}
}
func TestAddShortcutIdentityFlag_UsesAuthTypes(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := &cobra.Command{Use: "test"}
AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"bot"})
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if flag.Hidden {
t.Fatal("expected --as flag to be visible outside strict mode")
}
if got := flag.DefValue; got != "bot" {
t.Fatalf("default value = %q, want %q", got, "bot")
}
}

View File

@@ -20,44 +20,12 @@ type IOStreams struct {
IsTerminal bool
}
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
// IsTerminal is derived from in's underlying *os.File, if any; non-file
// readers (bytes.Buffer, strings.Reader, …) yield IsTerminal=false.
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
isTerminal := false
if f, ok := in.(*os.File); ok {
isTerminal = term.IsTerminal(int(f.Fd()))
}
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
//
//nolint:forbidigo // entry point for real stdio
func SystemIO() *IOStreams {
return NewIOStreams(os.Stdin, os.Stdout, os.Stderr)
}
// normalizeStreams returns a fresh IOStreams with any nil field filled from
// SystemIO(). Callers constructing a partial struct like &IOStreams{Out: buf}
// get a usable result without nil writers leaking into RoundTripper warnings,
// Cobra I/O, or credential-provider error paths.
func normalizeStreams(s *IOStreams) *IOStreams {
if s == nil {
return SystemIO()
return &IOStreams{
In: os.Stdin, //nolint:forbidigo // entry point for real stdio
Out: os.Stdout, //nolint:forbidigo // entry point for real stdio
ErrOut: os.Stderr, //nolint:forbidigo // entry point for real stdio
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())), //nolint:forbidigo // need Fd() for terminal check
}
out := *s
if out.In == nil || out.Out == nil || out.ErrOut == nil {
sys := SystemIO()
if out.In == nil {
out.In = sys.In
}
if out.Out == nil {
out.Out = sys.Out
}
if out.ErrOut == nil {
out.ErrOut = sys.ErrOut
}
}
return &out
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io"
"net/http"
"strings"
"testing"
"time"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestRetryTransport_NoRetry(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})
rt := &RetryTransport{Base: base, MaxRetries: 0}
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
if calls != 1 {
t.Errorf("expected 1 call, got %d", calls)
}
}
func TestRetryTransport_RetryOn500(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
if calls < 3 {
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})
rt := &RetryTransport{Base: base, MaxRetries: 3, Delay: 1 * time.Millisecond}
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200 after retries, got %d", resp.StatusCode)
}
if calls != 3 {
t.Errorf("expected 3 calls, got %d", calls)
}
}
func TestRetryTransport_DefaultNoRetry(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
})
rt := &RetryTransport{Base: base} // default MaxRetries=0
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 500 {
t.Errorf("expected 500 with no retries, got %d", resp.StatusCode)
}
if calls != 1 {
t.Errorf("expected 1 call with default config, got %d", calls)
}
}

View File

@@ -104,47 +104,20 @@ func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response,
}
// extensionMiddleware wraps the built-in transport chain with pre/post hooks.
// The built-in chain always executes unless the extension is an
// exttransport.AbortableInterceptor and its PreRoundTripE returns a non-nil
// error; it cannot otherwise be skipped or overridden.
//
// The original request context is restored after the pre hook to prevent
// The built-in chain always executes and cannot be skipped or overridden.
// The original request context is restored after PreRoundTrip to prevent
// extensions from tampering with cancellation, deadlines, or built-in values.
// Cloning the request isolates header/URL/etc. mutations from the caller's
// request object; req.Body is intentionally shared — extensions that consume
// it are responsible for rewinding (see Interceptor doc).
type extensionMiddleware struct {
Base http.RoundTripper
Ext exttransport.Interceptor
ExtName string // Provider.Name(), captured at wrap time for *AbortError.Extension
Base http.RoundTripper
Ext exttransport.Interceptor
}
// RoundTrip invokes the interceptor pre hook, restores the original context,
// executes the built-in chain (unless aborted), then calls the post hook if
// non-nil. When the extension implements AbortableInterceptor and returns a
// non-nil error from PreRoundTripE, the built-in chain is skipped and an
// *exttransport.AbortError is returned; the post hook is still invoked with
// (nil, reason) so extensions can unwind resources.
// RoundTrip calls PreRoundTrip, restores the original context, executes
// the built-in chain, then calls the post hook if non-nil.
func (m *extensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
origCtx := req.Context()
req = req.Clone(origCtx)
var (
post func(*http.Response, error)
abortEr error
)
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
post, abortEr = a.PreRoundTripE(req)
} else {
post = m.Ext.PreRoundTrip(req)
}
if abortEr != nil {
if post != nil {
post(nil, abortEr)
}
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortEr}
}
req = req.Clone(origCtx) // isolate caller's request before extension mutations
post := m.Ext.PreRoundTrip(req)
req = req.WithContext(origCtx) // restore original context
resp, err := m.Base.RoundTrip(req)
if post != nil {
@@ -164,5 +137,5 @@ func wrapWithExtension(transport http.RoundTripper) http.RoundTripper {
if tr == nil {
return transport
}
return &extensionMiddleware{Base: transport, Ext: tr, ExtName: p.Name()}
return &extensionMiddleware{Base: transport, Ext: tr}
}

View File

@@ -1,408 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
// ---------------------------------------------------------------------------
// RetryTransport
// ---------------------------------------------------------------------------
func TestRetryTransport_NoRetry(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})
rt := &RetryTransport{Base: base, MaxRetries: 0}
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
if calls != 1 {
t.Errorf("expected 1 call, got %d", calls)
}
}
func TestRetryTransport_RetryOn500(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
if calls < 3 {
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})
rt := &RetryTransport{Base: base, MaxRetries: 3, Delay: 1 * time.Millisecond}
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200 after retries, got %d", resp.StatusCode)
}
if calls != 3 {
t.Errorf("expected 3 calls, got %d", calls)
}
}
func TestRetryTransport_DefaultNoRetry(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
})
rt := &RetryTransport{Base: base} // default MaxRetries=0
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 500 {
t.Errorf("expected 500 with no retries, got %d", resp.StatusCode)
}
if calls != 1 {
t.Errorf("expected 1 call with default config, got %d", calls)
}
}
// ---------------------------------------------------------------------------
// buildSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
ua, ok := sec.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
}
func TestBuildSDKTransport_WithExtension(t *testing.T) {
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
transport := buildSDKTransport()
// Chain: extensionMiddleware → SecurityPolicy → UserAgent → Retry → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
}
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
}
ua, ok := sec.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("transport type = %T, want *UserAgentTransport", sec.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
exttransport.Register(nil)
transport := buildSDKTransport()
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
ua, ok := sec.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
}
// ---------------------------------------------------------------------------
// extensionMiddleware — legacy Interceptor path
// ---------------------------------------------------------------------------
type stubTransportProvider struct {
interceptor exttransport.Interceptor
}
func (s *stubTransportProvider) Name() string { return "stub" }
func (s *stubTransportProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
if s.interceptor != nil {
return s.interceptor
}
return &stubTransportImpl{}
}
type stubTransportImpl struct{}
func (s *stubTransportImpl) PreRoundTrip(req *http.Request) func(*http.Response, error) {
return nil
}
// headerCapturingInterceptor sets custom headers in PreRoundTrip and records
// whether PostRoundTrip was called, to verify execution order.
type headerCapturingInterceptor struct {
preCalled bool
postCalled bool
}
func (h *headerCapturingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
h.preCalled = true
// Set a custom header that should survive (no built-in override)
req.Header.Set("X-Custom-Trace", "ext-trace-123")
// Try to override a security header — should be overwritten by SecurityHeaderTransport
req.Header.Set(HeaderSource, "ext-tampered")
return func(resp *http.Response, err error) {
h.postCalled = true
}
}
func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
var receivedHeaders http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ic := &headerCapturingInterceptor{}
exttransport.Register(&stubTransportProvider{interceptor: ic})
t.Cleanup(func() { exttransport.Register(nil) })
// Use HTTP transport chain (has SecurityHeaderTransport)
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &SecurityHeaderTransport{Base: base}
transport := wrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
// PreRoundTrip was called
if !ic.preCalled {
t.Fatal("PreRoundTrip was not called")
}
// PostRoundTrip (closure) was called
if !ic.postCalled {
t.Fatal("PostRoundTrip closure was not called")
}
// Custom header set by extension survives (no built-in override)
if got := receivedHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
t.Fatalf("X-Custom-Trace = %q, want %q", got, "ext-trace-123")
}
// Security header overridden by extension is restored by SecurityHeaderTransport
if got := receivedHeaders.Get(HeaderSource); got != SourceValue {
t.Fatalf("%s = %q, want %q (built-in should override extension)", HeaderSource, got, SourceValue)
}
}
// interceptorFunc adapts a function to exttransport.Interceptor.
type interceptorFunc func(*http.Request) func(*http.Response, error)
func (f interceptorFunc) PreRoundTrip(req *http.Request) func(*http.Response, error) { return f(req) }
func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
type ctxKeyType string
const testKey ctxKeyType = "original"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
var ctxValue any
// Use a custom transport that captures the context value seen by the built-in chain
capturer := roundTripFunc(func(req *http.Request) (*http.Response, error) {
ctxValue = req.Context().Value(testKey)
return http.DefaultTransport.RoundTrip(req)
})
// Interceptor that tries to tamper with context
tamperIC := interceptorFunc(func(req *http.Request) func(*http.Response, error) {
// Try to replace context with a new one
*req = *req.WithContext(context.WithValue(req.Context(), testKey, "tampered"))
return nil
})
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
origCtx := context.WithValue(context.Background(), testKey, "original")
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
resp, err := mid.RoundTrip(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
// Built-in chain should see original context, not tampered
if ctxValue != "original" {
t.Fatalf("built-in chain saw context value %q, want %q", ctxValue, "original")
}
}
// ---------------------------------------------------------------------------
// extensionMiddleware — PreRoundTripE abort path
// ---------------------------------------------------------------------------
// abortingInterceptor implements exttransport.AbortableInterceptor and
// records invocation of the pre and post hooks. These middleware tests only
// assert middleware-level integration; pure *AbortError behavior
// (Error/Unwrap/Is/As) is covered in extension/transport/errors_test.go.
type abortingInterceptor struct {
reason error // if non-nil, PreRoundTripE returns this to abort
nilPost bool // if true, PreRoundTripE returns a nil post func
preECalled bool
postCalled bool
postResp *http.Response
postErr error
}
// PreRoundTrip is a no-op that satisfies the legacy Interceptor method; the
// middleware never calls it when PreRoundTripE is present.
func (*abortingInterceptor) PreRoundTrip(*http.Request) func(*http.Response, error) {
return nil
}
func (a *abortingInterceptor) PreRoundTripE(req *http.Request) (func(*http.Response, error), error) {
a.preECalled = true
if a.nilPost {
return nil, a.reason
}
return func(resp *http.Response, err error) {
a.postCalled = true
a.postResp = resp
a.postErr = err
}, a.reason
}
func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
innerErr := errors.New("denied by policy")
t.Run("skips base and wires AbortError fields", func(t *testing.T) {
ic := &abortingInterceptor{reason: innerErr}
baseCalls := 0
base := roundTripFunc(func(*http.Request) (*http.Response, error) {
baseCalls++
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
resp, err := mid.RoundTrip(req)
if resp != nil {
t.Fatalf("resp = %v, want nil on abort", resp)
}
if baseCalls != 0 {
t.Fatalf("base RoundTrip called %d times on abort, want 0", baseCalls)
}
if !ic.preECalled {
t.Fatal("PreRoundTripE was not called")
}
var aErr *exttransport.AbortError
if !errors.As(err, &aErr) {
t.Fatalf("errors.As(*AbortError) = false, err = %v (%T)", err, err)
}
if aErr.Extension != "stub" || aErr.Reason != innerErr {
t.Fatalf("AbortError = %+v, want {Extension:stub Reason:%v}", aErr, innerErr)
}
// Post must see the original inner err, not the *AbortError wrapper.
if !ic.postCalled {
t.Fatal("post hook was not called on abort")
}
if ic.postResp != nil {
t.Fatalf("post resp = %v, want nil", ic.postResp)
}
if ic.postErr != innerErr {
t.Fatalf("post err = %v, want original inner err %v", ic.postErr, innerErr)
}
})
t.Run("nil post still returns AbortError", func(t *testing.T) {
ic := &abortingInterceptor{reason: innerErr, nilPost: true}
base := roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Fatal("base must not be called on abort")
return nil, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
_, err := mid.RoundTrip(req)
var aErr *exttransport.AbortError
if !errors.As(err, &aErr) {
t.Fatalf("errors.As(*AbortError) = false, err = %v", err)
}
})
}
func TestExtensionMiddleware_PreRoundTripEHappyPath(t *testing.T) {
ic := &abortingInterceptor{} // reason == nil → no abort
baseCalls := 0
base := roundTripFunc(func(*http.Request) (*http.Response, error) {
baseCalls++
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
resp, err := mid.RoundTrip(req)
if err != nil {
t.Fatalf("happy path returned err: %v", err)
}
if resp == nil || resp.StatusCode != http.StatusOK {
t.Fatalf("resp = %v, want 200", resp)
}
if baseCalls != 1 {
t.Fatalf("base RoundTrip called %d times, want 1", baseCalls)
}
if !ic.preECalled {
t.Fatal("PreRoundTripE was not called")
}
if !ic.postCalled || ic.postErr != nil {
t.Fatalf("post hook not called or err != nil: called=%v err=%v", ic.postCalled, ic.postErr)
}
}

View File

@@ -11,8 +11,4 @@ const (
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// Sidecar proxy (auth proxy mode)
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
)

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"reflect"
"sort"
)
@@ -15,6 +16,29 @@ import (
var knownArrayFields = []string{
"items", "files", "events", "rooms", "records", "nodes",
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
"chats", "messages", "tasks", "created_tasks",
}
// asGenericSlice converts any slice value into []interface{}.
// Returns the slice and true when v is a slice, regardless of element type
// ([]interface{}, []map[string]interface{}, []MyStruct, etc.). This keeps
// formatter logic working when business code uses typed slices.
func asGenericSlice(v interface{}) ([]interface{}, bool) {
if v == nil {
return nil, false
}
if s, ok := v.([]interface{}); ok {
return s, true
}
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Slice {
return nil, false
}
out := make([]interface{}, rv.Len())
for i := 0; i < rv.Len(); i++ {
out[i] = rv.Index(i).Interface()
}
return out, true
}
// FindArrayField finds the primary array field in a response's data object.
@@ -23,7 +47,7 @@ var knownArrayFields = []string{
func FindArrayField(data map[string]interface{}) string {
for _, name := range knownArrayFields {
if arr, ok := data[name]; ok {
if _, isArr := arr.([]interface{}); isArr {
if _, isArr := asGenericSlice(arr); isArr {
return name
}
}
@@ -31,7 +55,7 @@ func FindArrayField(data map[string]interface{}) string {
// Fallback: lexicographically first array field (deterministic)
var candidates []string
for k, v := range data {
if _, isArr := v.([]interface{}); isArr {
if _, isArr := asGenericSlice(v); isArr {
candidates = append(candidates, k)
}
}
@@ -68,11 +92,12 @@ func toGeneric(v interface{}) interface{} {
// 1. Lark API envelope: result["data"][arrayField] (e.g. {"code":0,"data":{"items":[…]}})
// 2. Direct map: result[arrayField] (e.g. {"members":[…],"total":5})
//
// If data is already a plain []interface{}, it is returned as-is.
// If data is already a slice, it is returned as a []interface{}. Typed slices
// such as []map[string]interface{} are also accepted via asGenericSlice.
func ExtractItems(data interface{}) []interface{} {
resultMap, ok := data.(map[string]interface{})
if !ok {
if arr, ok := data.([]interface{}); ok {
if arr, ok := asGenericSlice(data); ok {
return arr
}
return nil
@@ -81,7 +106,7 @@ func ExtractItems(data interface{}) []interface{} {
// Strategy 1: Lark API envelope — result["data"][arrayField]
if dataObj, ok := resultMap["data"].(map[string]interface{}); ok {
if field := FindArrayField(dataObj); field != "" {
if items, ok := dataObj[field].([]interface{}); ok {
if items, ok := asGenericSlice(dataObj[field]); ok {
return items
}
}
@@ -90,7 +115,7 @@ func ExtractItems(data interface{}) []interface{} {
// Strategy 2: direct map — result[arrayField]
// Covers shortcut-level data like {"members":[…], "total":5, "has_more":false}
if field := FindArrayField(resultMap); field != "" {
if items, ok := resultMap[field].([]interface{}); ok {
if items, ok := asGenericSlice(resultMap[field]); ok {
return items
}
}

View File

@@ -266,6 +266,113 @@ func TestExtractItems(t *testing.T) {
}
}
// Regression: shortcuts often collect results into typed slices like
// []map[string]interface{} instead of []interface{}. ExtractItems must
// recognise those so --format table/csv/ndjson render the array rather
// than falling back to a key/value view of the envelope.
func TestExtractItems_TypedSlice(t *testing.T) {
cases := []struct {
name string
data interface{}
want int
}{
{
name: "direct map with []map[string]interface{} under known field",
data: map[string]interface{}{
"chats": []map[string]interface{}{
{"chat_id": "oc_a", "name": "Alice"},
{"chat_id": "oc_b", "name": "Bob"},
},
"has_more": true,
"total": float64(2),
},
want: 2,
},
{
name: "envelope with []map[string]interface{} under data.messages",
data: map[string]interface{}{
"data": map[string]interface{}{
"messages": []map[string]interface{}{
{"message_id": "om_1"},
},
},
},
want: 1,
},
{
name: "direct map with []map[string]interface{} under created_tasks",
data: map[string]interface{}{
"created_tasks": []map[string]interface{}{
{"task_id": "t1"},
{"task_id": "t2"},
{"task_id": "t3"},
},
},
want: 3,
},
{
name: "typed slice of structs via fallback",
data: map[string]interface{}{
"widgets": []struct {
Name string `json:"name"`
}{{Name: "x"}, {Name: "y"}},
},
want: 2,
},
{
name: "raw typed slice passed directly",
data: []map[string]interface{}{
{"k": "v"},
},
want: 1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
items := ExtractItems(tc.data)
if len(items) != tc.want {
t.Fatalf("expected %d items, got %d (%v)", tc.want, len(items), items)
}
})
}
}
// Regression: --format table on the 7 affected shortcuts used to print
// the envelope as a key/value table because the typed slice was ignored.
// After the fix, the array should be expanded into a proper header row.
func TestFormatValue_Table_TypedSlice(t *testing.T) {
data := map[string]interface{}{
"chats": []map[string]interface{}{
{"chat_id": "oc_abc", "name": "Lark test"},
},
"has_more": true,
"total": float64(1),
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatTable)
out := buf.String()
if !strings.Contains(out, "chat_id") {
t.Errorf("table output should expose chat_id column, got:\n%s", out)
}
if !strings.Contains(out, "oc_abc") {
t.Errorf("table output should contain the chat row, got:\n%s", out)
}
// The fallback bug manifested as the envelope being rendered as rows:
// the 'has_more' / 'total' envelope keys would appear as first-column
// labels. A correct render puts the array's element keys in the header
// and keeps envelope metadata out of the table body.
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "has_more") || strings.HasPrefix(trimmed, "total ") {
t.Errorf("envelope field leaked into table body:\n%s", out)
}
}
}
func TestFormatValue_LegacyFormats(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{

View File

@@ -61,7 +61,7 @@ func httpClient() *http.Client {
}
return &http.Client{
Timeout: fetchTimeout,
Transport: util.SharedTransport(),
Transport: util.NewBaseTransport(),
}
}

View File

@@ -72,47 +72,31 @@ func WarnIfProxied(w io.Writer) {
})
}
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport,
// lazily built the first time LARK_CLI_NO_PROXY is observed set.
var noProxyTransport = sync.OnceValue(func() *http.Transport {
// NewBaseTransport creates an *http.Transport cloned from http.DefaultTransport.
// If LARK_CLI_NO_PROXY is set, proxy support is disabled.
// Each call returns a new instance; use FallbackTransport for a shared singleton.
func NewBaseTransport() *http.Transport {
def, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return &http.Transport{}
}
t := def.Clone()
t.Proxy = nil
if os.Getenv(EnvNoProxy) != "" {
t.Proxy = nil
}
return t
}
// fallbackTransport is a lazily-initialized singleton used by transport
// decorators when their Base field is nil, preserving connection pooling.
var fallbackTransport = sync.OnceValue(func() *http.Transport {
return NewBaseTransport()
})
// SharedTransport returns the base http.RoundTripper for CLI HTTP clients.
//
// By default it returns http.DefaultTransport — the stdlib-provided
// process-wide singleton — so every HTTP client in the process shares one
// TCP connection pool, TLS session cache, and HTTP/2 state. When
// LARK_CLI_NO_PROXY is set it returns a separate proxy-disabled singleton
// clone; LARK_CLI_NO_PROXY is checked on every call, but the clone is built
// at most once.
//
// The returned RoundTripper MUST NOT be mutated. Callers that need a
// customized transport should assert to *http.Transport and Clone() it.
// Using a shared base is required so persistConn readLoop/writeLoop
// goroutines are reused; cloning per call leaks them until IdleConnTimeout
// (~90s) fires.
func SharedTransport() http.RoundTripper {
if os.Getenv(EnvNoProxy) != "" {
return noProxyTransport()
}
return http.DefaultTransport
}
// FallbackTransport returns a shared *http.Transport singleton. It is a
// thin wrapper over SharedTransport retained so modules that were already
// on the leak-free singleton path (internal/auth, internal/cmdutil
// transport decorators) do not have to migrate. New code should prefer
// SharedTransport and treat the base as an http.RoundTripper.
// FallbackTransport returns a shared *http.Transport singleton suitable for
// use as a fallback when a transport decorator's Base is nil.
// Unlike NewBaseTransport (which clones per call), this reuses a single
// instance so that TCP connections and TLS sessions are pooled.
func FallbackTransport() *http.Transport {
if t, ok := SharedTransport().(*http.Transport); ok {
return t
}
return noProxyTransport()
return fallbackTransport()
}

View File

@@ -28,65 +28,19 @@ func TestDetectProxyEnv(t *testing.T) {
}
}
func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
func TestNewBaseTransport_Default(t *testing.T) {
t.Setenv(EnvNoProxy, "")
tr := SharedTransport()
if tr != http.DefaultTransport {
t.Error("SharedTransport should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset")
tr := NewBaseTransport()
if tr.Proxy == nil {
t.Error("expected proxy func to be set when LARK_CLI_NO_PROXY is not set")
}
}
func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
func TestNewBaseTransport_NoProxy(t *testing.T) {
t.Setenv(EnvNoProxy, "1")
tr := SharedTransport()
if tr == http.DefaultTransport {
t.Fatal("SharedTransport should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set")
}
ht, ok := tr.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", tr)
}
if ht.Proxy != nil {
t.Error("no-proxy transport should have Proxy == nil")
}
}
func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
t.Setenv(EnvNoProxy, "1")
a := SharedTransport()
b := SharedTransport()
if a != b {
t.Error("repeated SharedTransport calls with LARK_CLI_NO_PROXY set must return the same instance")
}
}
func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
// the no-proxy singleton), then unsets it. Subsequent calls must return
// http.DefaultTransport, NOT the cached no-proxy clone.
t.Setenv(EnvNoProxy, "1")
noProxy := SharedTransport()
if noProxy == http.DefaultTransport {
t.Fatal("precondition: first call with env set should not return DefaultTransport")
}
t.Setenv(EnvNoProxy, "")
after := SharedTransport()
if after != http.DefaultTransport {
t.Errorf("after unsetting LARK_CLI_NO_PROXY, SharedTransport must return http.DefaultTransport, got %T (%p)", after, after)
}
}
func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
t.Setenv(EnvNoProxy, "1")
ht, ok := SharedTransport().(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", SharedTransport())
}
if ht.Proxy != nil {
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
tr := NewBaseTransport()
if tr.Proxy != nil {
t.Error("expected proxy func to be nil when LARK_CLI_NO_PROXY=1")
}
}
@@ -202,3 +156,35 @@ func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
t.Errorf("warning should contain redacted proxy URL, got: %s", out)
}
}
func TestNewBaseTransport_IsHTTPTransport(t *testing.T) {
t.Setenv(EnvNoProxy, "")
tr := NewBaseTransport()
// Should be a valid *http.Transport that can be used
var rt http.RoundTripper = tr
_ = rt
// Verify it's not the same pointer as DefaultTransport (should be a clone)
if tr == http.DefaultTransport {
t.Error("NewBaseTransport should return a clone, not DefaultTransport itself")
}
}
func TestNewBaseTransport_RespectsNoProxyEnv(t *testing.T) {
// Simulate: user sets both system proxy and our disable flag
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
t.Setenv(EnvNoProxy, "1")
tr := NewBaseTransport()
if tr.Proxy != nil {
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
}
// Clean up and verify proxy is restored
t.Setenv(EnvNoProxy, "")
tr2 := NewBaseTransport()
if tr2.Proxy == nil {
t.Error("proxy should be enabled when LARK_CLI_NO_PROXY is unset")
}
}

View File

@@ -1,11 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package main
import (
_ "github.com/larksuite/cli/extension/credential/sidecar" // activate sidecar credential provider
_ "github.com/larksuite/cli/extension/transport/sidecar" // activate sidecar transport interceptor
)

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !authsidecar
// This file is the fail-closed guard for builds that do NOT include the
// `authsidecar` tag. The sidecar credential-isolation feature is only
// compiled in under that tag; deploying the plain build into an environment
// that expects sidecar isolation would silently fall back to direct env
// credential use — exactly the failure mode the feature is meant to prevent.
//
// When LARKSUITE_CLI_AUTH_PROXY is set, we refuse to run rather than ignore
// the variable. The operator either rebuilt without realizing (wrong
// artifact) or the sandbox inherited the var by accident; both cases want
// a loud startup error, not a mysterious token leak on the first API call.
package main
import (
"fmt"
"io"
"os"
"github.com/larksuite/cli/internal/envvars"
)
func init() {
if code := checkNoAuthsidecarBuild(os.Getenv, os.Stderr); code != 0 {
os.Exit(code)
}
}
// checkNoAuthsidecarBuild returns a non-zero exit code (and writes a
// human-readable reason to stderr) when the environment asks for sidecar
// isolation that this binary cannot provide. Factored out from init() so
// tests can exercise the decision without actually calling os.Exit.
func checkNoAuthsidecarBuild(getenv func(string) string, stderr io.Writer) int {
v := getenv(envvars.CliAuthProxy)
if v == "" {
return 0
}
fmt.Fprintf(stderr,
"ERROR: %s is set, but this lark-cli binary was built WITHOUT the "+
"'authsidecar' build tag.\n"+
"The sidecar credential-isolation feature is compiled out — "+
"running would bypass isolation and\n"+
"send any real credentials present in the environment directly "+
"to the Lark API.\n\n"+
"To fix, either:\n"+
" - rebuild the CLI with: go build -tags authsidecar\n"+
" - or unset %s if sidecar isolation is not required\n",
envvars.CliAuthProxy, envvars.CliAuthProxy)
return 2
}

View File

@@ -1,52 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !authsidecar
package main
import (
"bytes"
"strings"
"testing"
"github.com/larksuite/cli/internal/envvars"
)
func TestCheckNoAuthsidecarBuild_Unset(t *testing.T) {
var stderr bytes.Buffer
code := checkNoAuthsidecarBuild(func(string) string { return "" }, &stderr)
if code != 0 {
t.Errorf("exit code = %d, want 0 when AUTH_PROXY is unset", code)
}
if stderr.Len() != 0 {
t.Errorf("stderr should be empty, got %q", stderr.String())
}
}
// TestCheckNoAuthsidecarBuild_Set verifies that deploying a plain build into
// a sandbox that expects sidecar isolation fails loudly at startup instead
// of silently leaking credentials through the env provider path.
func TestCheckNoAuthsidecarBuild_Set(t *testing.T) {
var stderr bytes.Buffer
env := func(k string) string {
if k == envvars.CliAuthProxy {
return "http://127.0.0.1:16384"
}
return ""
}
code := checkNoAuthsidecarBuild(env, &stderr)
if code == 0 {
t.Fatal("expected non-zero exit code when AUTH_PROXY is set")
}
msg := stderr.String()
for _, want := range []string{
envvars.CliAuthProxy,
"authsidecar", // build-tag name must appear so operators can act on it
"rebuild",
} {
if !strings.Contains(msg, want) {
t.Errorf("stderr message missing %q; got:\n%s", want, msg)
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.16",
"version": "1.0.14",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

70
rebase-420/dd05477.md Normal file
View File

@@ -0,0 +1,70 @@
# Cherry-pick 冲突解决报告: `dd05477`
- **原始 commit**: `dd05477` feat: add SetDefaultFS to allow replacing the global filesystem implementation
- **作者**: tuxedomm, 2026-04-09
- **新 commit**: `4d84994`
- **目标分支**: `feat/main_rebased_420`(基于 `larksuite/cli` 最新 main
## 改动范围
10 个文件, +179 / -70:
- **新增**: `cmd/build.go`, `cmd/init.go`
- **修改**: `cmd/root.go`, `cmd/root_integration_test.go`, `internal/cmdutil/factory_default.go`, `internal/cmdutil/factory_default_test.go`, `internal/cmdutil/factory_http_test.go`, `internal/cmdutil/iostreams.go`, `internal/credential/default_provider.go`, `internal/credential/integration_test.go`
核心意图:
-`cmd.Execute()` 里的 root 命令组装逻辑抽取到新文件 `cmd/build.go``buildInternal()`, 并暴露 `Build()` 作为库入口
- 引入 `cmd/init.go` 里的 `SetDefaultFS(fs vfs.FS)` 允许调用方在 `Build/Execute` 之前替换全局 fs
- `cmdutil.NewDefault(inv)` 签名调整为 `NewDefault(streams *IOStreams, inv InvocationContext)`
- `credentialDeps.Keychain``keychain.KeychainAccess` 改为 `func() keychain.KeychainAccess`(惰性读取, 允许构造后替换)
- `cmdutil.SystemIO()` 新函数封装对真实 stdio 的引用
## 冲突情况
只有一个文件冲突: `cmd/root.go`2 处)
| 位置 | HEAD(main) | fork(dd05477) |
|---|---|---|
| imports 段 | 保留 `cmd/api`, `cmd/auth`, `cmd/completion`, `cmdconfig`, `cmd/doctor`, `cmd/profile`, `cmd/schema`, `cmd/service`, `cmdupdate`, `shortcuts` 等 | 全部删除(这些 import 随 Execute 函数体一起搬去新文件 `cmd/build.go`|
| `Execute()` 函数体 | 完整包含 Factory 构造 + rootCmd 构造 + 子命令注册 + strict-mode 剪枝 | 精简为 `f, rootCmd := buildInternal(context.Background(), inv)` |
### 为什么会冲突
fork 的 dd05477 比 fork 之前落后 main 很多 commit, 而 main 上(比如 PR #391)在 fork 不知道的情况下加了 `rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))` 这一行 —— 它处于 fork 想整体搬走的那段代码里。git 无法自动判断这一行应该保留还是跟着搬, 所以报冲突。
## 解决方案
**两处冲突都采用 fork 的重构结构**(把 imports / 组装逻辑搬去 `cmd/build.go`, 但在 `cmd/build.go``buildInternal()` 里**追加**了 main 新增的 update 命令。
### 具体改动
`cmd/build.go` 里:
```go
// imports 段补上
cmdupdate "github.com/larksuite/cli/cmd/update"
// 在 rootCmd.AddCommand(completion.NewCmdCompletion(f)) 之后追加
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
```
如果不这样做, 就会丢失 main PR #391 引入的 `lark-cli update` 子命令。
## 非冲突文件处理
其余 9 个文件的 patch 全部直接应用, 无语义冲突:
- `cmd/build.go`, `cmd/init.go`: 新增文件
- `cmd/root_integration_test.go`, `internal/cmdutil/factory_default_test.go`, `internal/cmdutil/factory_http_test.go`, `internal/credential/integration_test.go`: 跟随签名变更调整调用方(`NewDefault(nil, ...)``cachedHttpClientFunc(&Factory{...})` 等)
- `internal/cmdutil/factory_default.go`, `internal/cmdutil/iostreams.go`, `internal/credential/default_provider.go`: 签名/结构体字段类型调整
- `cmd/root.go`: 冲突段外其余部分update 检查、错误处理等)保持原样
## 验证
- `go build ./...` 通过
- `go test ./cmd/... ./internal/cmdutil/... ./internal/credential/...` 全部通过
## 依赖
- `internal/vfs` 包(`DefaultFS``OsFs``FS` interface在 main 上已存在, `SetDefaultFS` 要切换的全局状态有完整基础
- `cmdupdate`main PR #391)已存在

View File

@@ -38,11 +38,11 @@ const messages = {
step3Fail: "应用配置失败。运行以下命令重试: lark-cli config init --new",
step4: "授权",
step4NotFound: "未找到 lark-cli跳过授权",
step4Confirm: "是否允许 AI 访问你个人的消息、文档、日历等飞书 / Lark 数据,并以你的名义执行操作",
step4Confirm: "允许 AI 访问你的飞书数据(消息、文档、日历等",
step4Skip: "跳过授权。后续运行 lark-cli auth login 完成授权",
step4Done: "授权完成",
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
done: "安装完成!\n可以你的 AI 工具(Claude Code、Trae等\"飞书/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
done: "安装完成!\n现在可以你的 AI 工具Claude Code、Trae 等)说:\"Feishu/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
cancelled: "安装已取消",
},
en: {
@@ -66,7 +66,7 @@ const messages = {
step3Fail: "Failed to configure app. Run manually: lark-cli config init --new",
step4: "Authorization",
step4NotFound: "lark-cli not found. Skipping authorization",
step4Confirm: "Allow the AI to access your messages, documents, calendar, and more in Feishu/Lark, and perform actions on your behalf?",
step4Confirm: "Allow AI to access your Feishu/Lark data (messages, docs, calendar, etc.)?",
step4Skip: "Skipped. Run lark-cli auth login to authorize later",
step4Done: "Authorization complete",
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",

View File

@@ -137,8 +137,6 @@ func TestDryRunRecordOps(t *testing.T) {
"bitable_file",
"PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
"report-final.pdf",
`"mime_type":"\u003cdetected_mime_type\u003e"`,
`"size":"\u003cfile_size\u003e"`,
"deprecated_set_attachment",
)
}

View File

@@ -1219,9 +1219,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
!strings.Contains(updateBody, `"image_height":480`) ||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) ||
!strings.Contains(updateBody, `"file_token":"file_tok_1"`) ||
!strings.Contains(updateBody, `"name":"report.txt"`) ||
!strings.Contains(updateBody, `"size":16`) ||
!strings.Contains(updateBody, `"mime_type":"text/plain"`) {
!strings.Contains(updateBody, `"name":"report.txt"`) {
t.Fatalf("update body=%s", updateBody)
}
})
@@ -1372,8 +1370,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
if !strings.Contains(updateBody, `"附件"`) ||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
!strings.Contains(updateBody, `"size":20971521`) ||
!strings.Contains(updateBody, `"mime_type":"application/octet-stream"`) ||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
t.Fatalf("update body=%s", updateBody)
}

View File

@@ -4,15 +4,11 @@
package base
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"mime"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
@@ -109,8 +105,6 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
map[string]interface{}{
"file_token": "<uploaded_file_token>",
"name": fileName,
"mime_type": "<detected_mime_type>",
"size": "<file_size>",
"deprecated_set_attachment": true,
},
},
@@ -249,14 +243,10 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i
}
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName)
if err != nil {
return nil, err
}
parentNode := baseToken
var (
fileToken string
err error
)
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
@@ -282,78 +272,7 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName,
attachment := map[string]interface{}{
"file_token": fileToken,
"name": fileName,
"mime_type": mimeType,
"size": fileSize,
"deprecated_set_attachment": true,
}
return attachment, nil
}
func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (string, error) {
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName)))); byExt != "" {
return stripMIMEParams(byExt), nil
}
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(filePath)))); byExt != "" {
return stripMIMEParams(byExt), nil
}
f, err := fio.Open(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
}
defer f.Close()
buf := make([]byte, 512)
n, readErr := f.Read(buf)
if readErr != nil && !errors.Is(readErr, io.EOF) {
return "", output.ErrValidation("cannot read file: %s", readErr)
}
return detectAttachmentMIMEFromContent(buf[:n]), nil
}
func stripMIMEParams(value string) string {
if i := strings.IndexByte(value, ';'); i != -1 {
value = value[:i]
}
return strings.TrimSpace(value)
}
func detectAttachmentMIMEFromContent(content []byte) string {
if len(content) == 0 {
return "application/octet-stream"
}
if bytes.HasPrefix(content, []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) {
return "image/png"
}
if bytes.HasPrefix(content, []byte{0xff, 0xd8, 0xff}) {
return "image/jpeg"
}
if bytes.HasPrefix(content, []byte("GIF87a")) || bytes.HasPrefix(content, []byte("GIF89a")) {
return "image/gif"
}
if len(content) >= 12 && bytes.Equal(content[:4], []byte("RIFF")) && bytes.Equal(content[8:12], []byte("WEBP")) {
return "image/webp"
}
if bytes.HasPrefix(content, []byte("%PDF-")) {
return "application/pdf"
}
if looksLikeText(content) {
return "text/plain"
}
return "application/octet-stream"
}
func looksLikeText(content []byte) bool {
if !utf8.Valid(content) {
return false
}
for _, r := range string(content) {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
if r < 0x20 || r == 0x7f {
return false
}
}
return true
}

View File

@@ -1,136 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"bytes"
"io"
"io/fs"
"os"
"strings"
"testing"
"github.com/larksuite/cli/extension/fileio"
)
type attachmentTestFileIO struct {
openFile fileio.File
openErr error
}
func (f attachmentTestFileIO) Open(string) (fileio.File, error) { return f.openFile, f.openErr }
func (attachmentTestFileIO) Stat(string) (fileio.FileInfo, error) {
return attachmentTestFileInfo{}, nil
}
func (attachmentTestFileIO) ResolvePath(path string) (string, error) { return path, nil }
func (attachmentTestFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
return nil, nil
}
type attachmentTestFileInfo struct{}
func (attachmentTestFileInfo) Size() int64 { return 0 }
func (attachmentTestFileInfo) IsDir() bool { return false }
func (attachmentTestFileInfo) Mode() fs.FileMode { return 0 }
type attachmentTestFile struct {
*bytes.Reader
}
func newAttachmentTestFile(content []byte) attachmentTestFile {
return attachmentTestFile{Reader: bytes.NewReader(content)}
}
func (attachmentTestFile) Close() error { return nil }
type attachmentReadErrorFile struct{}
func (attachmentReadErrorFile) Read([]byte) (int, error) { return 0, os.ErrPermission }
func (attachmentReadErrorFile) ReadAt([]byte, int64) (int, error) { return 0, io.EOF }
func (attachmentReadErrorFile) Close() error { return nil }
func TestDetectAttachmentMIMETypeUsesExtension(t *testing.T) {
got, err := detectAttachmentMIMEType(nil, "ignored", "note.TXT")
if err != nil {
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
}
if got != "text/plain" {
t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain")
}
}
func TestDetectAttachmentMIMETypeFallsBackToSourcePathExtension(t *testing.T) {
got, err := detectAttachmentMIMEType(nil, "report.docx", "report")
if err != nil {
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
}
if got != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" {
t.Fatalf("detectAttachmentMIMEType() = %q, want docx MIME type", got)
}
}
func TestDetectAttachmentMIMETypeFallsBackToContent(t *testing.T) {
fio := attachmentTestFileIO{openFile: newAttachmentTestFile([]byte("hello from base attachment"))}
got, err := detectAttachmentMIMEType(fio, "note", "note")
if err != nil {
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
}
if got != "text/plain" {
t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain")
}
}
func TestDetectAttachmentMIMETypeWrapsOpenError(t *testing.T) {
fio := attachmentTestFileIO{openErr: os.ErrNotExist}
_, err := detectAttachmentMIMEType(fio, "missing", "missing")
if err == nil {
t.Fatal("expected error for open failure")
}
if !strings.Contains(err.Error(), "cannot read file") {
t.Fatalf("error = %v, want wrapped read failure", err)
}
}
func TestDetectAttachmentMIMETypeReturnsReadError(t *testing.T) {
fio := attachmentTestFileIO{openFile: attachmentReadErrorFile{}}
_, err := detectAttachmentMIMEType(fio, "broken", "broken")
if err == nil {
t.Fatal("expected error for read failure")
}
if !strings.Contains(err.Error(), "cannot read file") {
t.Fatalf("error = %v, want read failure", err)
}
}
func TestDetectAttachmentMIMEFromContent(t *testing.T) {
tests := []struct {
name string
content []byte
want string
}{
{name: "empty", content: nil, want: "application/octet-stream"},
{name: "png", content: []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, want: "image/png"},
{name: "jpeg", content: []byte{0xff, 0xd8, 0xff, 0xe0}, want: "image/jpeg"},
{name: "gif87a", content: []byte("GIF87a"), want: "image/gif"},
{name: "gif89a", content: []byte("GIF89a"), want: "image/gif"},
{name: "webp", content: []byte("RIFF1234WEBP"), want: "image/webp"},
{name: "pdf", content: []byte("%PDF-1.7"), want: "application/pdf"},
{name: "text", content: []byte("hello from base attachment"), want: "text/plain"},
{name: "text with newline", content: []byte("hello\nworld\tok"), want: "text/plain"},
{name: "control bytes", content: []byte{'h', 'i', 0x00}, want: "application/octet-stream"},
{name: "binary fallback", content: []byte{0x00, 0x01, 0x02, 0x03}, want: "application/octet-stream"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectAttachmentMIMEFromContent(tt.content)
if got != tt.want {
t.Fatalf("detectAttachmentMIMEFromContent() = %q, want %q", got, tt.want)
}
})
}
}

View File

@@ -54,7 +54,6 @@ func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext,
"start_time": fmt.Sprintf("%d", startTime),
"end_time": fmt.Sprintf("%d", endTime),
}, nil)
err = wrapPredefinedError(err)
if err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", err)
}

View File

@@ -194,7 +194,6 @@ var CalendarCreate = common.Shortcut{
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events", validate.EncodePathSegment(calendarId)),
nil, eventData)
err = wrapPredefinedError(err)
if err != nil {
return err
}
@@ -222,13 +221,11 @@ var CalendarCreate = common.Shortcut{
"attendees": attendees,
"need_notification": true,
})
err = wrapPredefinedError(err)
if err != nil {
// Rollback: delete the event
_, rollbackErr := runtime.RawAPI("DELETE",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
map[string]interface{}{"need_notification": false}, nil)
rollbackErr = wrapPredefinedError(rollbackErr)
if rollbackErr != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; rollback also failed, orphan event_id=%s needs manual cleanup", rollbackErr, eventId)
}

View File

@@ -102,7 +102,6 @@ var CalendarFreebusy = common.Shortcut{
"user_id": userId,
"need_rsvp_status": true,
})
err = wrapPredefinedError(err)
if err != nil {
return err
}

View File

@@ -375,238 +375,6 @@ func TestCreate_NoEventIdReturned(t *testing.T) {
}
}
func TestCreate_CreateEvent_InvalidParamsWithDetail(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": errCodeInvalidParamsWithDetail,
"msg": "invalid params",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "end_time should be later than start_time"},
},
},
},
})
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Bad Time",
"--start", "2025-03-21T10:00:00+08:00",
"--end", "2025-03-21T11:00:00+08:00",
"--calendar-id", "cal_test123",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for 190014, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Code != errCodeInvalidParamsWithDetail {
t.Errorf("expected code %d, got %d", errCodeInvalidParamsWithDetail, exitErr.Detail.Code)
}
if !strings.Contains(exitErr.Detail.Message, "end_time should be later than start_time") {
t.Errorf("expected detail value in message, got %q", exitErr.Detail.Message)
}
}
func TestCreate_CreateEvent_InvalidParamsWithoutDetailValue(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": errCodeInvalidParamsWithDetail,
"msg": "invalid params",
},
})
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Bad Time",
"--start", "2025-03-21T10:00:00+08:00",
"--end", "2025-03-21T11:00:00+08:00",
"--calendar-id", "cal_test123",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for 190014, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Code != errCodeInvalidParamsWithDetail {
t.Errorf("expected code %d, got %d", errCodeInvalidParamsWithDetail, exitErr.Detail.Code)
}
}
func TestCreate_CreateEvent_InvalidParams_ErrorNotMap(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
RawBody: []byte(`{"code":190014,"msg":"invalid params","error":"just a string"}`),
ContentType: "text/plain",
})
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Bad Time",
"--start", "2025-03-21T10:00:00+08:00",
"--end", "2025-03-21T11:00:00+08:00",
"--calendar-id", "cal_test123",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for 190014, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Code != errCodeInvalidParamsWithDetail {
t.Errorf("expected code %d, got %d", errCodeInvalidParamsWithDetail, exitErr.Detail.Code)
}
}
func TestCreate_CreateEvent_InvalidParams_NoDetailsKey(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": errCodeInvalidParamsWithDetail,
"msg": "invalid params",
"error": map[string]interface{}{
"other_key": "no details here",
},
},
})
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Bad Time",
"--start", "2025-03-21T10:00:00+08:00",
"--end", "2025-03-21T11:00:00+08:00",
"--calendar-id", "cal_test123",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for 190014, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Code != errCodeInvalidParamsWithDetail {
t.Errorf("expected code %d, got %d", errCodeInvalidParamsWithDetail, exitErr.Detail.Code)
}
}
func TestCreate_CreateEvent_InvalidParams_DetailItemNotMap(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": errCodeInvalidParamsWithDetail,
"msg": "invalid params",
"error": map[string]interface{}{
"details": []interface{}{nil},
},
},
})
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Bad Time",
"--start", "2025-03-21T10:00:00+08:00",
"--end", "2025-03-21T11:00:00+08:00",
"--calendar-id", "cal_test123",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for 190014, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Code != errCodeInvalidParamsWithDetail {
t.Errorf("expected code %d, got %d", errCodeInvalidParamsWithDetail, exitErr.Detail.Code)
}
}
func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_190014",
"summary": "Bad Attendees",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/events/evt_190014/attendees",
Body: map[string]interface{}{
"code": errCodeInvalidParamsWithDetail,
"msg": "invalid params",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "invalid attendee open_id"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/events/evt_190014",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Bad Attendees",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--attendee-ids", "ou_invalid",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for invalid attendees with 190014, got nil")
}
if !strings.Contains(err.Error(), "invalid attendee open_id") {
t.Errorf("expected detail value in error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// CalendarAgenda tests
// ---------------------------------------------------------------------------
@@ -877,67 +645,6 @@ func TestAgenda_ExplicitCalendarId(t *testing.T) {
}
}
func TestAgenda_InvalidParamsWithDetail(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/events/instance_view",
Body: map[string]interface{}{
"code": errCodeInvalidParamsWithDetail,
"msg": "invalid params",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "start_time is required"},
},
},
},
})
err := mountAndRun(t, CalendarAgenda, []string{
"+agenda",
"--start", "2025-03-21",
"--end", "2025-03-21",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for 190014, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Code != errCodeInvalidParamsWithDetail {
t.Errorf("expected code %d, got %d", errCodeInvalidParamsWithDetail, exitErr.Detail.Code)
}
}
func TestAgenda_NonExitError_Passthrough(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/events/instance_view",
RawBody: []byte("this is not json"),
})
err := mountAndRun(t, CalendarAgenda, []string{
"+agenda",
"--start", "2025-03-21",
"--end", "2025-03-21",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for non-JSON response, got nil")
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil && exitErr.Detail.Code != 0 {
t.Fatalf("expected non-API error passthrough, got API error code %d", exitErr.Detail.Code)
}
}
// ---------------------------------------------------------------------------
// CalendarFreebusy tests
// ---------------------------------------------------------------------------
@@ -1018,46 +725,6 @@ func TestFreebusy_APIError(t *testing.T) {
}
}
func TestFreebusy_InvalidParamsWithDetail(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/freebusy/list",
Body: map[string]interface{}{
"code": errCodeInvalidParamsWithDetail,
"msg": "invalid params",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "user_id is invalid"},
},
},
},
})
err := mountAndRun(t, CalendarFreebusy, []string{
"+freebusy",
"--start", "2025-03-21",
"--end", "2025-03-21",
"--user-id", "ou_someone",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for 190014, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Code != errCodeInvalidParamsWithDetail {
t.Errorf("expected code %d, got %d", errCodeInvalidParamsWithDetail, exitErr.Detail.Code)
}
if !strings.Contains(exitErr.Detail.Message, "user_id is invalid") {
t.Errorf("expected detail value in message, got %q", exitErr.Detail.Message)
}
}
// ---------------------------------------------------------------------------
// CalendarSuggestion tests
// ---------------------------------------------------------------------------

View File

@@ -1,66 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"errors"
"fmt"
"github.com/larksuite/cli/internal/output"
)
const (
errCodeInvalidParamsWithDetail = 190014
)
// getErrorDetailValue extracts the first detail value from the output.ErrDetail.
// It assumes Detail is a map containing a "details" array of objects with "value" string fields.
// For example: {"details": [{"value": "error message 1"}, {"value": "error message 2"}]}
// Returns an empty string if the structure doesn't match or the array is empty.
func getErrorDetailValue(e *output.ErrDetail) string {
if e == nil || e.Detail == nil {
return ""
}
errMap, ok := e.Detail.(map[string]interface{})
if !ok {
return ""
}
details, ok := errMap["details"].([]interface{})
if !ok || len(details) == 0 {
return ""
}
detailObj, ok := details[0].(map[string]interface{})
if !ok {
return ""
}
val, _ := detailObj["value"].(string)
return val
}
// wrapPredefinedError wraps an error into *output.ExitError if it matches predefined error codes.
// Currently handles error code 190014 (invalid params with detail), extracting the detail value into the message.
// If the error is nil or doesn't match predefined codes, returns the original error.
func wrapPredefinedError(err error) error {
if err == nil {
return nil
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return err
}
if exitErr.Detail.Code == errCodeInvalidParamsWithDetail {
if val := getErrorDetailValue(exitErr.Detail); val != "" {
fullMsg := fmt.Sprintf("%s: %s", exitErr.Detail.Message, val)
return output.ErrAPI(exitErr.Detail.Code, fullMsg, exitErr.Detail.Detail)
}
}
return err
}

View File

@@ -37,10 +37,6 @@ type DriveMediaUploadAllConfig struct {
ParentType string
ParentNode *string
Extra string
// Reader, when non-nil, is used as the upload source instead of opening
// FilePath. Callers must set FileName and FileSize explicitly. The reader
// is NOT closed by UploadDriveMediaAll; the caller owns its lifetime.
Reader io.Reader
}
type DriveMediaMultipartUploadConfig struct {
@@ -53,17 +49,11 @@ type DriveMediaMultipartUploadConfig struct {
}
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
var fileReader io.Reader
if cfg.Reader != nil {
fileReader = cfg.Reader
} else {
f, err := runtime.FileIO().Open(cfg.FilePath)
if err != nil {
return "", WrapInputStatError(err)
}
defer f.Close()
fileReader = f
f, err := runtime.FileIO().Open(cfg.FilePath)
if err != nil {
return "", WrapInputStatError(err)
}
defer f.Close()
fd := larkcore.NewFormdata()
fd.AddField("file_name", cfg.FileName)
@@ -75,7 +65,7 @@ func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig)
if cfg.Extra != "" {
fd.AddField("extra", cfg.Extra)
}
fd.AddFile("file", fileReader)
fd.AddFile("file", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,

View File

@@ -488,12 +488,46 @@ func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
fmt.Fprintln(ctx.IO().Out, string(b))
}
// OutRaw prints a success JSON envelope to stdout with HTML escaping disabled.
// Use this instead of Out when the data contains XML/HTML content (e.g. document bodies)
// that should be preserved as-is in JSON output.
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
if ctx.JqExpr != "" {
if err := output.JqFilter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
if ctx.outputErr == nil {
ctx.outputErr = err
}
}
return
}
enc := json.NewEncoder(ctx.IO().Out)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
_ = enc.Encode(env)
}
// OutFormat prints output based on --format flag.
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
// When JqExpr is set, routes through Out() regardless of format.
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
ctx.outFormat(data, meta, prettyFn, false)
}
// OutFormatRaw is like OutFormat but with HTML escaping disabled in JSON output.
// Use this when the data contains XML/HTML content that should be preserved as-is.
func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
ctx.outFormat(data, meta, prettyFn, true)
}
func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), raw bool) {
outFn := ctx.Out
if raw {
outFn = ctx.OutRaw
}
if ctx.JqExpr != "" {
ctx.Out(data, meta)
outFn(data, meta)
return
}
switch ctx.Format {
@@ -501,10 +535,10 @@ func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, pretty
if prettyFn != nil {
prettyFn(ctx.IO().Out)
} else {
ctx.Out(data, meta)
outFn(data, meta)
}
case "json", "":
ctx.Out(data, meta)
outFn(data, meta)
default:
// table, csv, ndjson — pass data directly; FormatValue handles both
// plain arrays and maps with array fields (e.g. {"members":[…]})
@@ -571,16 +605,12 @@ func enhancePermissionError(err error, requiredScopes []string) error {
// Mount registers the shortcut on a parent command.
func (s Shortcut) Mount(parent *cobra.Command, f *cmdutil.Factory) {
s.MountWithContext(context.Background(), parent, f)
}
func (s Shortcut) MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
if s.Execute != nil {
s.mountDeclarative(ctx, parent, f)
s.mountDeclarative(parent, f)
}
}
func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
func (s Shortcut) mountDeclarative(parent *cobra.Command, f *cmdutil.Factory) {
shortcut := s
if len(shortcut.AuthTypes) == 0 {
shortcut.AuthTypes = []string{"user"}
@@ -596,9 +626,12 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
},
}
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
registerShortcutFlags(cmd, &shortcut)
cmdutil.SetTips(cmd, shortcut.Tips)
parent.AddCommand(cmd)
if shortcut.PostMount != nil {
shortcut.PostMount(cmd)
}
}
// runShortcut is the execution pipeline for a declarative shortcut.
@@ -827,11 +860,7 @@ func rejectPositionalArgs() cobra.PositionalArgs {
}
}
func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
registerShortcutFlagsWithContext(context.Background(), cmd, f, s)
}
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
for _, fl := range s.Flags {
desc := fl.Desc
if len(fl.Enum) > 0 {
@@ -868,7 +897,7 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
}
if len(fl.Enum) > 0 {
vals := fl.Enum
_ = cmd.RegisterFlagCompletionFunc(fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return vals, cobra.ShellCompDirectiveNoFileComp
})
}
@@ -882,9 +911,13 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
cmd.Flags().String("as", s.AuthTypes[0], "identity type: user | bot")
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return s.AuthTypes, cobra.ShellCompDirectiveNoFileComp
})
if s.HasFormat {
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// TestShortcutMount_FlagCompletionsRegistered exercises the two
// cmdutil.RegisterFlagCompletion call sites in registerShortcutFlagsWithContext:
// the per-flag enum completion (runner.go:879) and the auto-injected --format
// completion (runner.go:895).
func TestShortcutMount_FlagCompletionsRegistered(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
cmdutil.SetFlagCompletionsDisabled(false)
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "docs",
Command: "+fetch",
Description: "fetch doc",
HasFormat: true,
Flags: []Flag{
{Name: "sort-by", Desc: "sort", Enum: []string{"asc", "desc"}},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+fetch"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
// Enum flag completion.
fn, ok := cmd.GetFlagCompletionFunc("sort-by")
if !ok {
t.Fatal("expected completion func for --sort-by")
}
got, _ := fn(cmd, nil, "")
if len(got) != 2 || got[0] != "asc" || got[1] != "desc" {
t.Fatalf("sort-by completion = %v, want [asc desc]", got)
}
// HasFormat-injected --format completion.
fn, ok = cmd.GetFlagCompletionFunc("format")
if !ok {
t.Fatal("expected completion func for --format")
}
got, _ = fn(cmd, nil, "")
want := []string{"json", "pretty", "table", "ndjson", "csv"}
if len(got) != len(want) {
t.Fatalf("format completion = %v, want %v", got, want)
}
for i, v := range want {
if got[i] != v {
t.Fatalf("format completion[%d] = %q, want %q", i, got[i], v)
}
}
}
// TestShortcutMount_FlagCompletionsDisabled verifies the switch actually
// prevents the two registrations from landing in cobra's global map.
func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
cmdutil.SetFlagCompletionsDisabled(true)
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "docs",
Command: "+fetch",
Description: "fetch doc",
HasFormat: true,
Flags: []Flag{
{Name: "sort-by", Desc: "sort", Enum: []string{"asc", "desc"}},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+fetch"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if _, ok := cmd.GetFlagCompletionFunc("sort-by"); ok {
t.Fatal("did not expect completion func for --sort-by when disabled")
}
if _, ok := cmd.GetFlagCompletionFunc("format"); ok {
t.Fatal("did not expect completion func for --format when disabled")
}
}

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
func TestShortcutMount_StrictModeHidesAsFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "docs",
Command: "+fetch",
Description: "fetch doc",
AuthTypes: []string{"user", "bot"},
Execute: func(context.Context, *RuntimeContext) error {
return nil
},
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+fetch"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if !flag.Hidden {
t.Fatal("expected --as flag to be hidden in strict mode")
}
if got := flag.DefValue; got != "bot" {
t.Fatalf("default value = %q, want %q", got, "bot")
}
}

View File

@@ -145,10 +145,10 @@ func TestRuntimeContext_FileIO_UsesExecutionContext(t *testing.T) {
}
}
func newTestShortcutCmd(s *Shortcut, f *cmdutil.Factory) *cobra.Command {
func newTestShortcutCmd(s *Shortcut) *cobra.Command {
cmd := &cobra.Command{Use: "test-shortcut"}
cmd.SetContext(context.Background())
registerShortcutFlags(cmd, f, s)
registerShortcutFlags(cmd, s)
return cmd
}
@@ -177,7 +177,7 @@ func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
return nil
},
}
cmd := newTestShortcutCmd(s, newTestFactory())
cmd := newTestShortcutCmd(s)
cmd.Flags().Set("jq", ".data")
cmd.Flags().Set("format", "table")
cmd.Flags().Set("as", "bot")
@@ -200,7 +200,7 @@ func TestRunShortcut_JqInvalidExpression(t *testing.T) {
return nil
},
}
cmd := newTestShortcutCmd(s, newTestFactory())
cmd := newTestShortcutCmd(s)
cmd.Flags().Set("jq", "invalid[")
cmd.Flags().Set("as", "bot")
@@ -223,7 +223,7 @@ func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {
return nil
},
}
cmd := newTestShortcutCmd(s, newTestFactory())
cmd := newTestShortcutCmd(s)
cmd.Flags().Set("jq", ".foo | invalid_func_xyz")
cmd.Flags().Set("as", "bot")

View File

@@ -3,7 +3,11 @@
package common
import "context"
import (
"context"
"github.com/spf13/cobra"
)
// Flag.Input source constants.
const (
@@ -43,6 +47,11 @@ type Shortcut struct {
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
// PostMount is an optional hook called after the cobra.Command is fully
// configured (flags registered, tips set) but before it is added to the
// parent. Use it to install custom help functions or tweak the command.
PostMount func(cmd *cobra.Command)
}
// ScopesForIdentity returns the scopes applicable for the given identity.

View File

@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
@@ -36,7 +35,7 @@ var fileViewMap = map[string]int{
var DocMediaInsert = common.Shortcut{
Service: "docs",
Command: "+media-insert",
Description: "Insert a local image or file into a Lark document (4-step orchestration + auto-rollback); appends to end by default, or inserts relative to a text selection with --selection-with-ellipsis",
Description: "Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback)",
Risk: "write",
Scopes: []string{"docs:document.media:upload", "docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
@@ -46,8 +45,6 @@ var DocMediaInsert = common.Shortcut{
{Name: "type", Default: "image", Desc: "type: image | file"},
{Name: "align", Desc: "alignment: left | center | right"},
{Name: "caption", Desc: "image caption text"},
{Name: "selection-with-ellipsis", Desc: "plain text (or 'start...end' to disambiguate) matching the target block's content. Media is inserted at the top-level ancestor of the matched block — i.e., when the selection is inside a callout, table cell, or nested list, media lands outside that container, not inside it. Pass 'start...end' (a unique prefix and suffix separated by '...') when the plain text appears in more than one block"},
{Name: "before", Type: "bool", Desc: "insert before the matched block instead of after (requires --selection-with-ellipsis)"},
{Name: "file-view", Desc: "file block rendering: card (default) | preview | inline; only applies when --type=file. preview renders audio/video as an inline player"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -58,18 +55,6 @@ var DocMediaInsert = common.Shortcut{
if docRef.Kind == "doc" {
return output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx")
}
rawSelection := runtime.Str("selection-with-ellipsis")
trimmedSelection := strings.TrimSpace(rawSelection)
// Explicitly reject a flag that was supplied but blank: runtime.Str cannot
// distinguish "omitted" from "provided as empty/whitespace", and a silent
// trim-to-empty would make +media-insert fall back to append-mode and
// write at the wrong location.
if rawSelection != "" && trimmedSelection == "" {
return output.ErrValidation("--selection-with-ellipsis must not be blank or whitespace-only")
}
if runtime.Bool("before") && trimmedSelection == "" {
return output.ErrValidation("--before requires --selection-with-ellipsis")
}
if view := runtime.Str("file-view"); view != "" {
if _, ok := fileViewMap[view]; !ok {
return output.ErrValidation("invalid --file-view value %q, expected one of: card | preview | inline", view)
@@ -91,71 +76,30 @@ var DocMediaInsert = common.Shortcut{
filePath := runtime.Str("file")
mediaType := runtime.Str("type")
caption := runtime.Str("caption")
selection := strings.TrimSpace(runtime.Str("selection-with-ellipsis"))
hasSelection := selection != ""
fileViewType := fileViewMap[runtime.Str("file-view")]
parentType := parentTypeForMediaType(mediaType)
createBlockData := buildCreateBlockData(mediaType, 0, fileViewType)
if hasSelection {
createBlockData["index"] = "<locate_index>"
} else {
createBlockData["index"] = "<children_len>"
}
createBlockData["index"] = "<children_len>"
batchUpdateData := buildBatchUpdateData("<new_block_id>", mediaType, "<file_token>", runtime.Str("align"), caption)
d := common.NewDryRunAPI()
totalSteps := 4
if docRef.Kind == "wiki" {
totalSteps++
}
if hasSelection {
totalSteps++
}
positionLabel := map[bool]string{true: "before", false: "after"}[runtime.Bool("before")]
if docRef.Kind == "wiki" {
documentID = "<resolved_docx_token>"
stepBase = 2
d.Desc(fmt.Sprintf("%d-step orchestration: resolve wiki → query root →%s create block → upload file → bind to block (auto-rollback on failure)",
totalSteps, map[bool]string{true: " locate-doc →", false: ""}[hasSelection])).
d.Desc("5-step orchestration: resolve wiki → query root → create block → upload file → bind to block (auto-rollback on failure)").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to docx document").
Params(map[string]interface{}{"token": docRef.Token})
} else {
d.Desc(fmt.Sprintf("%d-step orchestration: query root →%s create block → upload file → bind to block (auto-rollback on failure)",
totalSteps, map[bool]string{true: " locate-doc →", false: ""}[hasSelection]))
d.Desc("4-step orchestration: query root → create block → upload file → bind to block (auto-rollback on failure)")
}
d.
GET("/open-apis/docx/v1/documents/:document_id/blocks/:document_id").
Desc(fmt.Sprintf("[%d] Get document root block", stepBase))
if hasSelection {
mcpEndpoint := common.MCPEndpoint(runtime.Config.Brand)
mcpArgs := map[string]interface{}{
"doc_id": documentID,
"selection_with_ellipsis": selection,
"limit": 1,
}
d.POST(mcpEndpoint).
Desc(fmt.Sprintf("[%d] MCP locate-doc: find block matching selection (%s)", stepBase+1, positionLabel)).
Body(map[string]interface{}{
"method": "tools/call",
"params": map[string]interface{}{
"name": "locate-doc",
"arguments": mcpArgs,
},
}).
Set("mcp_tool", "locate-doc").
Set("args", mcpArgs)
stepBase++
}
d.
Desc(fmt.Sprintf("[%d] Get document root block", stepBase)).
POST("/open-apis/docx/v1/documents/:document_id/blocks/:document_id/children").
Desc(fmt.Sprintf("[%d] Create empty block at target position", stepBase+1)).
Desc(fmt.Sprintf("[%d] Create empty block at document end", stepBase+1)).
Body(createBlockData)
appendDocMediaInsertUploadDryRun(d, runtime.FileIO(), filePath, parentType, stepBase+2)
d.PATCH("/open-apis/docx/v1/documents/:document_id/blocks/batch_update").
@@ -200,31 +144,13 @@ var DocMediaInsert = common.Shortcut{
return err
}
parentBlockID, insertIndex, rootChildren, err := extractAppendTarget(rootData, documentID)
parentBlockID, insertIndex, err := extractAppendTarget(rootData, documentID)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Root block ready: %s (%d children)\n", parentBlockID, insertIndex)
selection := strings.TrimSpace(runtime.Str("selection-with-ellipsis"))
if selection != "" {
before := runtime.Bool("before")
// Redact the selection when logging — it is copied verbatim from
// document content and may contain confidential text.
fmt.Fprintf(runtime.IO().ErrOut, "Locating block matching selection (%s)\n", redactSelection(selection))
idx, err := locateInsertIndex(runtime, documentID, selection, rootChildren, before)
if err != nil {
return err
}
insertIndex = idx
posLabel := "after"
if before {
posLabel = "before"
}
fmt.Fprintf(runtime.IO().ErrOut, "locate-doc matched: inserting %s at index %d\n", posLabel, insertIndex)
}
// Step 2: Create an empty block at the target position
// Step 2: Create an empty block at the end of the document
fmt.Fprintf(runtime.IO().ErrOut, "Creating block at index %d\n", insertIndex)
createData, err := runtime.CallAPI("POST",
@@ -298,20 +224,6 @@ func blockTypeForMediaType(mediaType string) int {
return 27
}
// redactSelection summarizes --selection-with-ellipsis values for logging and
// error messages without echoing raw document text. Returns the rune count and,
// for longer strings, a short prefix so operators can still identify which
// selection failed without leaking confidential content into terminals or CI
// logs.
func redactSelection(s string) string {
const prefixRunes = 8
runes := []rune(s)
if len(runes) <= prefixRunes {
return fmt.Sprintf("%d chars", len(runes))
}
return fmt.Sprintf("%q… %d chars total", string(runes[:prefixRunes]), len(runes))
}
func parentTypeForMediaType(mediaType string) string {
if mediaType == "file" {
return "docx_file"
@@ -420,150 +332,19 @@ func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption strin
}
}
func extractAppendTarget(rootData map[string]interface{}, fallbackBlockID string) (parentBlockID string, insertIndex int, children []interface{}, err error) {
func extractAppendTarget(rootData map[string]interface{}, fallbackBlockID string) (string, int, error) {
block, _ := rootData["block"].(map[string]interface{})
if len(block) == 0 {
return "", 0, nil, output.Errorf(output.ExitAPI, "api_error", "failed to query document root block")
return "", 0, output.Errorf(output.ExitAPI, "api_error", "failed to query document root block")
}
parentBlockID = fallbackBlockID
parentBlockID := fallbackBlockID
if blockID, _ := block["block_id"].(string); blockID != "" {
parentBlockID = blockID
}
children, _ = block["children"].([]interface{})
return parentBlockID, len(children), children, nil
}
// locateInsertIndex uses the MCP locate-doc tool to find the root-level index
// at which to insert relative to the block matching selection. It walks the
// parent_id chain (using single-block GET calls when needed) to resolve nested
// blocks to their top-level ancestor in rootChildren.
func locateInsertIndex(runtime *common.RuntimeContext, documentID string, selection string, rootChildren []interface{}, before bool) (int, error) {
// Ask for 2 matches so we can warn when the selection is ambiguous. locate-doc
// orders matches by document position, so matches[0] is still deterministic.
args := map[string]interface{}{
"doc_id": documentID,
"selection_with_ellipsis": selection,
"limit": 2,
}
result, err := common.CallMCPTool(runtime, "locate-doc", args)
if err != nil {
return 0, err
}
matches := common.GetSlice(result, "matches")
if len(matches) == 0 {
return 0, output.ErrWithHint(
output.ExitValidation,
"no_match",
fmt.Sprintf("locate-doc did not find any block matching selection (%s)", redactSelection(selection)),
"check spelling or use 'start...end' syntax to narrow the selection",
)
}
if len(matches) > 1 {
// Silently picking the first match surprises users whose selection appears
// in more than one block (e.g. the same phrase in a title and a paragraph).
// Surface that another match exists and point at the 'start...end' disambiguator.
fmt.Fprintf(runtime.IO().ErrOut,
"warning: selection (%s) matched more than one block; inserting relative to the first. "+
"Pass --selection-with-ellipsis 'start...end' to narrow.\n",
redactSelection(selection))
}
matchMap, _ := matches[0].(map[string]interface{})
anchorBlockID := common.GetString(matchMap, "anchor_block_id")
if anchorBlockID == "" {
// Fall back to first block entry if anchor_block_id is absent.
blocks := common.GetSlice(matchMap, "blocks")
if len(blocks) > 0 {
if b, ok := blocks[0].(map[string]interface{}); ok {
anchorBlockID = common.GetString(b, "block_id")
}
}
}
if anchorBlockID == "" {
return 0, output.Errorf(output.ExitAPI, "api_error", "locate-doc response missing anchor_block_id")
}
parentBlockID := common.GetString(matchMap, "parent_block_id")
// Build root children set for O(1) lookup.
rootSet := make(map[string]int, len(rootChildren))
for i, c := range rootChildren {
if id, ok := c.(string); ok {
rootSet[id] = i
}
}
// Walk up the parent chain to the top-level ancestor in rootChildren. This
// is serial by nature: each level's parent_id is only known after the
// previous level's GET /blocks/{id} response arrives, so the calls cannot
// be batched or parallelised.
//
// visited is the real cycle guard — it stops an A→B→A parent-id loop (seen
// on malformed API responses) after one lap. maxDepth is belt-and-suspenders
// in case both visited tracking and parent_id sanity simultaneously break;
// 32 comfortably exceeds the deepest real docx nesting (~68 levels for
// quote/callout/list combinations) without letting a bug run unbounded.
cur := anchorBlockID
nextParent := parentBlockID
visited := map[string]bool{}
const maxDepth = 32
walkDepth := 0
for depth := 0; depth < maxDepth; depth++ {
if visited[cur] {
break
}
visited[cur] = true
if idx, ok := rootSet[cur]; ok {
if walkDepth > 0 {
// The anchor was nested inside a callout / table cell / list and
// got resolved to its top-level ancestor. Surface this so users
// don't misread "insert before 'X'" as "insert right next to X"
// when X is buried several levels deep.
posLabel := "after"
if before {
posLabel = "before"
}
fmt.Fprintf(runtime.IO().ErrOut,
"note: selection (%s) was nested %d level(s) deep; inserting %s its top-level ancestor at index %d\n",
redactSelection(selection), walkDepth, posLabel, idx)
}
if before {
return idx, nil
}
return idx + 1, nil
}
// Advance: use the parent hint we already have, or fetch from API.
parent := nextParent
nextParent = "" // clear hint after first use
if parent == "" || parent == cur {
// Need to fetch this block to find its parent.
data, err := runtime.CallAPI("GET",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s",
validate.EncodePathSegment(documentID), validate.EncodePathSegment(cur)),
nil, nil)
if err != nil {
return 0, err
}
block := common.GetMap(data, "block")
parent = common.GetString(block, "parent_id")
}
if parent == "" || parent == cur {
break
}
cur = parent
walkDepth++
}
return 0, output.ErrWithHint(
output.ExitValidation,
"block_not_reachable",
fmt.Sprintf("block matching selection (%s) is not reachable from document root", redactSelection(selection)),
"try a top-level heading or paragraph as the selection",
)
children, _ := block["children"].([]interface{})
return parentBlockID, len(children), nil
}
func extractCreatedBlockTargets(createData map[string]interface{}, mediaType string) (blockID, uploadParentNode, replaceBlockID string) {

View File

@@ -5,15 +5,12 @@ package doc
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -225,7 +222,7 @@ func TestExtractAppendTargetUsesRootChildrenCount(t *testing.T) {
},
}
blockID, index, children, err := extractAppendTarget(rootData, "fallback")
blockID, index, err := extractAppendTarget(rootData, "fallback")
if err != nil {
t.Fatalf("extractAppendTarget() unexpected error: %v", err)
}
@@ -235,365 +232,6 @@ func TestExtractAppendTargetUsesRootChildrenCount(t *testing.T) {
if index != 3 {
t.Fatalf("extractAppendTarget() index = %d, want 3", index)
}
if len(children) != 3 {
t.Fatalf("extractAppendTarget() children len = %d, want 3", len(children))
}
}
// buildLocateDocMCPResponse builds a JSON-RPC 2.0 response for a locate-doc MCP call.
func buildLocateDocMCPResponse(matches []map[string]interface{}) map[string]interface{} {
resultJSON, _ := json.Marshal(map[string]interface{}{"matches": matches})
return map[string]interface{}{
"jsonrpc": "2.0",
"id": "test-id",
"result": map[string]interface{}{
"content": []interface{}{
map[string]interface{}{
"type": "text",
"text": string(resultJSON),
},
},
},
}
}
// registerInsertWithSelectionStubs wires the minimal stub set for the
// --selection-with-ellipsis happy path. Returns the create-block stub so
// callers can inspect the request body (e.g. to verify the computed index).
func registerInsertWithSelectionStubs(reg interface {
Register(*httpmock.Stub)
}, docID, anchorBlockID, parentBlockID string, rootChildren []interface{}) *httpmock.Stub {
// Root block
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID,
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"block": map[string]interface{}{
"block_id": docID,
"children": rootChildren,
},
},
},
})
// MCP locate-doc
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "mcp.feishu.cn/mcp",
Body: buildLocateDocMCPResponse([]map[string]interface{}{
{"anchor_block_id": anchorBlockID, "parent_block_id": parentBlockID},
}),
})
// Create block — returned so the test can inspect index in CapturedBody.
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID + "/children",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"children": []interface{}{
map[string]interface{}{"block_id": "blk_new", "block_type": 27, "image": map[string]interface{}{}},
},
},
},
}
reg.Register(createStub)
// Upload
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "ftok_test"},
},
})
// Batch update
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/batch_update",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
return createStub
}
// assertCreateBlockIndex decodes the create-block request body and asserts the
// `index` field equals want. Fails the test if the body is missing or wrong.
func assertCreateBlockIndex(t *testing.T, stub *httpmock.Stub, want int) {
t.Helper()
if stub.CapturedBody == nil {
t.Fatalf("create-block stub captured no body")
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode create-block body: %v (raw: %s)", err, stub.CapturedBody)
}
got, _ := body["index"].(float64)
if int(got) != want {
t.Fatalf("create-block index = %v, want %d (body: %s)", body["index"], want, stub.CapturedBody)
}
}
// TestLocateInsertIndexAfterModeViaExecute verifies that
// --selection-with-ellipsis (default after-mode) places the new block
// immediately after the matched root-level block. Uses three root children so
// the after-index (2) differs from what --before would produce (1), and
// inspects the create-block request body to prove the computed index actually
// reaches the /children API.
func TestLocateInsertIndexAfterModeViaExecute(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("locate-after-app"))
createStub := registerInsertWithSelectionStubs(reg, "doxcnSEL", "blk_b", "doxcnSEL",
[]interface{}{"blk_a", "blk_b", "blk_c"})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
writeSizedDocTestFile(t, "img.png", 100)
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", "doxcnSEL",
"--file", "img.png",
"--selection-with-ellipsis", "Introduction",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("Execute() error: %v", err)
}
// after blk_b (index 1) → insert at index 2, between blk_b and blk_c.
assertCreateBlockIndex(t, createStub, 2)
}
// TestLocateInsertIndexBeforeModeViaExecute verifies that --before inserts
// before the matched root-level block. Pairs with the after-mode test above:
// same fixture, same anchor, but --before should flip the index from 2 to 1.
// A regression that ignored --before would still pass the success check alone,
// so we assert the create-block body explicitly.
func TestLocateInsertIndexBeforeModeViaExecute(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("locate-before-app"))
createStub := registerInsertWithSelectionStubs(reg, "doxcnSEL2", "blk_b", "doxcnSEL2",
[]interface{}{"blk_a", "blk_b", "blk_c"})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
writeSizedDocTestFile(t, "img.png", 100)
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", "doxcnSEL2",
"--file", "img.png",
"--selection-with-ellipsis", "Architecture",
"--before",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("Execute() error: %v", err)
}
// before blk_b (index 1) → insert at index 1, between blk_a and blk_b.
assertCreateBlockIndex(t, createStub, 1)
}
// TestLocateInsertIndexNestedBlockViaExecute verifies that a deeply-nested
// anchor (2+ levels below root) walks up through an intermediate block via
// the GET /blocks/{id} API to find the root-level ancestor. This exercises
// the fallback ancestor-walk path in locateInsertIndex — the parent_block_id
// hint from locate-doc is only good for one level, so deeper nesting must hit
// the block-fetch loop.
func TestLocateInsertIndexNestedBlockViaExecute(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("locate-nested-app"))
docID := "doxcnNESTED"
// Root children: blk_section (index 0), blk_other (index 1).
// Anchor blk_grandchild is nested two levels deep:
// root → blk_section → blk_section_child → blk_grandchild
// locate-doc gives us parent_block_id = blk_section_child (one level up);
// the walk must fetch blk_section_child to discover its parent = blk_section
// before it can land on a root child.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID,
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"block": map[string]interface{}{
"block_id": docID,
"children": []interface{}{"blk_section", "blk_other"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "mcp.feishu.cn/mcp",
Body: buildLocateDocMCPResponse([]map[string]interface{}{
{"anchor_block_id": "blk_grandchild", "parent_block_id": "blk_section_child"},
}),
})
// Intermediate block lookup — this is the key step that exercises the
// fallback walk. Without this stub the test would fail.
intermediateStub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/blk_section_child",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"block": map[string]interface{}{
"block_id": "blk_section_child",
"parent_id": "blk_section",
},
},
},
}
reg.Register(intermediateStub)
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID + "/children",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"children": []interface{}{
map[string]interface{}{"block_id": "blk_new", "block_type": 27, "image": map[string]interface{}{}},
},
},
},
}
reg.Register(createStub)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "ftok_nested"},
},
})
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/batch_update",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
writeSizedDocTestFile(t, "img.png", 100)
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", docID,
"--file", "img.png",
"--selection-with-ellipsis", "nested content",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("Execute() error: %v", err)
}
// Confirm the ancestor-walk actually fired — without this assertion a
// regression that short-circuited the walk would still pass.
if intermediateStub.CapturedBody == nil && intermediateStub.CapturedHeaders == nil {
t.Errorf("expected GET /blocks/blk_section_child to be invoked by the parent-walk; stub was not hit")
}
// after blk_section (index 0) → insert at index 1, between blk_section and blk_other.
assertCreateBlockIndex(t, createStub, 1)
}
// TestLocateInsertIndexNoMatchReturnsError verifies that when locate-doc returns
// no matches, Execute returns a descriptive error.
func TestLocateInsertIndexNoMatchReturnsError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("locate-nomatch-app"))
docID := "doxcnNOMATCH"
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID,
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"block": map[string]interface{}{
"block_id": docID,
"children": []interface{}{"blk_a"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "mcp.feishu.cn/mcp",
Body: buildLocateDocMCPResponse([]map[string]interface{}{}),
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
writeSizedDocTestFile(t, "img.png", 100)
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", docID,
"--file", "img.png",
"--selection-with-ellipsis", "nonexistent text",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected no-match error, got nil")
}
if !strings.Contains(err.Error(), "no_match") && !strings.Contains(err.Error(), "did not find") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestLocateInsertIndexDryRunIncludesMCPStep verifies that the dry-run output
// includes a locate-doc MCP step when --selection-with-ellipsis is provided.
func TestLocateInsertIndexDryRunIncludesMCPStep(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "docs +media-insert"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("doc", "", "")
cmd.Flags().String("type", "image", "")
cmd.Flags().String("align", "", "")
cmd.Flags().String("caption", "", "")
cmd.Flags().String("selection-with-ellipsis", "", "")
cmd.Flags().Bool("before", false, "")
_ = cmd.Flags().Set("file", "img.png")
_ = cmd.Flags().Set("doc", "doxcnABCDEF")
_ = cmd.Flags().Set("selection-with-ellipsis", "Introduction")
rt := common.TestNewRuntimeContext(cmd, docsTestConfigWithAppID("dry-run-app"))
dryAPI := DocMediaInsert.DryRun(context.Background(), rt)
raw, _ := json.Marshal(dryAPI)
var dry struct {
Description string `json:"description"`
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(raw, &dry); err != nil {
t.Fatalf("decode dry-run: %v", err)
}
foundMCP := false
for _, step := range dry.API {
if strings.Contains(step.Desc, "locate-doc") {
foundMCP = true
}
}
if !foundMCP {
t.Fatalf("dry-run should include a locate-doc step, got: %+v", dry.API)
}
if !strings.Contains(dry.Description, "locate-doc") {
t.Fatalf("dry-run description should mention 'locate-doc', got: %s", dry.Description)
}
// Verify create-block step shows <locate_index> not <children_len>
for _, step := range dry.API {
if strings.Contains(step.URL, "/children") && step.Body != nil {
if idx, ok := step.Body["index"]; ok {
if idx != "<locate_index>" {
t.Fatalf("create-block index in selection mode = %q, want <locate_index>", idx)
}
}
}
}
}
func TestExtractCreatedBlockTargetsForImage(t *testing.T) {
@@ -731,256 +369,3 @@ func TestDocMediaInsertValidateFileView(t *testing.T) {
})
}
}
// TestLocateInsertIndexWarnsOnMultipleMatches verifies that when locate-doc
// returns more than one match, a warning is written to stderr pointing the user
// at the 'start...end' disambiguation syntax. Silently picking the first match
// of an ambiguous selection is a real UX trap — users who edit documents with
// repeated phrases (a heading that also appears in the TOC, for example) get
// no signal that another match existed.
func TestLocateInsertIndexWarnsOnMultipleMatches(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("locate-multi-app"))
docID := "doxcnMULTI"
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID,
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"block": map[string]interface{}{
"block_id": docID,
"children": []interface{}{"blk_a", "blk_b"},
},
},
},
})
// Two matches — same selection appears in two different root-level blocks.
// locate-doc orders matches by document position, so matches[0] is still
// deterministic (blk_a) even with limit=2.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "mcp.feishu.cn/mcp",
Body: buildLocateDocMCPResponse([]map[string]interface{}{
{"anchor_block_id": "blk_a", "parent_block_id": docID},
{"anchor_block_id": "blk_b", "parent_block_id": docID},
}),
})
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID + "/children",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"children": []interface{}{
map[string]interface{}{"block_id": "blk_new", "block_type": 27, "image": map[string]interface{}{}},
},
},
},
}
reg.Register(createStub)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "ftok_multi"},
},
})
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/batch_update",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
writeSizedDocTestFile(t, "img.png", 100)
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", docID,
"--file", "img.png",
"--selection-with-ellipsis", "Repeated phrase",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("Execute() error: %v", err)
}
// Warning should name the ambiguity and point at 'start...end'.
stderrOut := stderr.String()
if !strings.Contains(stderrOut, "matched more than one block") {
t.Errorf("stderr missing multi-match warning; got:\n%s", stderrOut)
}
if !strings.Contains(stderrOut, "start...end") {
t.Errorf("stderr missing 'start...end' disambiguation hint; got:\n%s", stderrOut)
}
// Should still insert at the first match (blk_a at index 0) → after ⇒ 1.
assertCreateBlockIndex(t, createStub, 1)
}
// TestLocateInsertIndexLogsNestedAnchor verifies that when the matched block is
// nested (not a direct root child), a note is written to stderr explaining that
// the media lands at the top-level ancestor. This protects users from being
// surprised when selecting text inside a callout or table cell and seeing the
// image appear outside that container.
func TestLocateInsertIndexLogsNestedAnchor(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("locate-nested-log-app"))
docID := "doxcnNESTEDLOG"
// Same shape as TestLocateInsertIndexNestedBlockViaExecute: anchor is two
// levels below root, so walkDepth == 2 when we hit the root ancestor.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID,
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"block": map[string]interface{}{
"block_id": docID,
"children": []interface{}{"blk_section", "blk_other"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "mcp.feishu.cn/mcp",
Body: buildLocateDocMCPResponse([]map[string]interface{}{
{"anchor_block_id": "blk_grandchild", "parent_block_id": "blk_section_child"},
}),
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/blk_section_child",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"block": map[string]interface{}{
"block_id": "blk_section_child",
"parent_id": "blk_section",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID + "/children",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"children": []interface{}{
map[string]interface{}{"block_id": "blk_new", "block_type": 27, "image": map[string]interface{}{}},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "ftok_nested_log"},
},
})
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/batch_update",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
writeSizedDocTestFile(t, "img.png", 100)
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", docID,
"--file", "img.png",
"--selection-with-ellipsis", "nested content",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("Execute() error: %v", err)
}
stderrOut := stderr.String()
if !strings.Contains(stderrOut, "nested") || !strings.Contains(stderrOut, "top-level ancestor") {
t.Errorf("stderr missing nested-anchor note; got:\n%s", stderrOut)
}
}
// TestLocateInsertIndexCycleDetection verifies that a malformed parent chain
// (blk_x.parent = blk_y and blk_y.parent = blk_x, neither reachable from root)
// does not spin the locate-doc walk forever. The `visited` map must break the
// cycle, and the user must see the "not reachable from document root" error
// rather than the process hanging. Without this test, a regression that broke
// cycle protection would only surface in production with a stalled CLI.
func TestLocateInsertIndexCycleDetection(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("locate-cycle-app"))
docID := "doxcnCYCLE"
// Root has unrelated children — neither blk_x nor blk_y reach root.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID,
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"block": map[string]interface{}{
"block_id": docID,
"children": []interface{}{"blk_unrelated_a", "blk_unrelated_b"},
},
},
},
})
// locate-doc hints parent_block_id = blk_y for anchor blk_x (first hop consumed).
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "mcp.feishu.cn/mcp",
Body: buildLocateDocMCPResponse([]map[string]interface{}{
{"anchor_block_id": "blk_x", "parent_block_id": "blk_y"},
}),
})
// blk_y claims blk_x as parent — closes the cycle. The walk must land here
// exactly once before visited[blk_x] triggers a break.
blkYStub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/blk_y",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"block": map[string]interface{}{
"block_id": "blk_y",
"parent_id": "blk_x",
},
},
},
}
reg.Register(blkYStub)
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
writeSizedDocTestFile(t, "img.png", 100)
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", docID,
"--file", "img.png",
"--selection-with-ellipsis", "cyclic anchor",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected 'block_not_reachable' error from cyclic parent chain; got nil")
}
if !strings.Contains(err.Error(), "not reachable") && !strings.Contains(err.Error(), "block_not_reachable") {
t.Fatalf("unexpected error — want cycle-bounded 'not reachable', got: %v", err)
}
// blk_y should be fetched exactly once. Registering just one stub for it
// already enforces an upper bound (httpmock errors on extra calls), so if
// the walk looped more than once the test harness would fail differently.
if blkYStub.CapturedHeaders == nil && blkYStub.CapturedBody == nil {
t.Errorf("expected the walk to fetch blk_y once; stub was not hit")
}
}

View File

@@ -118,7 +118,7 @@ func TestDocMediaUploadDryRunUsesMultipartForLargeFile(t *testing.T) {
t.Fatalf("set --parent-node: %v", err)
}
dry := decodeDocDryRun(t, DocMediaUpload.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
dry := decodeDocDryRun(t, MediaUpload.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
if dry.Description != "chunked media upload (files > 20MB)" {
t.Fatalf("dry-run description = %q", dry.Description)
}

View File

@@ -13,7 +13,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
var DocMediaUpload = common.Shortcut{
var MediaUpload = common.Shortcut{
Service: "docs",
Command: "+media-upload",
Description: "Upload media file (image/attachment) to a document block",
@@ -22,8 +22,8 @@ var DocMediaUpload = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true},
{Name: "parent-type", Desc: "parent type: docx_image | docx_file", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id)", Required: true},
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {

View File

@@ -7,9 +7,35 @@ import (
"context"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// v1CreateFlags returns the flag definitions for the v1 (MCP) create path.
func v1CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title", Hidden: true},
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "folder-token", Desc: "parent folder token", Hidden: true},
{Name: "wiki-node", Desc: "wiki node token", Hidden: true},
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)", Hidden: true},
}
}
var docsCreateFlagVersions = buildFlagVersionMap(v1CreateFlags(), v2CreateFlags())
// useV2Create returns true when the v2 (OpenAPI) create path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
func useV2Create(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
return runtime.Str("content") != "" ||
runtime.Str("parent-token") != "" ||
runtime.Str("parent-position") != ""
}
var DocsCreate = common.Shortcut{
Service: "docs",
Command: "+create",
@@ -17,56 +43,85 @@ var DocsCreate = common.Shortcut{
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"docx:document:create"},
Flags: []common.Flag{
{Name: "title", Desc: "document title"},
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "folder-token", Desc: "parent folder token"},
{Name: "wiki-node", Desc: "wiki node token"},
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)"},
},
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
},
v1CreateFlags(),
v2CreateFlags(),
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
count := 0
if runtime.Str("folder-token") != "" {
count++
if useV2Create(runtime) {
return validateCreateV2(ctx, runtime)
}
if runtime.Str("wiki-node") != "" {
count++
}
if runtime.Str("wiki-space") != "" {
count++
}
if count > 1 {
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
}
return nil
return validateCreateV1(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildDocsCreateArgs(runtime)
d := common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: create-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
Set("mcp_tool", "create-doc").Set("args", args)
if runtime.IsBot() {
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
if useV2Create(runtime) {
return dryRunCreateV2(ctx, runtime)
}
return d
return dryRunCreateV1(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
args := buildDocsCreateArgs(runtime)
result, err := common.CallMCPTool(runtime, "create-doc", args)
if err != nil {
return err
if useV2Create(runtime) {
return executeCreateV2(ctx, runtime)
}
augmentDocsCreateResult(runtime, result)
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
return executeCreateV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsCreateFlagVersions)
},
}
func buildDocsCreateArgs(runtime *common.RuntimeContext) map[string]interface{} {
// ── V1 (MCP) implementation ──
func validateCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("markdown") == "" {
return common.FlagErrorf("--markdown is required")
}
count := 0
if runtime.Str("folder-token") != "" {
count++
}
if runtime.Str("wiki-node") != "" {
count++
}
if runtime.Str("wiki-space") != "" {
count++
}
if count > 1 {
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
}
return nil
}
func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildCreateArgsV1(runtime)
d := common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: create-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
Set("mcp_tool", "create-doc").Set("args", args)
if runtime.IsBot() {
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
}
return d
}
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+create")
args := buildCreateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "create-doc", args)
if err != nil {
return err
}
augmentCreateResultV1(runtime, result)
normalizeWhiteboardResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
}
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
args := map[string]interface{}{
"markdown": runtime.Str("markdown"),
}
@@ -90,18 +145,17 @@ type docsPermissionTarget struct {
Type string
}
func augmentDocsCreateResult(runtime *common.RuntimeContext, result map[string]interface{}) {
target := selectDocsPermissionTarget(result)
func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]interface{}) {
target := selectPermissionTarget(result)
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
result["permission_grant"] = grant
}
}
func selectDocsPermissionTarget(result map[string]interface{}) docsPermissionTarget {
if ref, ok := parseDocsPermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget {
if ref, ok := parsePermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
return ref
}
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
if docID != "" {
return docsPermissionTarget{Token: docID, Type: "docx"}
@@ -109,16 +163,14 @@ func selectDocsPermissionTarget(result map[string]interface{}) docsPermissionTar
return docsPermissionTarget{}
}
func parseDocsPermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
func parsePermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
if strings.TrimSpace(docURL) == "" {
return docsPermissionTarget{}, false
}
ref, err := parseDocumentRef(docURL)
if err != nil {
return docsPermissionTarget{}, false
}
switch ref.Kind {
case "wiki":
return docsPermissionTarget{Token: ref.Token, Type: "wiki"}, true
@@ -128,3 +180,68 @@ func parseDocsPermissionTargetFromURL(docURL string) (docsPermissionTarget, bool
return docsPermissionTarget{}, false
}
}
// normalizeWhiteboardResult normalizes board_tokens in the MCP response when
// whiteboard creation markdown is detected.
func normalizeWhiteboardResult(result map[string]interface{}, markdown string) {
if !isWhiteboardCreateMarkdown(markdown) {
return
}
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
}
func isWhiteboardCreateMarkdown(markdown string) bool {
lower := strings.ToLower(markdown)
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
return true
}
return strings.Contains(lower, "<whiteboard") &&
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
}
func normalizeBoardTokens(raw interface{}) []string {
switch v := raw.(type) {
case nil:
return []string{}
case []string:
return v
case []interface{}:
tokens := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
tokens = append(tokens, s)
}
}
return tokens
case string:
if v == "" {
return []string{}
}
return []string{v}
default:
return []string{}
}
}
// ── Shared helpers ──
// concatFlags combines multiple flag slices into one.
func concatFlags(slices ...[]common.Flag) []common.Flag {
var out []common.Flag
for _, s := range slices {
out = append(out, s...)
}
return out
}
// buildFlagVersionMap creates a flag name → version mapping from v1 and v2 flag lists.
func buildFlagVersionMap(v1, v2 []common.Flag) map[string]string {
m := make(map[string]string, len(v1)+len(v2))
for _, f := range v1 {
m[f.Name] = "v1"
}
for _, f := range v2 {
m[f.Name] = "v2"
}
return m
}

View File

@@ -9,15 +9,182 @@ import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestDocsCreateBotAutoGrantSuccess(t *testing.T) {
// ── V2 (OpenAPI) tests ──
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_current_user",
"member_type": "openid",
"perm": "full_access",
},
},
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>项目计划</title><h1>目标</h1>",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("failed to parse permission request body: %v", err)
}
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
}
func TestDocsCreateV2BotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>内容</title><p>正文</p>",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if _, ok := grant["user_open_id"]; ok {
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
}
}
func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>内容</title><p>正文</p>",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>内容</title><p>正文</p>",
"--as", "bot",
})
if err != nil {
t.Fatalf("document creation should still succeed when auto-grant fails, got: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
}
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
}
// ── V1 (MCP) tests ──
func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
@@ -59,77 +226,9 @@ func TestDocsCreateBotAutoGrantSuccess(t *testing.T) {
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("failed to parse permission request body: %v", err)
}
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
}
func TestDocsCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--markdown", "## 内容",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if _, ok := grant["user_open_id"]; ok {
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
}
}
func TestDocsCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--markdown", "## 内容",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
@@ -164,12 +263,6 @@ func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
}
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
@@ -180,6 +273,8 @@ func TestDocsCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
}
}
// ── Helpers ──
func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
t.Helper()
@@ -193,6 +288,18 @@ func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
}
}
func registerDocsCreateAPIStub(reg *httpmock.Registry, data map[string]interface{}) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interface{}) {
payload, _ := json.Marshal(result)
reg.Register(&httpmock.Stub{
@@ -214,15 +321,7 @@ func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interfa
func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "docs"}
DocsCreate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
return mountAndRunDocs(t, DocsCreate, args, f, stdout)
}
func decodeDocsCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "content", Desc: "document content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "parent-token", Desc: "parent folder or wiki-node token", Hidden: true},
{Name: "parent-position", Desc: "parent position (e.g. my_library)", Hidden: true},
}
}
func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("content") == "" {
return common.FlagErrorf("--content is required")
}
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
return common.FlagErrorf("--parent-token and --parent-position are mutually exclusive")
}
return nil
}
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCreateBody(runtime)
d := common.NewDryRunAPI().
POST("/open-apis/docs_ai/v1/documents").
Desc("OpenAPI: create document").
Body(body)
if runtime.IsBot() {
d.Desc("After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
}
return d
}
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
body := buildCreateBody(runtime)
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
if err != nil {
return err
}
stripBlockIDs(data)
augmentDocsCreatePermission(runtime, data)
runtime.OutRaw(data, nil)
return nil
}
func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
"content": runtime.Str("content"),
}
if v := runtime.Str("parent-token"); v != "" {
body["parent_token"] = v
}
if v := runtime.Str("parent-position"); v != "" {
body["parent_position"] = v
}
injectDocsScene(runtime, body)
return body
}
// augmentDocsCreatePermission grants full_access to the current CLI user when
// the document was created with bot identity.
func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string]interface{}) {
doc, _ := data["document"].(map[string]interface{})
if doc == nil {
return
}
docID := strings.TrimSpace(common.GetString(doc, "document_id"))
if docID == "" {
return
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, docID, "docx"); grant != nil {
data["permission_grant"] = grant
}
}

View File

@@ -9,9 +9,45 @@ import (
"io"
"strconv"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// v1FetchFlags returns the flag definitions for the v1 (MCP) fetch path.
func v1FetchFlags() []common.Flag {
return []common.Flag{
{Name: "offset", Desc: "pagination offset", Hidden: true},
{Name: "limit", Desc: "pagination limit", Hidden: true},
}
}
var docsFetchFlagVersions = buildFlagVersionMap(v1FetchFlags(), v2FetchFlags())
// useV2Fetch returns true when the v2 (OpenAPI) fetch path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only
// flags with non-default values (bare "--doc xxx" stays on v1).
func useV2Fetch(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
// --doc-format default is "xml", --detail default is "simple", --revision-id default is -1.
// Only trigger auto-detect when a non-default value is present.
if d := runtime.Str("detail"); d != "" && d != "simple" {
return true
}
if f := runtime.Str("doc-format"); f != "" && f != "xml" {
return true
}
if runtime.Int("revision-id") != -1 {
return true
}
if m := runtime.Str("scope"); m != "" && m != "full" {
return true
}
return false
}
var DocsFetch = common.Shortcut{
Service: "docs",
Command: "+fetch",
@@ -20,66 +56,81 @@ var DocsFetch = common.Shortcut{
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "offset", Desc: "pagination offset"},
{Name: "limit", Desc: "pagination limit"},
},
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
{Name: "doc", Desc: "document URL or token", Required: true},
},
v1FetchFlags(),
v2FetchFlags(),
),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// Default to skipping embedded task detail expansion for faster +fetch output.
"skip_task_detail": true,
if useV2Fetch(runtime) {
return dryRunFetchV2(ctx, runtime)
}
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)
args["offset"] = n
}
if v := runtime.Str("limit"); v != "" {
n, _ := strconv.Atoi(v)
args["limit"] = n
}
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: fetch-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}).
Set("mcp_tool", "fetch-doc").Set("args", args)
return dryRunFetchV1(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// Default to skipping embedded task detail expansion for faster +fetch output.
"skip_task_detail": true,
if useV2Fetch(runtime) {
return executeFetchV2(ctx, runtime)
}
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)
args["offset"] = n
}
if v := runtime.Str("limit"); v != "" {
n, _ := strconv.Atoi(v)
args["limit"] = n
}
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
if err != nil {
return err
}
if md, ok := result["markdown"].(string); ok {
result["markdown"] = fixExportedMarkdown(md)
}
runtime.OutFormat(result, nil, func(w io.Writer) {
if title, ok := result["title"].(string); ok && title != "" {
fmt.Fprintf(w, "# %s\n\n", title)
}
if md, ok := result["markdown"].(string); ok {
fmt.Fprintln(w, md)
}
if hasMore, ok := result["has_more"].(bool); ok && hasMore {
fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---")
}
})
return nil
return executeFetchV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsFetchFlagVersions)
},
}
// ── V1 (MCP) implementation ──
func dryRunFetchV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildFetchArgsV1(runtime)
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: fetch-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}).
Set("mcp_tool", "fetch-doc").Set("args", args)
}
func executeFetchV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+fetch")
args := buildFetchArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
if err != nil {
return err
}
if md, ok := result["markdown"].(string); ok {
result["markdown"] = fixExportedMarkdown(md)
}
runtime.OutFormat(result, nil, func(w io.Writer) {
if title, ok := result["title"].(string); ok && title != "" {
fmt.Fprintf(w, "# %s\n\n", title)
}
if md, ok := result["markdown"].(string); ok {
fmt.Fprintln(w, md)
}
if hasMore, ok := result["has_more"].(bool); ok && hasMore {
fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---")
}
})
return nil
}
func buildFetchArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"skip_task_detail": true,
}
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)
args["offset"] = n
}
if v := runtime.Str("limit"); v != "" {
n, _ := strconv.Atoi(v)
args["limit"] = n
}
return args
}

View File

@@ -0,0 +1,194 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
return []common.Flag{
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown", "text"}},
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
{Name: "keyword", Desc: "keyword mode: search string (case-insensitive); use '|' to match multiple keywords, e.g. 'foo|bar|baz'"},
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},
}
}
func dryRunFetchV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("error: %v", err))
}
body := buildFetchBody(runtime)
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
return common.NewDryRunAPI().
POST(apiPath).
Desc("OpenAPI: fetch document").
Body(body).
Set("document_id", ref.Token)
}
func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return err
}
if err := validateFetchDetail(runtime); err != nil {
return err
}
if err := validateReadModeFlags(runtime); err != nil {
return err
}
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
body := buildFetchBody(runtime)
data, err := doDocAPI(runtime, "POST", apiPath, body)
if err != nil {
return err
}
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
if doc, ok := data["document"].(map[string]interface{}); ok {
if content, ok := doc["content"].(string); ok {
fmt.Fprintln(w, content)
}
}
})
return nil
}
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
}
if v := runtime.Int("revision-id"); v > 0 {
body["revision_id"] = v
}
detail := runtime.Str("detail")
switch detail {
case "", "simple":
body["export_option"] = map[string]interface{}{
"export_block_id": false,
"export_style_attrs": false,
"export_cite_extra_data": false,
}
case "with-ids":
body["export_option"] = map[string]interface{}{
"export_block_id": true,
}
case "full":
body["export_option"] = map[string]interface{}{
"export_block_id": true,
"export_style_attrs": true,
"export_cite_extra_data": true,
}
}
if ro := buildReadOption(runtime); ro != nil {
body["read_option"] = ro
}
injectDocsScene(runtime, body)
return body
}
// buildReadOption 拼装 read_option JSONfull/空模式返回 nil让服务端走默认全文路径。
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
mode := strings.TrimSpace(runtime.Str("scope"))
if mode == "" || mode == "full" {
return nil
}
ro := map[string]interface{}{"read_mode": mode}
if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" {
ro["start_block_id"] = v
}
if v := strings.TrimSpace(runtime.Str("end-block-id")); v != "" {
ro["end_block_id"] = v
}
if v := strings.TrimSpace(runtime.Str("keyword")); v != "" {
ro["keyword"] = v
}
if v := runtime.Int("context-before"); v > 0 {
ro["context_before"] = strconv.Itoa(v)
}
if v := runtime.Int("context-after"); v > 0 {
ro["context_after"] = strconv.Itoa(v)
}
if v := runtime.Int("max-depth"); v >= 0 {
ro["max_depth"] = strconv.Itoa(v)
}
return ro
}
// validateFetchDetail 非 xml 格式markdown/text不承载 block_id 与样式属性,拒绝 with-ids/full。
func validateFetchDetail(runtime *common.RuntimeContext) error {
format := strings.TrimSpace(runtime.Str("doc-format"))
detail := strings.TrimSpace(runtime.Str("detail"))
if format == "" || format == "xml" {
return nil
}
if detail == "with-ids" || detail == "full" {
return fmt.Errorf("--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format)
}
return nil
}
// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。
func validateReadModeFlags(runtime *common.RuntimeContext) error {
mode := strings.TrimSpace(runtime.Str("scope"))
if mode == "" || mode == "full" {
return nil
}
if v := runtime.Int("context-before"); v < 0 {
return fmt.Errorf("--context-before must be >= 0, got %d", v)
}
if v := runtime.Int("context-after"); v < 0 {
return fmt.Errorf("--context-after must be >= 0, got %d", v)
}
if v := runtime.Int("max-depth"); v < -1 {
return fmt.Errorf("--max-depth must be >= -1, got %d", v)
}
switch mode {
case "outline":
return nil
case "range":
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
strings.TrimSpace(runtime.Str("end-block-id")) == "" {
return fmt.Errorf("range mode requires --start-block-id or --end-block-id")
}
return nil
case "keyword":
if strings.TrimSpace(runtime.Str("keyword")) == "" {
return fmt.Errorf("keyword mode requires --keyword")
}
return nil
case "section":
if strings.TrimSpace(runtime.Str("start-block-id")) == "" {
return fmt.Errorf("section mode requires --start-block-id")
}
return nil
default:
return fmt.Errorf("invalid --scope %q", mode)
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestBuildFetchBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, " DoubaoCLI ")
runtime := newFetchBodyTestRuntime(ctx)
body := buildFetchBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildCreateBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
runtime := newCreateBodyTestRuntime(ctx)
body := buildCreateBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildUpdateBodyIncludesSceneFromContext(t *testing.T) {
t.Parallel()
ctx := context.WithValue(context.Background(), docsSceneContextKey, "DoubaoCLI")
runtime := newUpdateBodyTestRuntime(ctx)
body := buildUpdateBody(runtime)
if got := body["scene"]; got != "DoubaoCLI" {
t.Fatalf("scene = %#v, want %q", got, "DoubaoCLI")
}
}
func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
body := buildFetchBody(runtime)
if _, ok := body["scene"]; ok {
t.Fatalf("did not expect empty scene in fetch body: %#v", body)
}
}
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+fetch"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("detail", "simple", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("scope", "full", "")
cmd.Flags().String("start-block-id", "", "")
cmd.Flags().String("end-block-id", "", "")
cmd.Flags().String("keyword", "", "")
cmd.Flags().Int("context-before", 0, "")
cmd.Flags().Int("context-after", 0, "")
cmd.Flags().Int("max-depth", -1, "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+create"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("content", "<title>hello</title>", "")
cmd.Flags().String("parent-token", "", "")
cmd.Flags().String("parent-position", "", "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}
func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+update"}
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("command", "append", "")
cmd.Flags().Int("revision-id", 0, "")
cmd.Flags().String("content", "<p>hello</p>", "")
cmd.Flags().String("pattern", "", "")
cmd.Flags().String("block-id", "", "")
cmd.Flags().String("src-block-ids", "", "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}

View File

@@ -5,13 +5,13 @@ package doc
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
var validModes = map[string]bool{
var validModesV1 = map[string]bool{
"append": true,
"overwrite": true,
"replace_range": true,
@@ -21,7 +21,7 @@ var validModes = map[string]bool{
"delete_range": true,
}
var needsSelection = map[string]bool{
var needsSelectionV1 = map[string]bool{
"replace_range": true,
"replace_all": true,
"insert_before": true,
@@ -29,6 +29,32 @@ var needsSelection = map[string]bool{
"delete_range": true,
}
// v1UpdateFlags returns the flag definitions for the v1 (MCP) update path.
func v1UpdateFlags() []common.Flag {
return []common.Flag{
{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Hidden: true},
{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')", Hidden: true},
{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')", Hidden: true},
{Name: "new-title", Desc: "also update document title", Hidden: true},
}
}
var docsUpdateFlagVersions = buildFlagVersionMap(v1UpdateFlags(), v2UpdateFlags())
// useV2Update returns true when the v2 (OpenAPI) update path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
func useV2Update(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
return runtime.Str("command") != "" ||
runtime.Str("content") != "" ||
runtime.Str("pattern") != "" ||
runtime.Str("block-id") != "" ||
runtime.Str("src-block-ids") != ""
}
var DocsUpdate = common.Shortcut{
Service: "docs",
Command: "+update",
@@ -36,151 +62,104 @@ var DocsUpdate = common.Shortcut{
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Required: true},
{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Input: []string{common.File, common.Stdin}},
{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')"},
{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')"},
{Name: "new-title", Desc: "also update document title"},
},
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
{Name: "doc", Desc: "document URL or token", Required: true},
},
v1UpdateFlags(),
v2UpdateFlags(),
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
mode := runtime.Str("mode")
if !validModes[mode] {
return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode)
if useV2Update(runtime) {
return validateUpdateV2(ctx, runtime)
}
if mode != "delete_range" && runtime.Str("markdown") == "" {
return common.FlagErrorf("--%s mode requires --markdown", mode)
}
selEllipsis := runtime.Str("selection-with-ellipsis")
selTitle := runtime.Str("selection-by-title")
if selEllipsis != "" && selTitle != "" {
return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive")
}
if needsSelection[mode] && selEllipsis == "" && selTitle == "" {
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
}
if err := validateSelectionByTitle(selTitle); err != nil {
return err
}
return nil
return validateUpdateV1(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"mode": runtime.Str("mode"),
if useV2Update(runtime) {
return dryRunUpdateV2(ctx, runtime)
}
if v := runtime.Str("markdown"); v != "" {
args["markdown"] = v
}
if v := runtime.Str("selection-with-ellipsis"); v != "" {
args["selection_with_ellipsis"] = v
}
if v := runtime.Str("selection-by-title"); v != "" {
args["selection_by_title"] = v
}
if v := runtime.Str("new-title"); v != "" {
args["new_title"] = v
}
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: update-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}).
Set("mcp_tool", "update-doc").Set("args", args)
return dryRunUpdateV1(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
mode := runtime.Str("mode")
markdown := runtime.Str("markdown")
// Static semantic checks run before the MCP call so users see
// warnings even if the subsequent request fails. They never block
// execution — the update still proceeds.
for _, w := range docsUpdateWarnings(mode, markdown) {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
if useV2Update(runtime) {
return executeUpdateV2(ctx, runtime)
}
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"mode": mode,
}
if markdown != "" {
args["markdown"] = markdown
}
if v := runtime.Str("selection-with-ellipsis"); v != "" {
args["selection_with_ellipsis"] = v
}
if v := runtime.Str("selection-by-title"); v != "" {
args["selection_by_title"] = v
}
if v := runtime.Str("new-title"); v != "" {
args["new_title"] = v
}
result, err := common.CallMCPTool(runtime, "update-doc", args)
if err != nil {
return err
}
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
return executeUpdateV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsUpdateFlagVersions)
},
}
func normalizeDocsUpdateResult(result map[string]interface{}, markdown string) {
if !isWhiteboardCreateMarkdown(markdown) {
return
// ── V1 (MCP) implementation ──
func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
mode := runtime.Str("mode")
if mode == "" {
return common.FlagErrorf("--mode is required")
}
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
if !validModesV1[mode] {
return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode)
}
if mode != "delete_range" && runtime.Str("markdown") == "" {
return common.FlagErrorf("--%s mode requires --markdown", mode)
}
selEllipsis := runtime.Str("selection-with-ellipsis")
selTitle := runtime.Str("selection-by-title")
if selEllipsis != "" && selTitle != "" {
return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive")
}
if needsSelectionV1[mode] && selEllipsis == "" && selTitle == "" {
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
}
return nil
}
func isWhiteboardCreateMarkdown(markdown string) bool {
lower := strings.ToLower(markdown)
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
return true
}
return strings.Contains(lower, "<whiteboard") &&
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
func dryRunUpdateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildUpdateArgsV1(runtime)
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: update-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}).
Set("mcp_tool", "update-doc").Set("args", args)
}
func normalizeBoardTokens(raw interface{}) []string {
switch v := raw.(type) {
case nil:
return []string{}
case []string:
return v
case []interface{}:
tokens := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
tokens = append(tokens, s)
}
}
return tokens
case string:
if v == "" {
return []string{}
}
return []string{v}
default:
return []string{}
func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+update")
args := buildUpdateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "update-doc", args)
if err != nil {
return err
}
normalizeWhiteboardResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
}
func validateSelectionByTitle(title string) error {
if title == "" {
return nil
func buildUpdateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"mode": runtime.Str("mode"),
}
trimmed := strings.TrimSpace(title)
if strings.Contains(trimmed, "\n") || strings.Contains(trimmed, "\r") {
return common.FlagErrorf("--selection-by-title must be a single heading line (for example: '## Section')")
if v := runtime.Str("markdown"); v != "" {
args["markdown"] = v
}
if strings.HasPrefix(trimmed, "#") {
return nil
if v := runtime.Str("selection-with-ellipsis"); v != "" {
args["selection_with_ellipsis"] = v
}
return common.FlagErrorf("--selection-by-title must include markdown heading prefix '#'. Example: --selection-by-title '## Section'")
if v := runtime.Str("selection-by-title"); v != "" {
args["selection_by_title"] = v
}
if v := runtime.Str("new-title"); v != "" {
args["new_title"] = v
}
return args
}

View File

@@ -1,281 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"regexp"
"strings"
)
// docsUpdateWarnings returns a list of human-readable warnings for a
// `docs +update` invocation based on static analysis of the mode and
// Markdown payload. The warnings describe CLI/MCP contract edges that
// commonly surprise users; the update is still executed — callers
// decide whether to stop at a warning.
//
// Both checks ignore fenced code blocks (```…``` and ~~~…~~~, with up
// to 3 leading spaces per CommonMark §4.5), inline code spans, and
// backslash-escaped emphasis markers so that literal Markdown content
// embedded in code samples or escaped prose does not produce false
// positives.
//
// Warnings emitted (current):
//
// 1. replace_* modes do not split blocks. A Markdown payload containing
// a blank line (\n\n) in prose implies the caller expects multiple
// paragraphs, but replace_range / replace_all only swap in-block
// text. The resulting block will contain the blank line as literal
// text and appear as a single paragraph in the UI.
//
// 2. Lark does not round-trip bold+italic. Six shapes are detected:
// ***text*** ___text___
// **_text_** __*text*__
// _**text**_ *__text__*
// Lark stores only one of the two emphases (usually italic), silently
// dropping the other. The user wanted both; they will get one.
func docsUpdateWarnings(mode, markdown string) []string {
var warnings []string
if w := checkDocsUpdateReplaceMultilineMarkdown(mode, markdown); w != "" {
warnings = append(warnings, w)
}
if w := checkDocsUpdateBoldItalic(markdown); w != "" {
warnings = append(warnings, w)
}
return warnings
}
// checkDocsUpdateReplaceMultilineMarkdown flags markdown that contains a
// blank-line paragraph break outside fenced code blocks under a replace_*
// mode. Blank lines inside code fences are literal content and don't
// imply paragraph semantics, so they are deliberately ignored.
func checkDocsUpdateReplaceMultilineMarkdown(mode, markdown string) string {
if mode != "replace_range" && mode != "replace_all" {
return ""
}
// A CR/LF-robust check: both "\n\n" and "\r\n\r\n" count as paragraph
// separators. We normalize line endings once before detection.
normalized := strings.ReplaceAll(markdown, "\r\n", "\n")
if !proseHasBlankLine(normalized) {
return ""
}
return "--mode=" + mode + " does not split a block into multiple paragraphs; " +
"the blank line in --markdown will render as literal text. " +
"For multiple paragraphs, use --mode=delete_range followed by --mode=insert_before."
}
// combinedEmphasisPatterns holds the six documented combined-emphasis shapes
// that Lark downgrades to a single emphasis. Each entry pairs a regex with a
// short shape label for the warning message. The two forms per shape (with
// and without `[^…]*?`) are there because the lazy quantifier needs at least
// one non-delimiter character to match; single-rune payloads (e.g. `***X***`)
// take the second alternation.
var combinedEmphasisPatterns = []struct {
shape string
re *regexp.Regexp
}{
// Bold+italic with a single delimiter char.
{"***text***", regexp.MustCompile(`\*\*\*\S[^*]*?\S\*\*\*|\*\*\*\S\*\*\*`)},
{"___text___", regexp.MustCompile(`___\S[^_]*?\S___|___\S___`)},
// Bold wrapping italic (asterisk outside).
{"**_text_**", regexp.MustCompile(`\*\*_\S[^_*]*?\S_\*\*|\*\*_\S_\*\*`)},
{"__*text*__", regexp.MustCompile(`__\*\S[^_*]*?\S\*__|__\*\S\*__`)},
// Italic wrapping bold (asterisk inside).
{"_**text**_", regexp.MustCompile(`_\*\*\S[^_*]*?\S\*\*_|_\*\*\S\*\*_`)},
{"*__text__*", regexp.MustCompile(`\*__\S[^_*]*?\S__\*|\*__\S__\*`)},
}
// checkDocsUpdateBoldItalic flags Markdown emphases that attempt to
// combine bold and italic in a way Lark cannot represent. Fenced code
// blocks, inline code spans, and backslash-escaped emphasis markers are
// stripped first so that literal markdown examples ("here is a
// `***keyword***` to flag") do not trigger the warning.
func checkDocsUpdateBoldItalic(markdown string) string {
if markdown == "" {
return ""
}
sanitized := stripEscapedEmphasisMarkers(stripMarkdownCodeRegions(markdown))
for _, p := range combinedEmphasisPatterns {
if p.re.MatchString(sanitized) {
return "Lark does not support combined bold+italic markers " +
"(e.g. ***text***, ___text___, **_text_**, _**text**_, __*text*__, *__text__*); " +
"the emphasis will be downgraded to either bold or italic. " +
"Split into two separate emphases or drop one of them."
}
}
return ""
}
// proseHasBlankLine reports whether markdown contains a blank line outside
// of fenced code blocks. Blank lines inside ```...``` or ~~~...~~~ fences
// are code content, not paragraph separators, and must not trip the
// "replace_* cannot split paragraphs" warning.
//
// A blank line counts only when it sits between two non-blank boundaries
// (other prose, or a fence open/close). A trailing empty line at EOF is
// not treated as "\n\n".
func proseHasBlankLine(markdown string) bool {
lines := strings.Split(markdown, "\n")
inFence := false
var fenceMarker string
for i, line := range lines {
if inFence {
if isCodeFenceClose(line, fenceMarker) {
inFence = false
fenceMarker = ""
}
continue
}
if marker := codeFenceOpenMarker(line); marker != "" {
inFence = true
fenceMarker = marker
continue
}
if strings.TrimSpace(line) == "" && i > 0 && i+1 < len(lines) {
return true
}
}
return false
}
// stripMarkdownCodeRegions returns markdown with fenced code blocks blanked
// out and inline code spans replaced by whitespace of equivalent length.
// Byte offsets outside the masked regions are preserved, so follow-on
// regex matches still point at real prose positions.
func stripMarkdownCodeRegions(markdown string) string {
lines := strings.Split(markdown, "\n")
inFence := false
var fenceMarker string
for i, line := range lines {
if inFence {
if isCodeFenceClose(line, fenceMarker) {
inFence = false
fenceMarker = ""
}
lines[i] = ""
continue
}
if marker := codeFenceOpenMarker(line); marker != "" {
inFence = true
fenceMarker = marker
lines[i] = ""
continue
}
lines[i] = maskInlineCodeSpans(line)
}
return strings.Join(lines, "\n")
}
// maskInlineCodeSpans replaces the byte ranges of any inline code spans in
// line with space characters of equal length. Uses scanInlineCodeSpans from
// markdown_fix.go, which implements the CommonMark §6.1 matching-backtick-run
// rule (so “ `a`b` “ is a single span).
func maskInlineCodeSpans(line string) string {
spans := scanInlineCodeSpans(line)
if len(spans) == 0 {
return line
}
var sb strings.Builder
pos := 0
for _, loc := range spans {
sb.WriteString(line[pos:loc[0]])
sb.WriteString(strings.Repeat(" ", loc[1]-loc[0]))
pos = loc[1]
}
sb.WriteString(line[pos:])
return sb.String()
}
// stripEscapedEmphasisMarkers removes backslash-escaped '*' and '_' so the
// bold/italic regexes don't treat literal sequences like `\***text***` as
// real combined emphasis. CommonMark renders "\*" as a literal "*" with no
// emphasis semantics; dropping the escape + its target from the detection
// input keeps the heuristic aligned with what the renderer actually does.
//
// Known limitation: a doubled backslash escape ("\\" followed by a real
// emphasis marker, e.g. `\\***text***`) renders as a literal backslash
// followed by genuine combined emphasis, but this strip is not a proper
// parser and will instead consume the second backslash as the opener for
// another escape. That hides the real emphasis from the check, producing
// a false negative. Practical impact is small (this shape is rare in the
// kind of AI-Agent prompts we target) and the alternative — a full
// CommonMark escape parser — is not worth the code surface here.
func stripEscapedEmphasisMarkers(s string) string {
s = strings.ReplaceAll(s, `\*`, "")
s = strings.ReplaceAll(s, `\_`, "")
return s
}
// codeFenceOpenMarker returns the fence marker (e.g. "```" or "~~~~") if
// line opens a fenced code block, otherwise "". Applies CommonMark §4.5
// rules: up to 3 leading spaces are tolerated; 4+ leading spaces (or any
// leading tab, which expands to 4 columns) make the line an indented code
// block rather than a fence.
func codeFenceOpenMarker(line string) string {
body, ok := fenceIndentOK(line)
if !ok {
return ""
}
switch {
case strings.HasPrefix(body, "```"):
return leadingRun(body, '`')
case strings.HasPrefix(body, "~~~"):
return leadingRun(body, '~')
}
return ""
}
// isCodeFenceClose reports whether line closes a fence opened with marker.
// Per CommonMark §4.5 the closer must use the same fence character, be at
// least as long as the opener, sit within 0..3 leading spaces, and carry
// no info-string text.
func isCodeFenceClose(line, marker string) bool {
if marker == "" {
return false
}
body, ok := fenceIndentOK(line)
if !ok {
return false
}
fenceChar := marker[0]
run := leadingRun(body, fenceChar)
if len(run) < len(marker) {
return false
}
return strings.TrimSpace(body[len(run):]) == ""
}
// fenceIndentOK returns (bodyWithoutLeadingSpaces, true) when line has
// 0..3 leading spaces and no leading tab — i.e. the indentation is
// permissible for a CommonMark fence. Returns ("", false) otherwise
// (4+ leading spaces or any tab), meaning the line must be treated as
// indented code block content rather than a fence boundary.
func fenceIndentOK(line string) (string, bool) {
for i := 0; i < len(line) && i < 4; i++ {
switch line[i] {
case ' ':
continue
case '\t':
return "", false
default:
return line[i:], true
}
}
// Reached index 4 without hitting a non-space character: too indented.
if len(line) >= 4 {
return "", false
}
// Line shorter than 4 chars and all spaces — still valid (empty content).
return "", true
}
// leadingRun returns the longest prefix of s made up of the byte c.
func leadingRun(s string, c byte) string {
i := 0
for i < len(s) && s[i] == c {
i++
}
return s[:i]
}

View File

@@ -1,375 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"strings"
"testing"
)
func TestCheckDocsUpdateReplaceMultilineMarkdown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mode string
markdown string
wantHint bool
}{
{
name: "replace_range with blank line emits hint",
mode: "replace_range",
markdown: "new paragraph\n\nsecond paragraph",
wantHint: true,
},
{
name: "replace_all with blank line emits hint",
mode: "replace_all",
markdown: "first\n\nsecond",
wantHint: true,
},
{
name: "replace_range single paragraph is fine",
mode: "replace_range",
markdown: "just a single paragraph of text",
wantHint: false,
},
{
name: "single newline is not a paragraph break",
mode: "replace_range",
markdown: "line one\nline two",
wantHint: false,
},
{
name: "crlf paragraph break is also detected",
mode: "replace_range",
markdown: "first\r\n\r\nsecond",
wantHint: true,
},
{
name: "other modes are not flagged",
mode: "insert_before",
markdown: "first\n\nsecond",
wantHint: false,
},
{
name: "append mode is not flagged",
mode: "append",
markdown: "first\n\nsecond",
wantHint: false,
},
{
name: "empty markdown is fine",
mode: "replace_range",
markdown: "",
wantHint: false,
},
{
// The check must ignore blank lines inside fenced code; otherwise
// a user replacing one block with a legitimate code sample that
// contains blank lines would see a spurious warning.
name: "blank line inside backtick fenced code is not flagged",
mode: "replace_range",
markdown: "```\nline1\n\nline2\n```",
wantHint: false,
},
{
name: "blank line inside tilde fenced code is not flagged",
mode: "replace_range",
markdown: "~~~\ncode line one\n\ncode line two\n~~~",
wantHint: false,
},
{
// Mixed prose + fenced code: any blank line in prose still wins,
// even if the fenced content also contains blanks.
name: "blank line in prose outside fence still flags even when fence has blanks",
mode: "replace_range",
markdown: "first paragraph\n\nsecond paragraph\n\n```\ncode\n\nmore\n```",
wantHint: true,
},
{
// Fenced code with no blank lines inside must not trip on the
// fence markers themselves.
name: "fenced code with no blank lines does not flag",
mode: "replace_range",
markdown: "prose before\n```go\nfmt.Println(\"hi\")\n```\nprose after",
wantHint: false,
},
{
// CommonMark §4.5: the closing fence must be ≥ opening fence length.
// A 4-backtick close for a 3-backtick open is a legitimate way to
// embed triple-backticks in a code sample; the check must see the
// fence as properly closed and not treat the rest of the document
// as still-inside-fence.
name: "longer close marker closes fence correctly",
mode: "replace_range",
markdown: "```\nsome code\n````\n\nprose paragraph after",
wantHint: true, // the blank line AFTER the fence is real prose
},
{
name: "longer close marker still hides blank line inside fence",
mode: "replace_range",
markdown: "```\nbefore\n\nafter\n````",
wantHint: false,
},
{
// 4+ leading spaces make the line an indented code block, not a
// fence open. The "fence"-looking line is code content; the
// surrounding blank must still be detected.
name: "four-space indented fence-like line is not a fence open",
mode: "replace_range",
markdown: "first paragraph\n\n ```\n code\n ```",
wantHint: true,
},
{
// A tab in the leading whitespace is always ≥4 columns and thus
// forces indented-code-block semantics.
name: "tab-indented fence-like line is not a fence open",
mode: "replace_range",
markdown: "first paragraph\n\n\t```\n\tcode\n\t```",
wantHint: true,
},
{
// 3 leading spaces is still within the fence-tolerance window.
name: "three-space indented fence is still a fence",
mode: "replace_range",
markdown: " ```\ncode\n\nmore\n ```",
wantHint: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := checkDocsUpdateReplaceMultilineMarkdown(tt.mode, tt.markdown)
hasHint := got != ""
if hasHint != tt.wantHint {
t.Fatalf("checkDocsUpdateReplaceMultilineMarkdown(%q, %q) = %q, wantHint=%v",
tt.mode, tt.markdown, got, tt.wantHint)
}
if tt.wantHint && (!strings.Contains(got, "delete_range") || !strings.Contains(got, "insert_before")) {
t.Errorf("hint should suggest delete_range/insert_before remediation, got: %s", got)
}
})
}
}
func TestCheckDocsUpdateBoldItalic(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantHint bool
}{
{
name: "triple asterisks flagged",
input: "a ***key insight*** here",
wantHint: true,
},
{
name: "triple asterisks single char flagged",
input: "a ***X*** here",
wantHint: true,
},
{
name: "bold wrapping underscore italic flagged",
input: "note: **_important_** detail",
wantHint: true,
},
{
name: "underscore wrapping double asterisk flagged",
input: "note: _**important**_ detail",
wantHint: true,
},
{
name: "plain bold is fine",
input: "this is **bold** text",
wantHint: false,
},
{
name: "plain italic is fine",
input: "this is *italic* or _italic_ text",
wantHint: false,
},
{
name: "horizontal rule is not flagged",
input: "paragraph\n\n---\n\nnext",
wantHint: false,
},
{
name: "bold followed by italic with space is not flagged",
input: "**bold** and *italic*",
wantHint: false,
},
{
name: "empty input is fine",
input: "",
wantHint: false,
},
{
// The emphasis check must not fire on literal Markdown samples
// inside a fenced code block — the canonical use case is docs
// authors pasting tutorials that demonstrate these exact patterns.
name: "triple asterisks inside backtick fenced code is not flagged",
input: "example:\n```\nthe shape ***keyword*** downgrades\n```",
wantHint: false,
},
{
name: "underscore-bold inside fenced code is not flagged",
input: "example:\n```markdown\nuse **_strong italic_** carefully\n```",
wantHint: false,
},
{
name: "bold-underscore inside fenced code is not flagged",
input: "example:\n~~~\n_**outside-underscore**_ is a bad shape\n~~~",
wantHint: false,
},
{
name: "triple asterisks inside inline code span is not flagged",
input: "the literal `***text***` marker is just a sample",
wantHint: false,
},
{
name: "underscore-bold inside inline code is not flagged",
input: "the shape `**_italic_**` would downgrade, but only if it were real",
wantHint: false,
},
{
name: "escaped triple asterisks rendered as literal text is not flagged",
input: `the literal \***text*** with escaped opener`,
wantHint: false,
},
{
name: "escaped bold inside underscore-italic is not flagged",
input: `shape \*\*_text_\*\* is literal, not emphasis`,
wantHint: false,
},
{
// Real emphasis outside the code span must still be detected —
// the strip step must not over-sanitize.
name: "real triple asterisks outside inline code still flags",
input: "real ***strong*** and literal `***keyword***` — the first one counts",
wantHint: true,
},
{
name: "real triple asterisks outside fenced code still flags",
input: "real ***strong***\n\n```\nliteral ***keyword*** in code\n```",
wantHint: true,
},
// --- Triple-underscore combined emphasis: ___text___ ---
{
name: "triple underscores flagged",
input: "a ___key insight___ here",
wantHint: true,
},
{
name: "triple underscores single char flagged",
input: "a ___X___ here",
wantHint: true,
},
{
name: "triple underscores inside fenced code not flagged",
input: "sample:\n```\nuse ___keyword___ carefully\n```",
wantHint: false,
},
{
name: "triple underscores inside inline code not flagged",
input: "the literal `___phrase___` marker",
wantHint: false,
},
{
name: "escaped triple underscores not flagged",
input: `literal \___phrase___ with escaped opener`,
wantHint: false,
},
// --- Underscore-bold wrapping asterisk-italic: __*text*__ ---
{
name: "underscore-bold wrapping asterisk-italic flagged",
input: "note: __*important*__ text",
wantHint: true,
},
{
name: "underscore-bold wrapping asterisk-italic inside fenced code not flagged",
input: "```\nnote: __*important*__ sample\n```",
wantHint: false,
},
{
name: "underscore-bold wrapping asterisk-italic inside inline code not flagged",
input: "literal `__*important*__` marker",
wantHint: false,
},
// --- Asterisk-italic wrapping underscore-bold: *__text__* ---
{
name: "asterisk-italic wrapping underscore-bold flagged",
input: "note: *__phrase__* text",
wantHint: true,
},
{
name: "asterisk-italic wrapping underscore-bold inside fenced code not flagged",
input: "```md\nnote: *__phrase__* sample\n```",
wantHint: false,
},
// --- Positive tests: real emphasis in prose coexisting with fake in code ---
{
// Underscore-variant in prose must still fire when an asterisk
// variant appears inside a code span — verifies the strip does
// not over-sanitize across the six regex alternatives.
name: "real triple underscores outside inline code still flag when asterisk variant is in code",
input: "real ___strong___ and literal `***shape***` in code",
wantHint: true,
},
{
// Longer close fence closes properly; real ***emphasis*** after
// the fence must fire.
name: "real emphasis after a fence closed by longer marker still flags",
input: "```\nliteral ***phrase*** in code\n````\n\nand then real ***phrase*** after",
wantHint: true,
},
{
// 4-space indented "```" is an indented code block, not a fence
// open. The fence helper should refuse it; emphasis outside the
// (non-existent) fence must still be detected.
name: "four-space indented fence-like line does not open a fence for the emphasis check",
input: "prose\n\n ```\n not a fence\n ```\n\nreal ***strong*** here",
wantHint: true,
},
{
// 3-space indented fence is valid per CommonMark. Emphasis inside
// must be sanitized away, so the check must not fire.
name: "three-space indented fence still hides triple-asterisk inside",
input: " ```\n literal ***text*** inside\n ```",
wantHint: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := checkDocsUpdateBoldItalic(tt.input)
hasHint := got != ""
if hasHint != tt.wantHint {
t.Fatalf("checkDocsUpdateBoldItalic(%q) = %q, wantHint=%v", tt.input, got, tt.wantHint)
}
})
}
}
func TestDocsUpdateWarningsAggregates(t *testing.T) {
t.Parallel()
// Both flags trigger: replace_range with blank line AND triple-asterisk.
warnings := docsUpdateWarnings("replace_range", "***opening***\n\nsecond paragraph")
if len(warnings) != 2 {
t.Fatalf("expected 2 warnings, got %d: %v", len(warnings), warnings)
}
}
func TestDocsUpdateWarningsEmpty(t *testing.T) {
t.Parallel()
// Clean markdown in a non-replace mode produces zero warnings.
warnings := docsUpdateWarnings("insert_before", "plain paragraph text")
if len(warnings) != 0 {
t.Fatalf("expected no warnings, got: %v", warnings)
}
}

View File

@@ -3,16 +3,36 @@
package doc
import (
"context"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// ── V2 tests ──
func TestValidCommandsV2(t *testing.T) {
expected := map[string]bool{
"str_replace": true,
"str_delete": true,
"block_delete": true,
"block_insert_after": true,
"block_copy_insert_after": true,
"block_replace": true,
"block_move_after": true,
"overwrite": true,
"append": true,
}
if len(validCommandsV2) != len(expected) {
t.Fatalf("expected %d commands, got %d", len(expected), len(validCommandsV2))
}
for cmd := range validCommandsV2 {
if !expected[cmd] {
t.Fatalf("unexpected command %q in validCommandsV2", cmd)
}
}
}
// ── V1 tests ──
func TestIsWhiteboardCreateMarkdown(t *testing.T) {
t.Run("blank whiteboard tags", func(t *testing.T) {
markdown := "<whiteboard type=\"blank\"></whiteboard>\n<whiteboard type=\"blank\"></whiteboard>"
@@ -36,66 +56,13 @@ func TestIsWhiteboardCreateMarkdown(t *testing.T) {
})
}
func TestNormalizeBoardTokens(t *testing.T) {
// Codecov patch includes normalizeBoardTokens in this PR's diff because
// the PR base predates #569 where this helper landed; the previously-
// untested string and default arms are what keep patch coverage under the
// threshold. These cases lock the fallback paths so any future caller
// that passes a plain string or a non-slice token bag gets a stable shape.
t.Run("nil raw returns empty slice", func(t *testing.T) {
got := normalizeBoardTokens(nil)
if len(got) != 0 {
t.Fatalf("expected empty slice, got %#v", got)
}
})
t.Run("already-typed string slice passes through", func(t *testing.T) {
in := []string{"a", "b"}
got := normalizeBoardTokens(in)
if !reflect.DeepEqual(got, in) {
t.Fatalf("got %#v, want %#v", got, in)
}
})
t.Run("interface slice skips non-string and empty string items", func(t *testing.T) {
got := normalizeBoardTokens([]interface{}{"keep", "", 42, "also"})
want := []string{"keep", "also"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
})
t.Run("single string wraps into one-item slice", func(t *testing.T) {
got := normalizeBoardTokens("solo")
want := []string{"solo"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
})
t.Run("empty string returns empty slice, not one-item slice", func(t *testing.T) {
got := normalizeBoardTokens("")
if len(got) != 0 {
t.Fatalf("expected empty slice for empty string input, got %#v", got)
}
})
t.Run("unsupported type falls through to empty slice", func(t *testing.T) {
got := normalizeBoardTokens(42)
if len(got) != 0 {
t.Fatalf("expected empty slice for non-string/non-slice input, got %#v", got)
}
})
}
func TestNormalizeDocsUpdateResult(t *testing.T) {
func TestNormalizeWhiteboardResult(t *testing.T) {
t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) {
result := map[string]interface{}{
"success": true,
}
normalizeDocsUpdateResult(result, "<whiteboard type=\"blank\"></whiteboard>")
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
got, ok := result["board_tokens"].([]string)
if !ok {
@@ -111,7 +78,7 @@ func TestNormalizeDocsUpdateResult(t *testing.T) {
"board_tokens": []interface{}{"board_1", "board_2"},
}
normalizeDocsUpdateResult(result, "<whiteboard type=\"blank\"></whiteboard>")
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
want := []string{"board_1", "board_2"}
got, ok := result["board_tokens"].([]string)
@@ -128,208 +95,10 @@ func TestNormalizeDocsUpdateResult(t *testing.T) {
"success": true,
}
normalizeDocsUpdateResult(result, "## plain text")
normalizeWhiteboardResult(result, "## plain text")
if _, ok := result["board_tokens"]; ok {
t.Fatalf("did not expect board_tokens for non-whiteboard markdown")
}
})
}
func TestValidateSelectionByTitle(t *testing.T) {
t.Run("empty title passes", func(t *testing.T) {
if err := validateSelectionByTitle(""); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
})
t.Run("heading style title passes", func(t *testing.T) {
if err := validateSelectionByTitle("## 第二章"); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
})
t.Run("plain text title fails with guidance", func(t *testing.T) {
err := validateSelectionByTitle("第二章")
if err == nil {
t.Fatalf("expected validation error")
}
if got := err.Error(); got == "" || !containsAll(got, "selection-by-title", "heading prefix") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("multi-line heading still fails", func(t *testing.T) {
err := validateSelectionByTitle("## 第二章\n## 第三章")
if err == nil {
t.Fatalf("expected validation error")
}
if got := err.Error(); got == "" || !containsAll(got, "single heading line") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("multi-line title fails", func(t *testing.T) {
err := validateSelectionByTitle("第二章\n第三章")
if err == nil {
t.Fatalf("expected validation error")
}
if got := err.Error(); got == "" || !containsAll(got, "single heading line") {
t.Fatalf("unexpected error: %v", err)
}
})
}
func containsAll(s string, tokens ...string) bool {
for _, token := range tokens {
if !strings.Contains(s, token) {
return false
}
}
return true
}
// TestDocsUpdateValidate exercises the Validate closure directly so the new
// --selection-by-title integration point (call site in Validate) is covered,
// not just the underlying validateSelectionByTitle helper. Without this the
// three lines added to the closure show up as untested in the patch coverage
// report even though the helper itself is at 100%.
func TestDocsUpdateValidate(t *testing.T) {
tests := []struct {
name string
flags map[string]string
boolFlag string // name of optional bool flag to set (currently unused; placeholder for future flags)
wantErr string // substring; empty = expect nil error
}{
{
// Happy path that exercises the new selection-by-title call site
// with a valid heading — reaches the `return nil` branch.
name: "heading-style selection-by-title passes",
flags: map[string]string{
"doc": "doxcnABCDEF",
"mode": "replace_range",
"markdown": "new body",
"selection-by-title": "## Section",
},
},
{
// Exercises the error-return branch of the new call site.
name: "plain-text selection-by-title is rejected with heading-prefix guidance",
flags: map[string]string{
"doc": "doxcnABCDEF",
"mode": "replace_range",
"markdown": "new body",
"selection-by-title": "第二章",
},
wantErr: "heading prefix",
},
{
// Exercises the multi-line guard inside validateSelectionByTitle
// through the Validate call path.
name: "multi-line selection-by-title is rejected as not a single heading",
flags: map[string]string{
"doc": "doxcnABCDEF",
"mode": "replace_range",
"markdown": "new body",
"selection-by-title": "## a\n## b",
},
wantErr: "single heading line",
},
{
// Invalid mode — proves the earlier mode check still fires before
// reaching the new selection-by-title check, so the new code
// doesn't accidentally mask pre-existing validation.
name: "invalid mode is still rejected first",
flags: map[string]string{
"doc": "doxcnABCDEF",
"mode": "bogus",
"selection-by-title": "## Section",
},
wantErr: "invalid --mode",
},
{
// Both selection forms supplied — proves the mutual-exclusion
// check still fires before the new selection-by-title check.
name: "conflicting selection flags are rejected before title validation",
flags: map[string]string{
"doc": "doxcnABCDEF",
"mode": "replace_range",
"markdown": "body",
"selection-with-ellipsis": "start...end",
"selection-by-title": "## Section",
},
wantErr: "mutually exclusive",
},
{
// Non-delete_range modes require --markdown; this exercises the
// pre-existing empty-markdown branch that sits between the mode
// check and the new selection-by-title check. Covering it keeps
// patch coverage above codecov's threshold for this closure.
name: "non-delete_range mode without --markdown is rejected",
flags: map[string]string{
"doc": "doxcnABCDEF",
"mode": "replace_range",
"selection-by-title": "## Section",
},
wantErr: "requires --markdown",
},
{
// needsSelection[mode] is true for replace_range but neither
// selection flag is set — covers the "requires selection" branch
// that precedes the new call site.
name: "replace_range without any selection flag is rejected",
flags: map[string]string{
"doc": "doxcnABCDEF",
"mode": "replace_range",
"markdown": "body",
},
wantErr: "requires --selection-with-ellipsis or --selection-by-title",
},
{
// delete_range has no markdown requirement and no selection
// requirement when neither is supplied is actually ok under the
// current rules (delete_range still needs selection per
// needsSelection, but the test proves the markdown-empty guard
// does not fire for delete_range specifically).
name: "delete_range without --markdown but with selection passes markdown check",
flags: map[string]string{
"doc": "doxcnABCDEF",
"mode": "delete_range",
"selection-by-title": "## Section",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "docs +update"}
cmd.Flags().String("doc", "", "")
cmd.Flags().String("mode", "", "")
cmd.Flags().String("markdown", "", "")
cmd.Flags().String("selection-with-ellipsis", "", "")
cmd.Flags().String("selection-by-title", "", "")
cmd.Flags().String("new-title", "", "")
for k, v := range tt.flags {
if err := cmd.Flags().Set(k, v); err != nil {
t.Fatalf("set --%s=%q: %v", k, v, err)
}
}
rt := common.TestNewRuntimeContext(cmd, nil)
err := DocsUpdate.Validate(context.Background(), rt)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErr)
}
})
}
}

View File

@@ -0,0 +1,174 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"github.com/larksuite/cli/shortcuts/common"
)
var validCommandsV2 = map[string]bool{
"str_replace": true,
"str_delete": true,
"block_delete": true,
"block_insert_after": true,
"block_copy_insert_after": true,
"block_replace": true,
"block_move_after": true,
"overwrite": true,
"append": true,
}
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
func v2UpdateFlags() []common.Flag {
return []common.Flag{
{Name: "command", Desc: "operation: str_replace | str_delete | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", Hidden: true, Enum: validCommandsV2Keys()},
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "content", Desc: "new content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "pattern", Desc: "regex pattern for str_replace / str_delete", Hidden: true},
{Name: "block-id", Desc: "target block ID for block_* operations", Hidden: true},
{Name: "src-block-ids", Desc: "source block IDs (comma-separated) for block_copy_insert_after / block_move_after", Hidden: true},
{Name: "revision-id", Desc: "base revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
}
}
func validCommandsV2Keys() []string {
return []string{"str_replace", "str_delete", "block_delete", "block_insert_after", "block_copy_insert_after", "block_replace", "block_move_after", "overwrite", "append"}
}
func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
cmd := runtime.Str("command")
if cmd == "" {
return common.FlagErrorf("--command is required")
}
if !validCommandsV2[cmd] {
return common.FlagErrorf("invalid --command %q, valid: str_replace | str_delete | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd)
}
content := runtime.Str("content")
pattern := runtime.Str("pattern")
blockID := runtime.Str("block-id")
srcBlockIDs := runtime.Str("src-block-ids")
switch cmd {
case "str_replace":
if pattern == "" {
return common.FlagErrorf("--command str_replace requires --pattern")
}
if content == "" {
return common.FlagErrorf("--command str_replace requires --content")
}
case "str_delete":
if pattern == "" {
return common.FlagErrorf("--command str_delete requires --pattern")
}
case "block_delete":
if blockID == "" {
return common.FlagErrorf("--command block_delete requires --block-id")
}
case "block_insert_after":
if blockID == "" {
return common.FlagErrorf("--command block_insert_after requires --block-id")
}
if content == "" {
return common.FlagErrorf("--command block_insert_after requires --content")
}
case "block_copy_insert_after":
if blockID == "" {
return common.FlagErrorf("--command block_copy_insert_after requires --block-id")
}
if srcBlockIDs == "" {
return common.FlagErrorf("--command block_copy_insert_after requires --src-block-ids")
}
case "block_move_after":
if blockID == "" {
return common.FlagErrorf("--command block_move_after requires --block-id")
}
if content == "" && srcBlockIDs == "" {
return common.FlagErrorf("--command block_move_after requires --content or --src-block-ids")
}
case "block_replace":
if blockID == "" {
return common.FlagErrorf("--command block_replace requires --block-id")
}
if content == "" {
return common.FlagErrorf("--command block_replace requires --content")
}
case "overwrite":
if content == "" {
return common.FlagErrorf("--command overwrite requires --content")
}
case "append":
if content == "" {
return common.FlagErrorf("--command append requires --content")
}
}
return nil
}
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("error: %v", err))
}
body := buildUpdateBody(runtime)
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
return common.NewDryRunAPI().
PUT(apiPath).
Desc("OpenAPI: update document").
Body(body).
Set("document_id", ref.Token)
}
func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return err
}
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
body := buildUpdateBody(runtime)
data, err := doDocAPI(runtime, "PUT", apiPath, body)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
}
func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
cmd := runtime.Str("command")
// append is a shorthand for block_insert_after with block_id "-1" (end of document)
blockID := runtime.Str("block-id")
if cmd == "append" {
cmd = "block_insert_after"
blockID = "-1"
}
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
"command": cmd,
}
if v := runtime.Int("revision-id"); v != 0 {
body["revision_id"] = v
}
if v := runtime.Str("content"); v != "" {
body["content"] = v
}
if v := runtime.Str("pattern"); v != "" {
body["pattern"] = v
}
if blockID != "" {
body["block_id"] = blockID
}
if v := runtime.Str("src-block-ids"); v != "" {
body["src_block_ids"] = v
}
injectDocsScene(runtime, body)
return body
}

View File

@@ -4,12 +4,18 @@
package doc
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// docsSceneContextKey lets in-process embedders pass a server-owned docs_ai
// scene without exposing it as a user-controlled CLI flag.
const docsSceneContextKey = "lark_cli_docs_scene"
type documentRef struct {
Kind string
Token string
@@ -56,6 +62,40 @@ func extractDocumentToken(raw, marker string) (string, bool) {
return token, true
}
// doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns
// the parsed "data" field from the standard Lark response envelope {code, msg, data}.
func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body interface{}) (map[string]interface{}, error) {
return runtime.DoAPIJSON(method, apiPath, nil, body)
}
func docsSceneFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
scene, _ := ctx.Value(docsSceneContextKey).(string)
return strings.TrimSpace(scene)
}
func injectDocsScene(runtime *common.RuntimeContext, body map[string]interface{}) {
if scene := docsSceneFromContext(runtime.Ctx()); scene != "" {
body["scene"] = scene
}
}
// stripBlockIDs removes "block_id" from each entry in data.document.newblocks.
func stripBlockIDs(data map[string]interface{}) {
doc, _ := data["document"].(map[string]interface{})
if doc == nil {
return
}
blocks, _ := doc["newblocks"].([]interface{})
for _, b := range blocks {
if m, ok := b.(map[string]interface{}); ok {
delete(m, "block_id")
}
}
}
func buildDriveRouteExtra(docID string) (string, error) {
extra, err := json.Marshal(map[string]string{"drive_route_token": docID})
if err != nil {

View File

@@ -6,8 +6,6 @@ package doc
import (
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// fixExportedMarkdown applies post-processing to Lark-exported Markdown to
@@ -17,29 +15,24 @@ import (
// and strips redundant ** from ATX headings. Applied only outside fenced
// code blocks, and skips inline code spans.
//
// 2. normalizeNestedListIndentation: rewrites space-pair-indented nested list
// markers to tab-indented markers. This avoids nested ordered list items
// being flattened or interpreted as plain text/code on re-import.
//
// 3. fixSetextAmbiguity: inserts a blank line before any "---" that immediately
// 2. fixSetextAmbiguity: inserts a blank line before any "---" that immediately
// follows a non-empty line, preventing it from being parsed as a Setext H2.
// Applied only outside fenced code blocks.
//
// 4. fixBlockquoteHardBreaks: inserts a blank blockquote line (">") between
// 3. fixBlockquoteHardBreaks: inserts a blank blockquote line (">") between
// consecutive blockquote content lines so create-doc preserves line breaks.
// Applied only outside fenced code blocks.
//
// 5. fixTopLevelSoftbreaks: inserts a blank line between adjacent non-empty
// 4. fixTopLevelSoftbreaks: inserts a blank line between adjacent non-empty
// lines at the top level and inside content containers (callout,
// quote-container, lark-td). Code fences are left untouched, and
// consecutive list items / continuations are not separated.
//
// 6. fixCalloutEmoji: replaces named emoji aliases (e.g. emoji="warning") with
// 5. fixCalloutEmoji: replaces named emoji aliases (e.g. emoji="warning") with
// actual Unicode emoji characters that create-doc understands. Applied only
// outside fenced code blocks.
func fixExportedMarkdown(md string) string {
md = applyOutsideCodeFences(md, fixBoldSpacing)
md = applyOutsideCodeFences(md, normalizeNestedListIndentation)
md = applyOutsideCodeFences(md, fixSetextAmbiguity)
md = applyOutsideCodeFences(md, fixBlockquoteHardBreaks)
md = fixTopLevelSoftbreaks(md)
@@ -113,21 +106,20 @@ func fixBlockquoteHardBreaks(md string) string {
return strings.Join(out, "\n")
}
// fixBoldSpacing normalizes emphasis markers exported by Lark while preserving
// inline code spans:
// fixBoldSpacing fixes two issues with bold markers exported by Lark:
//
// 1. Removes leading whitespace after opening ** and * delimiters:
// "** text**" → "**text**", "* text*" → "*text*"
// 1. Trailing whitespace before closing **: "**text **" → "**text**"
// CommonMark requires no space before a closing delimiter; otherwise the
// ** is rendered as literal text.
//
// 2. Removes trailing whitespace before closing ** and * delimiters:
// "**text **" → "**text**", "*text *" → "*text*"
// 2. Redundant bold in ATX headings: "# **text**" → "# text"
// Headings are already bold, so the inner ** is visually redundant and
// some renderers display the markers literally.
//
// 3. Removes redundant bold around an entire ATX heading:
// "# **text**" → "# text"
//
// The bold and italic spacing fixes only run on non-code segments so literal
// code content is left unchanged.
// Both fixes skip inline code spans to avoid modifying literal code content.
var (
boldTrailingSpaceRe = regexp.MustCompile(`(\*\*\S[^*]*?)\s+(\*\*)`)
italicTrailingSpaceRe = regexp.MustCompile(`(\*\S[^*]*?)\s+(\*)`)
// headingBoldRe uses [^*]+ (no asterisks) to avoid mismatching headings
// that contain multiple disjoint bold spans such as "# **foo** and **bar**".
headingBoldRe = regexp.MustCompile(`(?m)^(#{1,6})\s+\*\*([^*]+)\*\*\s*$`)
@@ -190,116 +182,38 @@ func scanInlineCodeSpans(line string) [][2]int {
// fixBoldSpacingLine applies bold/italic trailing-space fixes to a single line,
// skipping content inside inline code spans to avoid corrupting literal code.
// ATX heading lines are also skipped here because headingBoldRe in fixBoldSpacing
// handles them separately, keeping heading-only normalization isolated from the
// inline emphasis spacing scanner below.
// handles them separately and boldTrailingSpaceRe can misfire on headings with
// multiple disjoint bold spans (e.g. "# **foo** and **bar**").
func fixBoldSpacingLine(line string) string {
if atxHeadingRe.MatchString(line) {
return line
}
spans := scanInlineCodeSpans(line)
if len(spans) == 0 {
return fixEmphasisSpacingSegment(line)
line = boldTrailingSpaceRe.ReplaceAllString(line, "$1$2")
line = italicTrailingSpaceRe.ReplaceAllString(line, "$1$2")
return line
}
var sb strings.Builder
pos := 0
for _, loc := range spans {
// Process the non-code segment before this inline code span.
seg := line[pos:loc[0]]
sb.WriteString(fixEmphasisSpacingSegment(seg))
seg = boldTrailingSpaceRe.ReplaceAllString(seg, "$1$2")
seg = italicTrailingSpaceRe.ReplaceAllString(seg, "$1$2")
sb.WriteString(seg)
// Preserve inline code span as-is.
sb.WriteString(line[loc[0]:loc[1]])
pos = loc[1]
}
// Remaining non-code segment after the last code span.
sb.WriteString(fixEmphasisSpacingSegment(line[pos:]))
seg := line[pos:]
seg = boldTrailingSpaceRe.ReplaceAllString(seg, "$1$2")
seg = italicTrailingSpaceRe.ReplaceAllString(seg, "$1$2")
sb.WriteString(seg)
return sb.String()
}
// fixEmphasisSpacingSegment trims only the whitespace immediately inside simple
// *...* and **...** spans. It deliberately ignores runs of 3+ asterisks and
// any candidate whose payload contains another asterisk so nested emphasis-like
// text remains untouched. When both inner sides contain whitespace, single-rune
// payloads are preserved as literal text (for example "* x *" and "** x **").
func fixEmphasisSpacingSegment(seg string) string {
if !strings.Contains(seg, "*") {
return seg
}
var sb strings.Builder
pos := 0
for pos < len(seg) {
openStart, openEnd, ok := nextAsteriskRun(seg, pos)
if !ok {
sb.WriteString(seg[pos:])
break
}
sb.WriteString(seg[pos:openStart])
markerLen := openEnd - openStart
if markerLen != 1 && markerLen != 2 {
sb.WriteString(seg[openStart:openEnd])
pos = openEnd
continue
}
closeStart, closeEnd, ok := nextAsteriskRun(seg, openEnd)
if !ok || closeEnd-closeStart != markerLen {
sb.WriteString(seg[openStart:openEnd])
pos = openEnd
continue
}
payload := seg[openEnd:closeStart]
normalized, shouldNormalize := normalizeEmphasisPayload(payload)
if !shouldNormalize {
sb.WriteString(seg[openStart:closeEnd])
pos = closeEnd
continue
}
marker := seg[openStart:openEnd]
sb.WriteString(marker)
sb.WriteString(normalized)
sb.WriteString(marker)
pos = closeEnd
}
return sb.String()
}
func nextAsteriskRun(s string, start int) (runStart, runEnd int, ok bool) {
for i := start; i < len(s); i++ {
if s[i] != '*' {
continue
}
j := i
for j < len(s) && s[j] == '*' {
j++
}
return i, j, true
}
return 0, 0, false
}
func normalizeEmphasisPayload(payload string) (string, bool) {
trimmedLeft := strings.TrimLeftFunc(payload, unicode.IsSpace)
trimmed := strings.TrimRightFunc(trimmedLeft, unicode.IsSpace)
if trimmed == "" {
return payload, false
}
hasLeadingSpace := len(trimmedLeft) != len(payload)
hasTrailingSpace := len(trimmed) != len(trimmedLeft)
if !hasLeadingSpace && !hasTrailingSpace {
return payload, true
}
if hasLeadingSpace && hasTrailingSpace && utf8.RuneCountInString(trimmed) == 1 {
return payload, false
}
return trimmed, true
}
var setextRe = regexp.MustCompile(`(?m)^([^\n]+)\n(-{3,}\s*$)`)
func fixSetextAmbiguity(md string) string {
@@ -377,44 +291,6 @@ var contentContainers = [][2]string{
// indented (nested) items.
var listItemRe = regexp.MustCompile(`^[ \t]*([-*+]|\d+[.)]) `)
// nestedListIndentRe matches nested list item markers indented with pairs of
// spaces. We rewrite those space pairs to tabs because some downstream
// round-trip paths treat multi-space indented ordered items as flat items or
// literal text, while tab indentation remains nested and avoids 4-space code
// block ambiguity.
var nestedListIndentRe = regexp.MustCompile(`^( {2,})([-*+]|\d+[.)]) `)
func normalizeNestedListIndentation(md string) string {
lines := strings.Split(md, "\n")
for i, line := range lines {
matches := nestedListIndentRe.FindStringSubmatch(line)
if len(matches) != 3 {
continue
}
if !hasPreviousNonBlankListItem(lines, i) {
continue
}
indent := matches[1]
if len(indent)%2 != 0 {
continue
}
tabs := strings.Repeat("\t", len(indent)/2)
lines[i] = tabs + line[len(indent):]
}
return strings.Join(lines, "\n")
}
func hasPreviousNonBlankListItem(lines []string, index int) bool {
for i := index - 1; i >= 0; i-- {
trimmed := strings.TrimSpace(lines[i])
if trimmed == "" {
return false
}
return listItemRe.MatchString(lines[i])
}
return false
}
// isListItemOrContinuation returns true for lines that are part of a list:
// either a list item marker line or an indented continuation of a list item.
// This is used to prevent blank lines being inserted between tight list lines,

View File

@@ -1,287 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"strings"
"testing"
)
// TestFixExportedMarkdownIdempotent asserts the core promise of the exported
// markdown pipeline: applying the fixes twice produces the same result as
// applying them once. Round-trip formatting relies on this invariant, so any
// transform that keeps rewriting its own output would break fetch → edit →
// update → fetch stability.
func TestFixExportedMarkdownIdempotent(t *testing.T) {
fixtures := map[string]string{
"kitchen sink": strings.Join([]string{
"# **Title**",
"paragraph one",
"paragraph two",
"**bold ** and * italic*",
"",
"> q1",
"> q2",
"",
"1. parent",
" 1. child",
" 1. grandchild",
"",
"<callout emoji=\"warning\">",
"callout body line 1",
"callout body line 2",
"</callout>",
"",
"some text",
"---",
"",
"```go",
"// code content with markdown-like shapes must survive as-is",
"**foo **",
"* hello*",
" 1. nested",
"> q",
"---",
"```",
"",
}, "\n"),
"cjk content": strings.Join([]string{
"# **测试标题**",
"段落一",
"段落二",
"**有用性 ** and * 关键 *",
"",
"1. 父项",
" 1. 子项",
"",
}, "\n"),
"nested containers": strings.Join([]string{
"<callout emoji=\"info\">",
"line a",
"line b",
"</callout>",
"",
"<quote-container>",
"quoted 1",
"quoted 2",
"</quote-container>",
"",
}, "\n"),
}
for name, fixture := range fixtures {
t.Run(name, func(t *testing.T) {
once := fixExportedMarkdown(fixture)
twice := fixExportedMarkdown(once)
if once != twice {
t.Errorf("fixExportedMarkdown is not idempotent for %q\nfirst pass:\n%s\nsecond pass:\n%s",
name, once, twice)
}
})
}
}
// TestFixExportedMarkdownPreservesFencedCodeByteForByte packs a fenced code
// block with content that every individual transform in the pipeline would
// normally rewrite, and asserts the fence content comes out byte-for-byte
// identical. This is the pipeline's strongest invariant — users' code samples
// must never be silently modified by a formatting pass.
func TestFixExportedMarkdownPreservesFencedCodeByteForByte(t *testing.T) {
// Every line below is something at least one transform would touch if it
// appeared outside a fence. None of it must change.
dangerous := strings.Join([]string{
"**foo **", // fixBoldSpacing — trailing space bold
"* hello*", // fixBoldSpacing — leading space italic
"# **heading**", // fixBoldSpacing — redundant heading bold
"para1", // fixTopLevelSoftbreaks — adjacent paragraphs
"para2",
"> q1", // fixBlockquoteHardBreaks — blockquote pair
"> q2",
"some text", // fixSetextAmbiguity — text before ---
"---",
" 1. nested", // normalizeNestedListIndentation
`<callout emoji="warning">`, // fixCalloutEmoji — emoji alias
}, "\n")
// Wrap the dangerous content in a triple-backtick fence and surround with
// content so the pipeline has adjacent regions to potentially touch.
input := "before\n\n```\n" + dangerous + "\n```\n\nafter\n"
got := fixExportedMarkdown(input)
// Extract the fence content from the output and compare to the input fence
// content byte-for-byte.
gotFence, ok := extractFirstFenceContent(got)
if !ok {
t.Fatalf("fixExportedMarkdown output lost its fenced code block:\n%s", got)
}
if gotFence != dangerous {
t.Errorf("fenced code content was modified\nwant (bytes): %q\ngot (bytes): %q",
dangerous, gotFence)
}
}
// extractFirstFenceContent returns the inner text of the first triple-backtick
// fenced code block it finds, or ("", false) if none is present.
func extractFirstFenceContent(md string) (string, bool) {
const fence = "```"
open := strings.Index(md, fence)
if open < 0 {
return "", false
}
// Skip the fence marker and its info-string line.
rest := md[open+len(fence):]
lineEnd := strings.Index(rest, "\n")
if lineEnd < 0 {
return "", false
}
rest = rest[lineEnd+1:]
close := strings.Index(rest, "\n"+fence)
if close < 0 {
return "", false
}
return rest[:close], true
}
// TestFixExportedMarkdownPreservesCRLF feeds CRLF-terminated markdown (Windows
// line endings) through the pipeline and asserts that line endings are
// preserved AND the emphasis/heading transforms still apply — neither
// silently-LF-normalized nor passed through unchanged.
func TestFixExportedMarkdownPreservesCRLF(t *testing.T) {
lf := "# **Title**\nparagraph one\nparagraph two\n**bold **\n"
crlf := strings.ReplaceAll(lf, "\n", "\r\n")
got := fixExportedMarkdown(crlf)
// Transforms must still fire: heading bold stripped, trailing-space bold trimmed.
if strings.Contains(got, "**Title**") {
t.Errorf("heading bold not stripped on CRLF input:\n%q", got)
}
if strings.Contains(got, "**bold **") {
t.Errorf("trailing-space bold not fixed on CRLF input:\n%q", got)
}
// CRLF line endings must survive — we don't want to silently normalize a
// Windows author's document to LF.
if !strings.Contains(got, "\r\n") {
t.Errorf("CRLF line endings were normalized away:\n%q", got)
}
}
// TestFixExportedMarkdownTransformInteractions covers shapes where more than
// one transform fires on the same input. Each transform is individually tested
// elsewhere; these cases guard against composition regressions.
func TestFixExportedMarkdownTransformInteractions(t *testing.T) {
tests := []struct {
name string
input string
wantContains []string // substrings that must be present after fixes
wantAbsent []string // substrings that must be absent after fixes
}{
{
name: "nested list item with trailing-space bold",
input: "1. parent\n 1. **child **\n",
wantContains: []string{
"\t1.", // nested indent converted to tab
"**child**", // trailing space trimmed
},
wantAbsent: []string{
" 1.", // original two-space indent gone
"**child **", // original trailing space gone
},
},
{
name: "paragraph followed by list",
input: "paragraph\n- item a\n- item b\n",
wantContains: []string{
"paragraph\n\n- item a", // blank line inserted at text-to-list transition
},
wantAbsent: []string{
"\n\n\n", // no triple newline
},
},
{
name: "callout containing list with emphasis",
input: "<callout emoji=\"info\">\n- **item **\n- another\n</callout>\n",
wantContains: []string{
"**item**", // trailing-space bold fixed inside callout
},
wantAbsent: []string{
"**item **",
},
},
{
name: "heading followed by paragraph with bold",
input: "# **Title**\nbody **text **\n",
wantContains: []string{
"# Title", // heading bold stripped
"body **text**", // paragraph bold trimmed, not stripped
},
wantAbsent: []string{
"# **Title**",
"body **text **",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := fixExportedMarkdown(tt.input)
for _, want := range tt.wantContains {
if !strings.Contains(got, want) {
t.Errorf("want substring %q not found in output:\n%s", want, got)
}
}
for _, unwanted := range tt.wantAbsent {
if strings.Contains(got, unwanted) {
t.Errorf("unwanted substring %q still present in output:\n%s", unwanted, got)
}
}
})
}
}
// TestNormalizeNestedListIndentationDocumentedSkips locks in the deliberate
// "do nothing" branches of normalizeNestedListIndentation. Each case below is
// a shape the function intentionally does not rewrite; if a future change to
// the heuristic flips one of these, we want the regression to be visible in
// the test diff rather than silently changing user documents.
func TestNormalizeNestedListIndentationDocumentedSkips(t *testing.T) {
tests := []struct {
name string
input string
// want is identical to input — we are asserting "no change".
}{
{
name: "three-space indent (odd) under list item stays unchanged",
input: "1. parent\n 1. child",
},
{
name: "five-space indent (odd) under list item stays unchanged",
input: "- parent\n - deep",
},
{
name: "two-space indent without a parent list item stays unchanged",
input: "plain paragraph\n - not nested",
},
{
name: "blank-line-separated loose-list sibling stays unchanged",
input: "1. a\n\n 1. b",
},
{
name: "four-space indented code block under list item stays unchanged",
input: "- parent\n\n 1. code sample",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeNestedListIndentation(tt.input)
if got != tt.input {
t.Errorf("normalizeNestedListIndentation unexpectedly rewrote documented-skip input\ninput: %q\ngot: %q", tt.input, got)
}
})
}
}

View File

@@ -14,56 +14,6 @@ func TestFixBoldSpacing(t *testing.T) {
input string
want string
}{
{
name: "leading space after opening bold",
input: "** hello**",
want: "**hello**",
},
{
name: "leading space after opening italic",
input: "* hello*",
want: "*hello*",
},
{
name: "leading and trailing spaces inside bold are collapsed",
input: "** hello **",
want: "**hello**",
},
{
name: "leading and trailing spaces inside italic are collapsed",
input: "* hello *",
want: "*hello*",
},
{
name: "multiple spaced italic spans on one line are each collapsed",
input: "* a* * b*",
want: "*a* *b*",
},
{
name: "ambiguous italic span stays literal",
input: "2 * x * y",
want: "2 * x * y",
},
{
name: "ambiguous bold span stays literal",
input: "2 ** x ** y",
want: "2 ** x ** y",
},
{
name: "single-rune italic with spaces on both sides stays literal",
input: "* x *",
want: "* x *",
},
{
name: "single-rune bold with spaces on both sides stays literal",
input: "** x **",
want: "** x **",
},
{
name: "triple-asterisk near miss stays literal",
input: "*** hello**",
want: "*** hello**",
},
{
name: "trailing space before closing bold",
input: "**hello **",
@@ -104,16 +54,6 @@ func TestFixBoldSpacing(t *testing.T) {
input: "**foo ** and `**bar **`",
want: "**foo** and `**bar **`",
},
{
name: "inline code with spaced italic stays literal while outside span is fixed",
input: "`* hello *` and * hello *",
want: "`* hello *` and *hello*",
},
{
name: "opening space inside text tag fixed",
input: `<text color="red">** Helpful - 有用性:**</text>`,
want: `<text color="red">**Helpful - 有用性:**</text>`,
},
{
name: "double-backtick inline code not modified",
input: "``**hello **`` and **world **",
@@ -282,53 +222,6 @@ func TestFixTopLevelSoftbreaks(t *testing.T) {
}
}
func TestNormalizeNestedListIndentation(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "nested ordered list uses tabs instead of space pairs",
input: "1. parent\n 1. child\n 1. grandchild",
want: "1. parent\n\t1. child\n\t\t1. grandchild",
},
{
name: "nested mixed list markers use tabs instead of space pairs",
input: "- parent\n - child\n 1. grandchild",
want: "- parent\n\t- child\n\t\t1. grandchild",
},
{
name: "top-level list unchanged",
input: "1. parent\n2. sibling",
want: "1. parent\n2. sibling",
},
{
name: "indented top-level marker without parent list stays unchanged",
input: "paragraph\n\n 1. item",
want: "paragraph\n\n 1. item",
},
{
name: "blank-line-separated loose-list sibling stays unchanged",
input: "1. a\n\n 1. b",
want: "1. a\n\n 1. b",
},
{
name: "indented code block inside list item stays unchanged",
input: "- parent\n\n 1. code",
want: "- parent\n\n 1. code",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeNestedListIndentation(tt.input)
if got != tt.want {
t.Errorf("normalizeNestedListIndentation(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFixExportedMarkdown(t *testing.T) {
// End-to-end: all fixes applied together
input := "# **Title**\nparagraph one\nparagraph two\n**bold **\n> q1\n> q2\nsome text\n---"

View File

@@ -13,7 +13,6 @@ func Shortcuts() []common.Shortcut {
DocsFetch,
DocsUpdate,
DocMediaInsert,
DocMediaUpload,
DocMediaPreview,
DocMediaDownload,
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/shortcuts/common"
)
// installVersionedHelp sets a custom help function on cmd that shows only the
// flags relevant to the selected --api-version. flagVersions maps flag name to
// its version ("v1" or "v2"). Flags not in the map are treated as shared and
// always visible.
func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersions map[string]string) {
origHelp := cmd.HelpFunc()
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
ver, _ := cmd.Flags().GetString("api-version")
if ver == "" {
ver = defaultVersion
}
// Show/hide flags based on the active version.
cmd.Flags().VisitAll(func(f *pflag.Flag) {
if fv, ok := flagVersions[f.Name]; ok {
f.Hidden = fv != ver
}
})
origHelp(cmd, args)
if ver == "v1" {
fmt.Fprintf(cmd.OutOrStdout(),
"\n[NOTE] v1 API is deprecated and will be removed in a future release.\n"+
" Use --api-version v2 for the latest API:\n"+
" %s %s --api-version v2 --help\n"+
" Upgrade skill:\n"+
" npx skills add larksuite/cli#feat/upgrade-command -y -g\n",
cmd.Parent().Name(), cmd.Name())
}
})
}
// warnDeprecatedV1 prints a deprecation notice to stderr when the v1 (MCP) code
// path is used, guiding users to upgrade their skill to v2.
func warnDeprecatedV1(runtime *common.RuntimeContext, shortcut string) {
fmt.Fprintf(runtime.IO().ErrOut,
"[deprecated] docs %s with v1 API is deprecated and will be removed in a future release.\n"+
"Please upgrade your skill: npx skills add larksuite/cli#feat/upgrade-command -y -g\n",
shortcut)
}

View File

@@ -707,4 +707,29 @@ func TestShortcutDryRunShapes(t *testing.T) {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s", formatted)
}
})
t.Run("ImChatMessageList dry run includes root-only query", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"page-size": "20",
"sort": "desc",
}, nil)
formatted := ImChatMessageList.DryRun(context.Background(), runtime).Format()
if !strings.Contains(formatted, "only_thread_root_messages=true") {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
}
})
}
func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"page-size": "20",
"sort": "desc",
}, nil)
formatted := ImChatMessageList.DryRun(context.Background(), runtime).Format()
if !strings.Contains(formatted, "only_thread_root_messages=true") {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
}
}

View File

@@ -210,14 +210,15 @@ func TestBuildChatMessageListRequest(t *testing.T) {
}
want := larkcore.QueryParams{
"container_id_type": {"chat"},
"container_id": {"oc_123"},
"sort_type": {"ByCreateTimeAsc"},
"page_size": {"50"},
"card_msg_content_type": {"raw_card_content"},
"start_time": {"1772294400"},
"end_time": {"1772467199"},
"page_token": {"next"},
"container_id_type": {"chat"},
"container_id": {"oc_123"},
"sort_type": {"ByCreateTimeAsc"},
"page_size": {"50"},
"only_thread_root_messages": {"true"},
"card_msg_content_type": {"raw_card_content"},
"start_time": {"1772294400"},
"end_time": {"1772467199"},
"page_token": {"next"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildChatMessageListRequest() = %#v, want %#v", got, want)
@@ -245,6 +246,13 @@ func TestBuildChatMessageListRequest(t *testing.T) {
})
}
func TestChatMessageListOnlyThreadRootMessagesParams(t *testing.T) {
got := buildChatMessageListParams("desc", "20", "oc_123")
if vals := got["only_thread_root_messages"]; !reflect.DeepEqual(vals, []string{"true"}) {
t.Fatalf("only_thread_root_messages = %#v, want true", vals)
}
}
func TestResolveChatIDForMessagesList(t *testing.T) {
t.Run("chat passthrough", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{

View File

@@ -172,11 +172,12 @@ func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.Q
pageSize = min(max(n, 1), 50)
}
return larkcore.QueryParams{
"container_id_type": []string{"chat"},
"container_id": []string{chatId},
"sort_type": []string{sortType},
"page_size": []string{strconv.Itoa(pageSize)},
"card_msg_content_type": []string{"raw_card_content"},
"container_id_type": []string{"chat"},
"container_id": []string{chatId},
"sort_type": []string{sortType},
"page_size": []string{strconv.Itoa(pageSize)},
"card_msg_content_type": []string{"raw_card_content"},
"only_thread_root_messages": []string{"true"},
}
}

View File

@@ -1,452 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
xhtml "golang.org/x/net/html"
)
// largeAttHeaderEntry is a union of the CLI and server JSON formats for
// entries in the large attachment header.
type largeAttHeaderEntry struct {
ID string `json:"id,omitempty"`
FileKey string `json:"file_key,omitempty"`
FileName string `json:"file_name,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
}
func (e largeAttHeaderEntry) token() string {
if e.ID != "" {
return e.ID
}
return e.FileKey
}
// IsLargeAttachmentHeader returns true if the header name matches either
// the CLI-written or server-returned large attachment header.
func IsLargeAttachmentHeader(name string) bool {
return strings.EqualFold(name, LargeAttachmentIDsHeader) ||
strings.EqualFold(name, ServerLargeAttachmentHeader)
}
// decodeLargeAttachmentHeader decodes the base64 value and returns entries.
func decodeLargeAttachmentHeader(value string) ([]largeAttHeaderEntry, error) {
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(value))
if err != nil {
return nil, err
}
var items []largeAttHeaderEntry
if err := json.Unmarshal(decoded, &items); err != nil {
return nil, err
}
return items, nil
}
// parseLargeAttachmentTokens returns the ordered list of large attachment
// tokens from either X-Lms-Large-Attachment-Ids (CLI format) or
// X-Lark-Large-Attachment (server format). Returns nil when neither
// header is present or the value is malformed.
func parseLargeAttachmentTokens(headers []Header) []string {
for _, h := range headers {
if !IsLargeAttachmentHeader(h.Name) {
continue
}
items, err := decodeLargeAttachmentHeader(h.Value)
if err != nil {
return nil
}
out := make([]string, 0, len(items))
for _, it := range items {
if tok := it.token(); tok != "" {
out = append(out, tok)
}
}
return out
}
return nil
}
// ParseLargeAttachmentSummariesFromHeader extracts full metadata from the
// large attachment header. Returns non-nil only when the server-format
// header (X-Lark-Large-Attachment) is found, since it carries file_name
// and file_size that the CLI-format header lacks.
func ParseLargeAttachmentSummariesFromHeader(headers []Header) []LargeAttachmentSummary {
for _, h := range headers {
if !strings.EqualFold(h.Name, ServerLargeAttachmentHeader) {
continue
}
items, err := decodeLargeAttachmentHeader(h.Value)
if err != nil {
return nil
}
out := make([]LargeAttachmentSummary, 0, len(items))
for _, it := range items {
tok := it.token()
if tok == "" {
continue
}
out = append(out, LargeAttachmentSummary{
Token: tok,
FileName: it.FileName,
SizeBytes: it.FileSize,
})
}
return out
}
return nil
}
// ParseLargeAttachmentItemsFromHTML walks the HTML body looking for large
// attachment card items (<div id="large-file-item">) and returns a map
// from token (data-mail-token attribute value) to filename + size.
//
// The size is parsed best-effort from the displayed string (e.g. "25.0 MB");
// it carries the precision of the formatted value and is not byte-exact.
func ParseLargeAttachmentItemsFromHTML(htmlBody string) map[string]LargeAttachmentSummary {
out := map[string]LargeAttachmentSummary{}
if htmlBody == "" {
return out
}
doc, err := xhtml.Parse(strings.NewReader(htmlBody))
if err != nil {
return out
}
var walk func(n *xhtml.Node)
walk = func(n *xhtml.Node) {
if n == nil {
return
}
if n.Type == xhtml.ElementNode && n.Data == "div" && attr(n, "id") == LargeFileItemID {
if token, meta, ok := extractItemMeta(n); ok {
out[token] = meta
}
// Do not descend further: the <a> and texts have been collected.
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
return out
}
// extractItemMeta collects the token, filename, and size from a large
// attachment item node. Returns ok=false when the token is missing.
//
// Expected structure (see largeAttItemTpl in mail/large_attachment.go):
//
// <div id="large-file-item">
// <div><img ... /></div> // icon
// <div>
// <div>FILENAME</div>
// <div><span>SIZE_DISPLAY</span></div>
// </div>
// <a data-mail-token="TOKEN" ...>DOWNLOAD_LABEL</a>
// </div>
//
// The token comes from the <a data-mail-token=...>. The first non-anchor
// text is the filename; the next text is the size display.
func extractItemMeta(item *xhtml.Node) (token string, meta LargeAttachmentSummary, ok bool) {
var texts []string
var insideAnchor bool
var walk func(n *xhtml.Node)
walk = func(n *xhtml.Node) {
if n == nil {
return
}
if n.Type == xhtml.ElementNode && n.Data == "a" {
if t := attr(n, LargeAttachmentTokenAttr); t != "" && token == "" {
token = t
}
// Skip collecting the anchor's label (e.g. "Download" / "下载").
prev := insideAnchor
insideAnchor = true
defer func() { insideAnchor = prev }()
}
if n.Type == xhtml.TextNode && !insideAnchor {
if s := strings.TrimSpace(n.Data); s != "" {
texts = append(texts, s)
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(item)
if token == "" {
return "", LargeAttachmentSummary{}, false
}
if len(texts) > 0 {
meta.FileName = texts[0]
}
if len(texts) > 1 {
meta.SizeBytes = parseSizeDisplay(texts[1])
}
return token, meta, true
}
func attr(n *xhtml.Node, name string) string {
for _, a := range n.Attr {
if a.Key == name {
return a.Val
}
}
return ""
}
// sizeDisplayRe matches sizes like "25.0 MB", "1 GB", "500 KB", "42 B".
// The unit is case-insensitive and may be B / KB / MB / GB / TB.
var sizeDisplayRe = regexp.MustCompile(`(?i)^\s*([0-9]+(?:\.[0-9]+)?)\s*(B|KB|MB|GB|TB)\s*$`)
// parseSizeDisplay converts a formatted size display string back into
// an approximate byte count. Precision is limited by the display rounding
// (e.g. "25.0 MB" round-trips to 26214400 bytes).
// Returns 0 when the input cannot be parsed.
func parseSizeDisplay(s string) int64 {
m := sizeDisplayRe.FindStringSubmatch(s)
if m == nil {
return 0
}
value, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return 0
}
unit := strings.ToUpper(m[2])
var mul int64
switch unit {
case "B":
mul = 1
case "KB":
mul = 1024
case "MB":
mul = 1024 * 1024
case "GB":
mul = 1024 * 1024 * 1024
case "TB":
mul = 1024 * 1024 * 1024 * 1024
default:
return 0
}
return int64(value * float64(mul))
}
// removeLargeAttachment removes a large attachment by its file token.
// It updates both representations:
//
// 1. X-Lms-Large-Attachment-Ids header: removes the token from the JSON
// ID list. If the list becomes empty, the header itself is removed.
// 2. HTML body: removes the <div id="large-file-item"> whose <a> has the
// matching data-mail-token attribute. If the enclosing container
// <div id="large-file-area-*"> has no remaining items, the whole
// container is removed.
func removeLargeAttachment(snapshot *DraftSnapshot, token string) error {
token = strings.TrimSpace(token)
if token == "" {
return fmt.Errorf("remove_attachment: token is empty")
}
if err := removeTokenFromIDsHeader(snapshot, token); err != nil {
return err
}
if err := removeTokenFromHTMLBody(snapshot, token); err != nil {
return err
}
return nil
}
// removeTokenFromIDsHeader removes the given token from whichever large
// attachment header is present (CLI or server format). Returns an error
// if no header is found or the token is not listed. After removal, the
// header is re-encoded in CLI format (X-Lms-Large-Attachment-Ids) so
// the server can process the update on upload.
func removeTokenFromIDsHeader(snapshot *DraftSnapshot, token string) error {
headerIdx := -1
for i, h := range snapshot.Headers {
if IsLargeAttachmentHeader(h.Name) {
headerIdx = i
break
}
}
if headerIdx < 0 {
return fmt.Errorf("remove_attachment: draft has no large attachment header")
}
items, err := decodeLargeAttachmentHeader(snapshot.Headers[headerIdx].Value)
if err != nil {
return fmt.Errorf("remove_attachment: malformed large attachment header: %w", err)
}
filtered := make([]largeAttHeaderEntry, 0, len(items))
removed := false
for _, it := range items {
if it.token() == token {
removed = true
continue
}
filtered = append(filtered, it)
}
if !removed {
return fmt.Errorf("remove_attachment: token %q not found in large attachment header", token)
}
if len(filtered) == 0 {
snapshot.Headers = append(snapshot.Headers[:headerIdx], snapshot.Headers[headerIdx+1:]...)
return nil
}
cliItems := make([]struct {
ID string `json:"id"`
}, len(filtered))
for i, it := range filtered {
cliItems[i].ID = it.token()
}
encoded, err := json.Marshal(cliItems)
if err != nil {
return fmt.Errorf("remove_attachment: failed to re-encode large attachment header: %w", err)
}
snapshot.Headers[headerIdx].Name = LargeAttachmentIDsHeader
snapshot.Headers[headerIdx].Value = base64.StdEncoding.EncodeToString(encoded)
return nil
}
// removeTokenFromHTMLBody walks the HTML body, removes the single
// large-file-item whose anchor has data-mail-token == token, and if the
// enclosing container becomes empty (no more large-file-item children),
// removes the whole container.
//
// It is not an error if the HTML body or item is missing — the header
// removal is still considered the authoritative operation. This handles
// cases where the HTML was already edited out but the header wasn't.
func removeTokenFromHTMLBody(snapshot *DraftSnapshot, token string) error {
htmlPart := FindHTMLBodyPart(snapshot.Body)
if htmlPart == nil || len(htmlPart.Body) == 0 {
return nil
}
body := string(htmlPart.Body)
newBody, changed := RemoveLargeFileItemFromHTML(body, token)
if !changed {
return nil
}
htmlPart.Body = []byte(newBody)
htmlPart.Dirty = true
return nil
}
// RemoveLargeFileItemFromHTML parses the HTML, finds the large-file-item
// containing an <a> whose token matches (via data-mail-token attribute or
// href URL token= parameter), removes that item, and if the enclosing
// large-file-area container becomes empty, removes the container as well.
// Returns the updated HTML and a changed flag.
func RemoveLargeFileItemFromHTML(htmlBody, token string) (string, bool) {
doc, err := xhtml.Parse(strings.NewReader(htmlBody))
if err != nil {
return htmlBody, false
}
item := findLargeFileItemByToken(doc, token)
if item == nil {
return htmlBody, false
}
container := item.Parent
// Detach the item from its parent.
if container != nil {
container.RemoveChild(item)
}
// If the container is a large-file-area and has no remaining
// large-file-item children, remove the whole container.
if container != nil && isLargeFileAreaContainer(container) && !hasLargeFileItemChild(container) {
if grand := container.Parent; grand != nil {
grand.RemoveChild(container)
}
}
var buf bytes.Buffer
if err := xhtml.Render(&buf, doc); err != nil {
return htmlBody, false
}
return stripHTMLEnvelope(buf.String()), true
}
func findLargeFileItemByToken(n *xhtml.Node, token string) *xhtml.Node {
if n == nil {
return nil
}
if n.Type == xhtml.ElementNode && n.Data == "div" && attr(n, "id") == LargeFileItemID {
if itemContainsToken(n, token) {
return n
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if found := findLargeFileItemByToken(c, token); found != nil {
return found
}
}
return nil
}
func itemContainsToken(item *xhtml.Node, token string) bool {
if item == nil {
return false
}
for c := item.FirstChild; c != nil; c = c.NextSibling {
if c.Type == xhtml.ElementNode && c.Data == "a" {
if attr(c, LargeAttachmentTokenAttr) == token {
return true
}
if hrefContainsToken(attr(c, "href"), token) {
return true
}
}
if itemContainsToken(c, token) {
return true
}
}
return false
}
func hrefContainsToken(href, token string) bool {
if href == "" || token == "" {
return false
}
u, err := url.Parse(href)
if err != nil {
return false
}
return u.Query().Get("token") == token
}
func isLargeFileAreaContainer(n *xhtml.Node) bool {
if n == nil || n.Type != xhtml.ElementNode || n.Data != "div" {
return false
}
return strings.HasPrefix(attr(n, "id"), LargeFileContainerIDPrefix)
}
func hasLargeFileItemChild(n *xhtml.Node) bool {
if n == nil {
return false
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == xhtml.ElementNode && c.Data == "div" && attr(c, "id") == LargeFileItemID {
return true
}
if hasLargeFileItemChild(c) {
return true
}
}
return false
}
// stripHTMLEnvelope removes the <html><head></head><body>...</body></html>
// wrapper that xhtml.Parse + xhtml.Render adds around HTML fragments.
func stripHTMLEnvelope(s string) string {
s = strings.TrimPrefix(s, "<html><head></head><body>")
s = strings.TrimSuffix(s, "</body></html>")
return s
}

View File

@@ -1,314 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
)
func TestParseLargeAttachmentTokens(t *testing.T) {
encode := func(ids ...string) string {
type item struct {
ID string `json:"id"`
}
items := make([]item, len(ids))
for i, id := range ids {
items[i] = item{ID: id}
}
b, _ := json.Marshal(items)
return base64.StdEncoding.EncodeToString(b)
}
cases := []struct {
name string
headers []Header
want []string
}{
{
name: "empty headers",
headers: nil,
want: nil,
},
{
name: "header present with one token",
headers: []Header{{Name: LargeAttachmentIDsHeader, Value: encode("tokA")}},
want: []string{"tokA"},
},
{
name: "header present with multiple tokens in order",
headers: []Header{{Name: LargeAttachmentIDsHeader, Value: encode("tokA", "tokB", "tokC")}},
want: []string{"tokA", "tokB", "tokC"},
},
{
name: "case-insensitive header name match",
headers: []Header{{Name: "x-lms-large-attachment-ids", Value: encode("tokA")}},
want: []string{"tokA"},
},
{
name: "malformed base64 → nil",
headers: []Header{{Name: LargeAttachmentIDsHeader, Value: "not!!base64"}},
want: nil,
},
{
name: "malformed JSON → nil",
headers: []Header{{Name: LargeAttachmentIDsHeader, Value: base64.StdEncoding.EncodeToString([]byte("not json"))}},
want: nil,
},
{
name: "empty string IDs filtered out",
headers: []Header{{Name: LargeAttachmentIDsHeader, Value: encode("tokA", "", "tokB")}},
want: []string{"tokA", "tokB"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseLargeAttachmentTokens(tc.headers)
if !equalStrings(got, tc.want) {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestParseSizeDisplay(t *testing.T) {
cases := []struct {
in string
want int64
}{
{"25.0 MB", 26214400},
{"1 GB", 1024 * 1024 * 1024},
{"500 KB", 500 * 1024},
{"42 B", 42},
{" 25.0 MB ", 26214400}, // whitespace tolerated
{"25.0 mb", 26214400}, // case-insensitive
{"garbage", 0},
{"", 0},
{"25", 0}, // no unit
{"25 XB", 0}, // invalid unit
}
for _, tc := range cases {
if got := parseSizeDisplay(tc.in); got != tc.want {
t.Errorf("parseSizeDisplay(%q) = %d, want %d", tc.in, got, tc.want)
}
}
}
func TestParseLargeAttachmentItemsFromHTML(t *testing.T) {
// Minimal HTML mirroring the structure generated by mail/large_attachment.go
item := func(token, filename, size string) string {
return `<div id="large-file-item">` +
`<div><img src="x.png"/></div>` +
`<div>` +
`<div>` + filename + `</div>` +
`<div><span>` + size + `</span></div>` +
`</div>` +
`<a href="x" data-mail-token="` + token + `">Download</a>` +
`</div>`
}
html := `<div id="large-file-area-123">` +
`<div>Title</div>` +
item("tokA", "a.pdf", "25.0 MB") +
item("tokB", "b.mov", "300 MB") +
`</div>`
got := ParseLargeAttachmentItemsFromHTML(html)
if len(got) != 2 {
t.Fatalf("expected 2 items, got %d: %+v", len(got), got)
}
if got["tokA"].FileName != "a.pdf" {
t.Errorf("tokA filename: got %q, want %q", got["tokA"].FileName, "a.pdf")
}
if got["tokA"].SizeBytes != 26214400 {
t.Errorf("tokA size: got %d, want 26214400", got["tokA"].SizeBytes)
}
if got["tokB"].FileName != "b.mov" {
t.Errorf("tokB filename: got %q, want %q", got["tokB"].FileName, "b.mov")
}
if got["tokB"].SizeBytes != 300*1024*1024 {
t.Errorf("tokB size: got %d, want %d", got["tokB"].SizeBytes, 300*1024*1024)
}
}
func TestProjectLargeAttachments_MergesHeaderAndHTML(t *testing.T) {
type idItem struct {
ID string `json:"id"`
}
idsJSON, _ := json.Marshal([]idItem{{ID: "tokA"}, {ID: "tokB"}})
headers := []Header{{Name: LargeAttachmentIDsHeader, Value: base64.StdEncoding.EncodeToString(idsJSON)}}
html := `<div id="large-file-area-1">` +
`<div>Title</div>` +
`<div id="large-file-item"><div>a.pdf</div><div><span>25.0 MB</span></div><a data-mail-token="tokA">D</a></div>` +
`<div id="large-file-item"><div>b.mov</div><div><span>300 MB</span></div><a data-mail-token="tokB">D</a></div>` +
`</div>`
got := projectLargeAttachments(headers, html)
if len(got) != 2 {
t.Fatalf("expected 2, got %d: %+v", len(got), got)
}
if got[0].Token != "tokA" || got[0].FileName != "a.pdf" || got[0].SizeBytes != 26214400 {
t.Errorf("index 0: %+v", got[0])
}
if got[1].Token != "tokB" || got[1].FileName != "b.mov" {
t.Errorf("index 1: %+v", got[1])
}
}
func TestProjectLargeAttachments_HeaderWithoutHTML(t *testing.T) {
// Token present in header but HTML missing the card entry (malformed draft).
// We still report the token with empty meta.
type idItem struct {
ID string `json:"id"`
}
idsJSON, _ := json.Marshal([]idItem{{ID: "orphanToken"}})
headers := []Header{{Name: LargeAttachmentIDsHeader, Value: base64.StdEncoding.EncodeToString(idsJSON)}}
got := projectLargeAttachments(headers, "")
if len(got) != 1 {
t.Fatalf("expected 1, got %d", len(got))
}
if got[0].Token != "orphanToken" {
t.Errorf("got token %q", got[0].Token)
}
if got[0].FileName != "" || got[0].SizeBytes != 0 {
t.Errorf("expected empty meta, got %+v", got[0])
}
}
func TestProjectLargeAttachments_NoHeader(t *testing.T) {
got := projectLargeAttachments(nil, `<div id="large-file-area-1">...</div>`)
if got != nil {
t.Errorf("expected nil, got %v", got)
}
}
func TestRemoveLargeAttachment_RemovesOneOfTwo(t *testing.T) {
type idItem struct {
ID string `json:"id"`
}
idsJSON, _ := json.Marshal([]idItem{{ID: "tokA"}, {ID: "tokB"}})
headerValue := base64.StdEncoding.EncodeToString(idsJSON)
html := `<html><body><p>hi</p><div id="large-file-area-1">` +
`<div>Title</div>` +
`<div id="large-file-item"><div>a.pdf</div><div><span>25.0 MB</span></div><a data-mail-token="tokA">D</a></div>` +
`<div id="large-file-item"><div>b.mov</div><div><span>300 MB</span></div><a data-mail-token="tokB">D</a></div>` +
`</div></body></html>`
snapshot := &DraftSnapshot{
Headers: []Header{{Name: LargeAttachmentIDsHeader, Value: headerValue}},
Body: &Part{
MediaType: "text/html",
Body: []byte(html),
},
}
if err := removeLargeAttachment(snapshot, "tokA"); err != nil {
t.Fatalf("removeLargeAttachment: %v", err)
}
// Header should contain only tokB
tokens := parseLargeAttachmentTokens(snapshot.Headers)
if !equalStrings(tokens, []string{"tokB"}) {
t.Errorf("tokens after removal: got %v, want [tokB]", tokens)
}
// HTML should not contain data-mail-token="tokA" anymore, but still contain tokB and the container
newHTML := string(snapshot.Body.Body)
if strings.Contains(newHTML, `data-mail-token="tokA"`) {
t.Errorf("HTML still contains tokA item:\n%s", newHTML)
}
if !strings.Contains(newHTML, `data-mail-token="tokB"`) {
t.Errorf("HTML missing tokB item:\n%s", newHTML)
}
if !strings.Contains(newHTML, `id="large-file-area-1"`) {
t.Errorf("HTML missing container (should still exist with tokB):\n%s", newHTML)
}
}
func TestRemoveLargeAttachment_RemovesLastOneClearsContainer(t *testing.T) {
type idItem struct {
ID string `json:"id"`
}
idsJSON, _ := json.Marshal([]idItem{{ID: "tokOnly"}})
headerValue := base64.StdEncoding.EncodeToString(idsJSON)
html := `<html><body><p>hi</p><div id="large-file-area-1">` +
`<div>Title</div>` +
`<div id="large-file-item"><div>a.pdf</div><div><span>25.0 MB</span></div><a data-mail-token="tokOnly">D</a></div>` +
`</div></body></html>`
snapshot := &DraftSnapshot{
Headers: []Header{{Name: LargeAttachmentIDsHeader, Value: headerValue}},
Body: &Part{
MediaType: "text/html",
Body: []byte(html),
},
}
if err := removeLargeAttachment(snapshot, "tokOnly"); err != nil {
t.Fatalf("removeLargeAttachment: %v", err)
}
// Header should be entirely removed (empty list)
for _, h := range snapshot.Headers {
if strings.EqualFold(h.Name, LargeAttachmentIDsHeader) {
t.Errorf("header should have been removed when list is empty")
}
}
// HTML should not contain the container at all
newHTML := string(snapshot.Body.Body)
if strings.Contains(newHTML, "large-file-area-1") {
t.Errorf("container should have been removed:\n%s", newHTML)
}
if strings.Contains(newHTML, "large-file-item") {
t.Errorf("no items should remain:\n%s", newHTML)
}
// Other body content should survive
if !strings.Contains(newHTML, "<p>hi</p>") {
t.Errorf("user body should remain:\n%s", newHTML)
}
}
func TestRemoveLargeAttachment_UnknownToken(t *testing.T) {
type idItem struct {
ID string `json:"id"`
}
idsJSON, _ := json.Marshal([]idItem{{ID: "tokA"}})
headerValue := base64.StdEncoding.EncodeToString(idsJSON)
snapshot := &DraftSnapshot{
Headers: []Header{{Name: LargeAttachmentIDsHeader, Value: headerValue}},
Body: &Part{MediaType: "text/html", Body: []byte(`<p>hi</p>`)},
}
err := removeLargeAttachment(snapshot, "unknown")
if err == nil {
t.Errorf("expected error for unknown token")
}
}
func TestRemoveLargeAttachment_MissingHeader(t *testing.T) {
snapshot := &DraftSnapshot{
Headers: []Header{},
Body: &Part{MediaType: "text/html", Body: []byte(`<p>hi</p>`)},
}
err := removeLargeAttachment(snapshot, "any")
if err == nil {
t.Errorf("expected error when header is missing")
}
}

View File

@@ -18,11 +18,6 @@ type DraftRaw struct {
RawEML string
}
type DraftResult struct {
DraftID string
Reference string
}
type Header struct {
Name string
Value string
@@ -138,33 +133,22 @@ type PartSummary struct {
CID string `json:"cid,omitempty"`
}
// LargeAttachmentSummary describes a single large attachment registered in
// the draft via the X-Lms-Large-Attachment-Ids header. Unlike normal
// attachments, large attachments have no MIME part — their existence is
// conveyed by the header plus an HTML card in the body.
type LargeAttachmentSummary struct {
Token string `json:"token"`
FileName string `json:"filename,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
}
type DraftProjection struct {
Subject string `json:"subject"`
To []Address `json:"to,omitempty"`
Cc []Address `json:"cc,omitempty"`
Bcc []Address `json:"bcc,omitempty"`
ReplyTo []Address `json:"reply_to,omitempty"`
InReplyTo string `json:"in_reply_to,omitempty"`
References string `json:"references,omitempty"`
BodyText string `json:"body_text,omitempty"`
BodyHTMLSummary string `json:"body_html_summary,omitempty"`
HasQuotedContent bool `json:"has_quoted_content,omitempty"`
HasSignature bool `json:"has_signature,omitempty"`
SignatureID string `json:"signature_id,omitempty"`
AttachmentsSummary []PartSummary `json:"attachments_summary,omitempty"`
LargeAttachmentsSummary []LargeAttachmentSummary `json:"large_attachments_summary,omitempty"`
InlineSummary []PartSummary `json:"inline_summary,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Subject string `json:"subject"`
To []Address `json:"to,omitempty"`
Cc []Address `json:"cc,omitempty"`
Bcc []Address `json:"bcc,omitempty"`
ReplyTo []Address `json:"reply_to,omitempty"`
InReplyTo string `json:"in_reply_to,omitempty"`
References string `json:"references,omitempty"`
BodyText string `json:"body_text,omitempty"`
BodyHTMLSummary string `json:"body_html_summary,omitempty"`
HasQuotedContent bool `json:"has_quoted_content,omitempty"`
HasSignature bool `json:"has_signature,omitempty"`
SignatureID string `json:"signature_id,omitempty"`
AttachmentsSummary []PartSummary `json:"attachments_summary,omitempty"`
InlineSummary []PartSummary `json:"inline_summary,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
type Patch struct {
@@ -180,25 +164,12 @@ type PatchOptions struct {
type AttachmentTarget struct {
PartID string `json:"part_id,omitempty"`
CID string `json:"cid,omitempty"`
// Token selects a large attachment by its file token (registered via
// the X-Lms-Large-Attachment-Ids header). Only valid for
// remove_attachment; replace_inline/remove_inline operate on MIME
// parts and do not accept Token.
Token string `json:"token,omitempty"`
}
// hasKey reports whether a PartID or CID is set. Used for ops that
// target MIME parts (replace_inline, remove_inline).
func (t AttachmentTarget) hasKey() bool {
return strings.TrimSpace(t.PartID) != "" || strings.TrimSpace(t.CID) != ""
}
// hasAnyKey reports whether any locator (PartID, CID, or Token) is set.
// Used for remove_attachment which supports all three.
func (t AttachmentTarget) hasAnyKey() bool {
return t.hasKey() || strings.TrimSpace(t.Token) != ""
}
type PatchOp struct {
Op string `json:"op"`
Value string `json:"value,omitempty"`
@@ -300,8 +271,8 @@ func (op PatchOp) Validate() error {
return fmt.Errorf("add_attachment requires path")
}
case "remove_attachment":
if !op.Target.hasAnyKey() {
return fmt.Errorf("remove_attachment requires target with at least one of part_id, cid, or token")
if !op.Target.hasKey() {
return fmt.Errorf("remove_attachment requires target with at least one of part_id or cid")
}
case "add_inline":
if strings.TrimSpace(op.Path) == "" {

View File

@@ -104,16 +104,7 @@ func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchO
case "add_attachment":
return addAttachment(dctx, snapshot, op.Path)
case "remove_attachment":
// Priority: part_id > cid > token. When only token is set, route to
// the large attachment path (updates header + HTML card, no MIME
// part to remove). Otherwise, resolve to a concrete part_id.
tgt := op.Target
if strings.TrimSpace(tgt.PartID) == "" && strings.TrimSpace(tgt.CID) == "" {
if token := strings.TrimSpace(tgt.Token); token != "" {
return removeLargeAttachment(snapshot, token)
}
}
partID, err := resolveTarget(snapshot, tgt)
partID, err := resolveTarget(snapshot, op.Target)
if err != nil {
return fmt.Errorf("remove_attachment: %w", err)
}
@@ -266,19 +257,7 @@ func appendBody(snapshot *DraftSnapshot, bodyKind, value string, options PatchOp
return nil
}
// setBody replaces the body with value. Before replacement, it
// automatically preserves system-managed elements (signature block and
// large attachment card) from the old body, so body edits do not
// accidentally delete content the user didn't author. Users can still
// replace these elements explicitly by including their own equivalents
// in the new value; they can delete them explicitly via the dedicated
// ops (remove_signature, remove_attachment).
//
// This mirrors how normal attachments (independent MIME parts) survive
// body edits — giving consistent mental model: attachments and signature
// are draft-level concerns, not body content.
func setBody(snapshot *DraftSnapshot, value string, options PatchOptions) error {
value = autoPreserveSystemManagedRegions(snapshot, value)
switch {
case snapshot.PrimaryTextPartID != "" && snapshot.PrimaryHTMLPartID == "":
return replaceBody(snapshot, "text/plain", value, options)
@@ -297,70 +276,27 @@ func setBody(snapshot *DraftSnapshot, value string, options PatchOptions) error
}
}
// autoPreserveSystemManagedRegions extracts system-managed elements
// (signature block and large attachment card) from the draft's old HTML
// body and injects them into value (before any quote block in value, or
// appended when no quote). Order is [sig][card], matching compose-time
// layout [user][sig][card][quote].
//
// For each element, auto-injection is skipped when value's
// user-authored region (before any quote block in value) already
// contains that element — so users who explicitly reconstruct the body
// with their own signature / card are respected. Elements inside a
// quote block in value belong to the quoted original message and are
// ignored for this check.
//
// No-op when the draft has no HTML body, or neither element exists in
// the old body.
func autoPreserveSystemManagedRegions(snapshot *DraftSnapshot, value string) string {
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
return value
}
oldHTML := string(htmlPart.Body)
sig := ExtractSignatureBlock(oldHTML)
_, card, _ := SplitAtLargeAttachment(oldHTML)
if sig == "" && card == "" {
return value
}
valuePreQuote, _ := SplitAtQuote(value)
if sig != "" && signatureWrapperRe.MatchString(valuePreQuote) {
sig = ""
}
if card != "" && HTMLContainsLargeAttachment(valuePreQuote) {
card = ""
}
if sig == "" && card == "" {
return value
}
return InsertBeforeQuoteOrAppend(value, sig+card)
}
// setReplyBody replaces only the user-authored portion of the HTML
// body, preserving the trailing reply/forward quote block (generated
// by +reply / +forward). Signature and large attachment card
// preservation is delegated to setBody, which handles them via
// autoPreserveSystemManagedRegions. When there is no quote block, this
// falls through to setBody with no quote to preserve.
// setReplyBody replaces only the user-authored portion of the HTML body,
// preserving the trailing reply/forward quote block (the
// history-quote-wrapper div generated by +reply / +forward). If no quote
// block is found, it falls back to setBody.
func setReplyBody(snapshot *DraftSnapshot, value string, options PatchOptions) error {
htmlPartID := snapshot.PrimaryHTMLPartID
if htmlPartID == "" {
// No HTML part — fall back to setBody which handles text-only drafts.
return setBody(snapshot, value, options)
}
htmlPart := findPart(snapshot.Body, htmlPartID)
if htmlPart == nil {
return setBody(snapshot, value, options)
}
_, quote := SplitAtQuote(string(htmlPart.Body))
if quote == "" {
_, quotePart := SplitAtQuote(string(htmlPart.Body))
if quotePart == "" {
// No quote block found — fall back to regular set_body.
return setBody(snapshot, value, options)
}
// setBody's autoPreserve will insert the card before the quote wrapper
// it finds inside value (which is the quote we just appended here).
return setBody(snapshot, value+quote, options)
// Combine the new user content with the preserved quote block.
return setBody(snapshot, value+quotePart, options)
}
func tryApplyCoupledBodySetBody(snapshot *DraftSnapshot, value string) bool {
@@ -1211,41 +1147,41 @@ func postProcessInlineImages(dctx *DraftCtx, snapshot *DraftSnapshot, resolveLoc
// insertSignatureOp inserts a pre-rendered signature into the HTML body.
// The RenderedSignatureHTML and SignatureImages fields must be populated
// by the shortcut layer before calling Apply.
//
// Placement: signature goes between the user-authored region and any
// system-managed tail (large attachment card or history quote wrapper),
// matching the compose-time order [user][sig][card?][quote?]. When the
// draft already has a signature, it is replaced in place.
func insertSignatureOp(snapshot *DraftSnapshot, op PatchOp) error {
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
return fmt.Errorf("insert_signature: no HTML body part found; use set_body first")
}
oldHTML := string(htmlPart.Body)
html := string(htmlPart.Body)
// Collect CIDs from old signature before replacement so we can prune
// MIME inline parts that the new signature doesn't re-reference.
oldSigCIDs := collectSignatureCIDsFromHTML(oldHTML)
// Collect CIDs from old signature before removing it, so we can
// clean up orphaned MIME inline parts and avoid duplicates.
oldSigCIDs := collectSignatureCIDsFromHTML(html)
sigBlock := SignatureSpacing() + BuildSignatureHTML(op.SignatureID, op.RenderedSignatureHTML)
newHTML := PlaceSignatureBeforeSystemTail(oldHTML, sigBlock)
// Remove existing signature (if any), including preceding spacing.
html = RemoveSignatureHTML(html)
// Remove orphaned MIME inline parts from old signature.
for _, cid := range oldSigCIDs {
if !containsCIDIgnoreCase(newHTML, cid) {
if !containsCIDIgnoreCase(html, cid) {
removeMIMEPartByCID(snapshot.Body, cid)
}
}
htmlPart.Body = []byte(newHTML)
// Split at quote and insert signature between body and quote.
body, quote := SplitAtQuote(html)
sigBlock := SignatureSpacing() + BuildSignatureHTML(op.SignatureID, op.RenderedSignatureHTML)
html = body + sigBlock + quote
htmlPart.Body = []byte(html)
htmlPart.Dirty = true
// Add new signature inline images to the MIME tree.
// Add signature inline images to the MIME tree.
for _, img := range op.SignatureImages {
addInlinePartToSnapshot(snapshot, img.Data, img.ContentType, img.FileName, img.CID)
}
syncTextPartFromHTML(snapshot, newHTML)
syncTextPartFromHTML(snapshot, html)
return nil
}

View File

@@ -1,425 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"strings"
"testing"
)
// buildSnapshotWithCard builds a minimal snapshot whose HTML body contains
// a user section, a large attachment card, and optionally a quote block.
func buildSnapshotWithCard(userContent, card, quote string) *DraftSnapshot {
html := userContent + card + quote
return &DraftSnapshot{
PrimaryHTMLPartID: "1",
Body: &Part{
PartID: "1",
MediaType: "text/html",
Body: []byte(html),
},
}
}
// buildSnapshotFromHTML wraps arbitrary HTML into a minimal snapshot.
func buildSnapshotFromHTML(html string) *DraftSnapshot {
return &DraftSnapshot{
PrimaryHTMLPartID: "1",
Body: &Part{
PartID: "1",
MediaType: "text/html",
Body: []byte(html),
},
}
}
const testLargeCard = `<div id="large-file-area-123"><div>Title</div>` +
`<div id="large-file-item"><div>a.pdf</div><div><span>25.0 MB</span></div>` +
`<a data-mail-token="tokA">D</a></div></div>`
const testQuoteBlock = `<div class="history-quote-wrapper"><p>original msg</p></div>`
// testSigBlock mirrors what BuildSignatureHTML would produce, including
// the preceding SignatureSpacing.
var testSigBlock = SignatureSpacing() + `<div id="sig-abc" class="lark-mail-signature" style="padding-top:6px;padding-bottom:6px"><div>-- My Sig</div></div>`
func TestSetBody_PreservesLargeAttachmentCard(t *testing.T) {
snap := buildSnapshotWithCard(`<p>old user content</p>`, testLargeCard, "")
err := setBody(snap, `<p>new user content</p>`, PatchOptions{})
if err != nil {
t.Fatalf("setBody: %v", err)
}
newHTML := string(snap.Body.Body)
if !strings.Contains(newHTML, "new user content") {
t.Errorf("missing new content: %s", newHTML)
}
if strings.Contains(newHTML, "old user content") {
t.Errorf("old content should be gone: %s", newHTML)
}
if !strings.Contains(newHTML, `id="large-file-area-123"`) {
t.Errorf("card should be preserved: %s", newHTML)
}
if !strings.Contains(newHTML, "a.pdf") || !strings.Contains(newHTML, "tokA") {
t.Errorf("card contents should be preserved: %s", newHTML)
}
}
func TestSetBody_RespectsUserSuppliedCard(t *testing.T) {
// When user's value already contains a large-file-area div, we must not
// auto-duplicate. Result should have only the user's card, not the old one.
snap := buildSnapshotWithCard(`<p>old</p>`, testLargeCard, "")
userCard := `<div id="large-file-area-999"><div id="large-file-item">` +
`<a data-mail-token="userTok">X</a></div></div>`
err := setBody(snap, `<p>new</p>`+userCard, PatchOptions{})
if err != nil {
t.Fatalf("setBody: %v", err)
}
newHTML := string(snap.Body.Body)
if !strings.Contains(newHTML, "userTok") {
t.Errorf("user's card should be present: %s", newHTML)
}
if strings.Contains(newHTML, "large-file-area-123") {
t.Errorf("old card should be gone (user supplied replacement): %s", newHTML)
}
// Should not be duplicated
if strings.Count(newHTML, "large-file-area-") != 1 {
t.Errorf("should have exactly one card, got %d: %s",
strings.Count(newHTML, "large-file-area-"), newHTML)
}
}
func TestSetBody_WithoutCardUnchangedBehavior(t *testing.T) {
// No card in draft — setBody behaves as before.
snap := &DraftSnapshot{
PrimaryHTMLPartID: "1",
Body: &Part{
PartID: "1",
MediaType: "text/html",
Body: []byte(`<p>old</p>`),
},
}
err := setBody(snap, `<p>new</p>`, PatchOptions{})
if err != nil {
t.Fatalf("setBody: %v", err)
}
if string(snap.Body.Body) != `<p>new</p>` {
t.Errorf("unexpected body: %q", string(snap.Body.Body))
}
}
func TestSetReplyBody_PreservesCardAndQuote(t *testing.T) {
snap := buildSnapshotWithCard(`<p>old user</p>`, testLargeCard, testQuoteBlock)
err := setReplyBody(snap, `<p>new user</p>`, PatchOptions{})
if err != nil {
t.Fatalf("setReplyBody: %v", err)
}
newHTML := string(snap.Body.Body)
if !strings.Contains(newHTML, "new user") {
t.Errorf("missing new content: %s", newHTML)
}
if strings.Contains(newHTML, "old user") {
t.Errorf("old user content should be gone: %s", newHTML)
}
if !strings.Contains(newHTML, `id="large-file-area-123"`) {
t.Errorf("card should be preserved: %s", newHTML)
}
if !strings.Contains(newHTML, "original msg") {
t.Errorf("quote should be preserved: %s", newHTML)
}
// Order: new user < card < quote
newIdx := strings.Index(newHTML, "new user")
cardIdx := strings.Index(newHTML, "large-file-area")
quoteIdx := strings.Index(newHTML, "original msg")
if !(newIdx < cardIdx && cardIdx < quoteIdx) {
t.Errorf("expected order [user][card][quote]: newIdx=%d cardIdx=%d quoteIdx=%d, html=%s",
newIdx, cardIdx, quoteIdx, newHTML)
}
}
// TestSetReplyBody_ReplyToMessageWithCard verifies that when replying to
// a message that itself contained a large attachment (so the quote block
// in the draft contains the original sender's card), the user's own card
// (sitting before the quote wrapper) is still preserved after
// set_reply_body. The check in autoPreserveLargeAttachmentCard must only
// look at value's user region, not inside the appended quote block.
func TestSetReplyBody_ReplyToMessageWithCard(t *testing.T) {
originalCardInQuote := `<div id="large-file-area-orig">` +
`<div id="large-file-item"><a data-mail-token="origTok">D</a></div>` +
`</div>`
quoteWithOrigCard := `<div class="history-quote-wrapper">` +
`<p>original message text</p>` + originalCardInQuote +
`</div>`
// Draft structure: [my reply][my card][quote[orig card]]
snap := buildSnapshotWithCard(`<p>my old reply</p>`, testLargeCard, quoteWithOrigCard)
err := setReplyBody(snap, `<p>my new reply</p>`, PatchOptions{})
if err != nil {
t.Fatalf("setReplyBody: %v", err)
}
newHTML := string(snap.Body.Body)
// My card (from [my card] slot) should be preserved, even though the
// quote block contains the original sender's card.
if !strings.Contains(newHTML, `id="large-file-area-123"`) {
t.Errorf("my own card (large-file-area-123) should be preserved: %s", newHTML)
}
// Original sender's card is still in the quote block (untouched by reply).
if !strings.Contains(newHTML, `id="large-file-area-orig"`) {
t.Errorf("original sender's card in quote should remain: %s", newHTML)
}
// New content present, old content gone.
if !strings.Contains(newHTML, "my new reply") {
t.Errorf("new content missing: %s", newHTML)
}
if strings.Contains(newHTML, "my old reply") {
t.Errorf("old content should be gone: %s", newHTML)
}
// Order: new user content < my card < quote wrapper (which contains orig card)
newIdx := strings.Index(newHTML, "my new reply")
myCardIdx := strings.Index(newHTML, "large-file-area-123")
quoteIdx := strings.Index(newHTML, "history-quote-wrapper")
origCardIdx := strings.Index(newHTML, "large-file-area-orig")
if !(newIdx < myCardIdx && myCardIdx < quoteIdx && quoteIdx < origCardIdx) {
t.Errorf("expected order [user][my-card][quote[orig-card]]: new=%d my-card=%d quote=%d orig-card=%d\nhtml=%s",
newIdx, myCardIdx, quoteIdx, origCardIdx, newHTML)
}
}
func TestSetReplyBody_NoQuoteFallsBackToSetBody(t *testing.T) {
// No quote — setReplyBody falls back to setBody, which preserves card.
snap := buildSnapshotWithCard(`<p>old</p>`, testLargeCard, "")
err := setReplyBody(snap, `<p>new</p>`, PatchOptions{})
if err != nil {
t.Fatalf("setReplyBody: %v", err)
}
newHTML := string(snap.Body.Body)
if !strings.Contains(newHTML, "large-file-area-123") {
t.Errorf("card should be preserved: %s", newHTML)
}
if !strings.Contains(newHTML, "new") {
t.Errorf("missing new content: %s", newHTML)
}
}
func TestSplitAtLargeAttachment(t *testing.T) {
cases := []struct {
name string
html string
wantBefore string
wantCardIn string // substring expected in card
wantAfter string
}{
{
name: "no card",
html: `<p>hello</p>`,
wantBefore: `<p>hello</p>`,
wantCardIn: "",
wantAfter: "",
},
{
name: "card at end",
html: `<p>user</p><div id="large-file-area-1"><div id="large-file-item"></div></div>`,
wantBefore: `<p>user</p>`,
wantCardIn: "large-file-area-1",
wantAfter: "",
},
{
name: "card before quote",
html: `<p>user</p>` +
`<div id="large-file-area-1"><div id="large-file-item"></div></div>` +
`<div class="history-quote-wrapper">q</div>`,
wantBefore: `<p>user</p>`,
wantCardIn: "large-file-area-1",
wantAfter: `<div class="history-quote-wrapper">q</div>`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
before, card, after := SplitAtLargeAttachment(tc.html)
if before != tc.wantBefore {
t.Errorf("before: got %q, want %q", before, tc.wantBefore)
}
if tc.wantCardIn == "" && card != "" {
t.Errorf("card should be empty, got %q", card)
}
if tc.wantCardIn != "" && !strings.Contains(card, tc.wantCardIn) {
t.Errorf("card should contain %q, got %q", tc.wantCardIn, card)
}
if after != tc.wantAfter {
t.Errorf("after: got %q, want %q", after, tc.wantAfter)
}
})
}
}
// ---------------------------------------------------------------------------
// set_body / set_reply_body: signature auto-preservation
// ---------------------------------------------------------------------------
func TestSetBody_PreservesSignature(t *testing.T) {
snap := buildSnapshotFromHTML(`<p>old user</p>` + testSigBlock)
if err := setBody(snap, `<p>new user</p>`, PatchOptions{}); err != nil {
t.Fatalf("setBody: %v", err)
}
newHTML := string(snap.Body.Body)
if !strings.Contains(newHTML, "new user") {
t.Errorf("missing new content: %s", newHTML)
}
if !strings.Contains(newHTML, `class="lark-mail-signature"`) {
t.Errorf("signature should be preserved: %s", newHTML)
}
if !strings.Contains(newHTML, "My Sig") {
t.Errorf("signature content should be preserved: %s", newHTML)
}
// Order: new user content < signature
newIdx := strings.Index(newHTML, "new user")
sigIdx := strings.Index(newHTML, "lark-mail-signature")
if newIdx > sigIdx {
t.Errorf("signature should come after new content: new@%d sig@%d", newIdx, sigIdx)
}
}
func TestSetBody_PreservesSignatureAndCard(t *testing.T) {
snap := buildSnapshotFromHTML(`<p>old</p>` + testSigBlock + testLargeCard)
if err := setBody(snap, `<p>new</p>`, PatchOptions{}); err != nil {
t.Fatalf("setBody: %v", err)
}
newHTML := string(snap.Body.Body)
newIdx := strings.Index(newHTML, "new")
sigIdx := strings.Index(newHTML, "lark-mail-signature")
cardIdx := strings.Index(newHTML, "large-file-area-123")
if newIdx < 0 || sigIdx < 0 || cardIdx < 0 {
t.Fatalf("missing parts: %s", newHTML)
}
if !(newIdx < sigIdx && sigIdx < cardIdx) {
t.Errorf("expected order [new][sig][card], got new@%d sig@%d card@%d",
newIdx, sigIdx, cardIdx)
}
}
func TestSetBody_RespectsUserSuppliedSignature(t *testing.T) {
snap := buildSnapshotFromHTML(`<p>old</p>` + testSigBlock)
userSig := `<div id="user-sig" class="lark-mail-signature"><div>-- User Sig</div></div>`
if err := setBody(snap, `<p>new</p>`+userSig, PatchOptions{}); err != nil {
t.Fatalf("setBody: %v", err)
}
newHTML := string(snap.Body.Body)
if !strings.Contains(newHTML, "User Sig") {
t.Errorf("user-supplied sig should be present: %s", newHTML)
}
if strings.Contains(newHTML, "My Sig") {
t.Errorf("old signature should be gone when user supplied their own: %s", newHTML)
}
// Only one signature wrapper
if strings.Count(newHTML, "lark-mail-signature") != 1 {
t.Errorf("expected exactly one signature wrapper, got %d",
strings.Count(newHTML, "lark-mail-signature"))
}
}
func TestSetReplyBody_PreservesSignatureAndQuote(t *testing.T) {
snap := buildSnapshotFromHTML(`<p>old user</p>` + testSigBlock + testQuoteBlock)
if err := setReplyBody(snap, `<p>new user</p>`, PatchOptions{}); err != nil {
t.Fatalf("setReplyBody: %v", err)
}
newHTML := string(snap.Body.Body)
newIdx := strings.Index(newHTML, "new user")
sigIdx := strings.Index(newHTML, "lark-mail-signature")
quoteIdx := strings.Index(newHTML, "history-quote-wrapper")
if !(newIdx < sigIdx && sigIdx < quoteIdx) {
t.Errorf("expected [new user][sig][quote], got new@%d sig@%d quote@%d",
newIdx, sigIdx, quoteIdx)
}
}
func TestSetReplyBody_PreservesAllThreeRegions(t *testing.T) {
snap := buildSnapshotFromHTML(`<p>old user</p>` + testSigBlock + testLargeCard + testQuoteBlock)
if err := setReplyBody(snap, `<p>new user</p>`, PatchOptions{}); err != nil {
t.Fatalf("setReplyBody: %v", err)
}
newHTML := string(snap.Body.Body)
newIdx := strings.Index(newHTML, "new user")
sigIdx := strings.Index(newHTML, "lark-mail-signature")
cardIdx := strings.Index(newHTML, "large-file-area-123")
quoteIdx := strings.Index(newHTML, "history-quote-wrapper")
if !(newIdx < sigIdx && sigIdx < cardIdx && cardIdx < quoteIdx) {
t.Errorf("expected [new][sig][card][quote], got new@%d sig@%d card@%d quote@%d",
newIdx, sigIdx, cardIdx, quoteIdx)
}
}
// ---------------------------------------------------------------------------
// ExtractSignatureBlock: symmetric with RemoveSignatureHTML
// ---------------------------------------------------------------------------
func TestExtractSignatureBlock_Symmetry(t *testing.T) {
cases := []string{
`<p>user</p>` + testSigBlock,
`<p>user</p>` + testSigBlock + testQuoteBlock,
`<p>user</p>` + testSigBlock + testLargeCard + testQuoteBlock,
}
for _, html := range cases {
extracted := ExtractSignatureBlock(html)
cleaned := RemoveSignatureHTML(html)
if extracted == "" {
t.Errorf("extract returned empty for: %s", html)
continue
}
// The concatenation of cleaned + extracted (inserted back at the
// right spot) should reconstitute the original. Since we don't
// know the position, verify extract contains "lark-mail-signature"
// and cleaned doesn't.
if !strings.Contains(extracted, "lark-mail-signature") {
t.Errorf("extract missing signature class: %s", extracted)
}
if strings.Contains(cleaned, "lark-mail-signature") {
t.Errorf("clean still has signature: %s", cleaned)
}
// Length invariant: original == cleaned + extracted (bytes)
if len(html) != len(cleaned)+len(extracted) {
t.Errorf("length mismatch: %d != %d + %d", len(html), len(cleaned), len(extracted))
}
}
}
func TestExtractSignatureBlock_NoSignature(t *testing.T) {
if got := ExtractSignatureBlock(`<p>just text</p>`); got != "" {
t.Errorf("expected empty, got %q", got)
}
}
func TestHTMLContainsLargeAttachment(t *testing.T) {
cases := []struct {
html string
want bool
}{
{`<p>hello</p>`, false},
{`<div id="large-file-area-123"></div>`, true},
{`<p>the text "large-file-area-" in body</p>`, false},
{`<div class="x" id="large-file-area-abc" style="...">`, true},
}
for _, tc := range cases {
if got := HTMLContainsLargeAttachment(tc.html); got != tc.want {
t.Errorf("HTMLContainsLargeAttachment(%q) = %v, want %v", tc.html, got, tc.want)
}
}
}

View File

@@ -118,122 +118,6 @@ Content-Type: text/html; charset=UTF-8
}
}
// ---------------------------------------------------------------------------
// insert_signature — with large attachment card (no quote)
// ---------------------------------------------------------------------------
func TestInsertSignature_BeforeCard(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Sig before card
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>My reply</p><div id="large-file-area-123" style="..."><div id="large-file-item"><a data-mail-token="tokA">D</a></div></div>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "sig-card",
RenderedSignatureHTML: "<div>-- My Sig</div>",
}},
})
if err != nil {
t.Fatalf("Apply insert_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
userIdx := strings.Index(html, "My reply")
sigIdx := strings.Index(html, "My Sig")
cardIdx := strings.Index(html, "large-file-area-123")
if userIdx < 0 || sigIdx < 0 || cardIdx < 0 {
t.Fatalf("missing part(s) in html: %s", html)
}
if !(userIdx < sigIdx && sigIdx < cardIdx) {
t.Errorf("expected order [user][sig][card], got user@%d sig@%d card@%d: %s",
userIdx, sigIdx, cardIdx, html)
}
}
// ---------------------------------------------------------------------------
// insert_signature — with large attachment card AND quote
// ---------------------------------------------------------------------------
func TestInsertSignature_BeforeCardAndQuote(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Sig before card and quote
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>My reply</p><div id="large-file-area-123"><div id="large-file-item"><a data-mail-token="tokA">D</a></div></div><div class="history-quote-wrapper"><p>quoted</p></div>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "sig-all",
RenderedSignatureHTML: "<div>-- My Sig</div>",
}},
})
if err != nil {
t.Fatalf("Apply insert_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
userIdx := strings.Index(html, "My reply")
sigIdx := strings.Index(html, "My Sig")
cardIdx := strings.Index(html, "large-file-area-123")
quoteIdx := strings.Index(html, "quoted")
if userIdx < 0 || sigIdx < 0 || cardIdx < 0 || quoteIdx < 0 {
t.Fatalf("missing part(s) in html: %s", html)
}
if !(userIdx < sigIdx && sigIdx < cardIdx && cardIdx < quoteIdx) {
t.Errorf("expected order [user][sig][card][quote], got user@%d sig@%d card@%d quote@%d",
userIdx, sigIdx, cardIdx, quoteIdx)
}
}
// ---------------------------------------------------------------------------
// insert_signature — replaces existing signature that sits after card
// (legacy draft produced by old buggy code); new signature should move
// back to the correct position before the card.
// ---------------------------------------------------------------------------
func TestInsertSignature_ReplacesExistingWithCard(t *testing.T) {
// Old bad draft: [user][card][sig_old][quote] (legacy layout)
snapshot := mustParseFixtureDraft(t, `Subject: Replace sig with card
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>My reply</p><div id="large-file-area-123"><div id="large-file-item"><a data-mail-token="tokA">D</a></div></div><div id="old-sig" class="lark-mail-signature" style="padding-top:6px;padding-bottom:6px"><div>-- Old Sig</div></div><div class="history-quote-wrapper"><p>quoted</p></div>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "insert_signature",
SignatureID: "new-sig",
RenderedSignatureHTML: "<div>-- New Sig</div>",
}},
})
if err != nil {
t.Fatalf("Apply insert_signature: %v", err)
}
html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body)
if strings.Contains(html, "Old Sig") {
t.Error("old signature should have been removed")
}
userIdx := strings.Index(html, "My reply")
sigIdx := strings.Index(html, "New Sig")
cardIdx := strings.Index(html, "large-file-area-123")
quoteIdx := strings.Index(html, "quoted")
if !(userIdx < sigIdx && sigIdx < cardIdx && cardIdx < quoteIdx) {
t.Errorf("expected new sig to be placed before card: user@%d sig@%d card@%d quote@%d",
userIdx, sigIdx, cardIdx, quoteIdx)
}
}
// ---------------------------------------------------------------------------
// insert_signature — no HTML body
// ---------------------------------------------------------------------------

View File

@@ -16,31 +16,6 @@ import (
// (the detector) share a single source of truth.
const QuoteWrapperClass = "history-quote-wrapper"
// Well-known anchors for the large attachment HTML card generated by
// CLI and the desktop client. The HTML structure is:
//
// <div id="large-file-area-{timestamp}" ...>
// <div>Title</div>
// <div id="large-file-item" ...>
// ... filename, size, <a data-mail-token="..."> ...
// </div>
// <div id="large-file-item" ...> ... </div>
// </div>
const (
LargeFileContainerIDPrefix = "large-file-area-"
LargeFileItemID = "large-file-item"
LargeAttachmentTokenAttr = "data-mail-token"
)
// LargeAttachmentIDsHeader is the header name CLI writes when creating
// or editing a draft. The value is base64-encoded JSON: [{"id":"<token>"}].
const LargeAttachmentIDsHeader = "X-Lms-Large-Attachment-Ids"
// ServerLargeAttachmentHeader is the header name the mail server returns
// on readback. The value is base64-encoded JSON with richer metadata:
// [{"file_key":"<token>","file_name":"...","file_size":...}].
const ServerLargeAttachmentHeader = "X-Lark-Large-Attachment"
// quoteWrapperRe matches an actual <div> element whose class attribute
// contains QuoteWrapperClass. This avoids false positives when the
// string appears as plain text, inside <pre> blocks, or in
@@ -128,43 +103,17 @@ func Project(snapshot *DraftSnapshot) DraftProjection {
}
}
var htmlBody string
if part := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID); part != nil {
htmlBody = string(part.Body)
for _, cid := range extractCIDRefs(htmlBody) {
for _, cid := range extractCIDRefs(string(part.Body)) {
if !inlineCIDs[strings.ToLower(cid)] {
proj.Warnings = append(proj.Warnings, "missing inline MIME part for cid:"+cid)
}
}
}
proj.LargeAttachmentsSummary = projectLargeAttachments(snapshot.Headers, htmlBody)
return proj
}
// projectLargeAttachments extracts large attachment info from the draft.
// It first tries the server-format header (X-Lark-Large-Attachment) which
// carries filename and size directly. Falls back to merging CLI-format
// header tokens with HTML-parsed metadata.
func projectLargeAttachments(headers []Header, htmlBody string) []LargeAttachmentSummary {
if summaries := ParseLargeAttachmentSummariesFromHeader(headers); len(summaries) > 0 {
return summaries
}
tokens := parseLargeAttachmentTokens(headers)
if len(tokens) == 0 {
return nil
}
metas := ParseLargeAttachmentItemsFromHTML(htmlBody)
out := make([]LargeAttachmentSummary, 0, len(tokens))
for _, token := range tokens {
meta := metas[token]
meta.Token = token
out = append(out, meta)
}
return out
}
func flattenParts(root *Part) []*Part {
if root == nil {
return nil
@@ -214,129 +163,6 @@ func SplitAtQuote(html string) (body, quote string) {
return html[:loc[0]], html[loc[0]:]
}
// largeFileAreaOpenRe matches the opening <div> of a large attachment
// card container (id starts with "large-file-area-").
var largeFileAreaOpenRe = regexp.MustCompile(
`<div\s[^>]*id="` + regexp.QuoteMeta(LargeFileContainerIDPrefix) + `[^"]*"`)
// SplitAtLargeAttachment splits HTML into three pieces around the first
// large-file-area container: content before, the entire container block,
// and content after. If no container is present, returns (html, "", "").
//
// Used by set_body / set_reply_body to preserve the large attachment card
// across body replacements.
func SplitAtLargeAttachment(html string) (before, card, after string) {
loc := largeFileAreaOpenRe.FindStringIndex(html)
if loc == nil {
return html, "", ""
}
startTag := loc[0]
end := FindMatchingCloseDiv(html, startTag)
return html[:startTag], html[startTag:end], html[end:]
}
// splitAtSystemTail splits html at the earliest system-managed element:
// either the large-file-area card container or the history-quote-wrapper,
// whichever appears first. When neither is present, returns (html, "").
//
// This is the placement point for signatures. In Lark mail's compose
// order the signature sits right after the user-authored region and
// before any attachment cards or quoted content.
func splitAtSystemTail(html string) (userRegion, systemTail string) {
cardLoc := largeFileAreaOpenRe.FindStringIndex(html)
quoteLoc := quoteWrapperRe.FindStringIndex(html)
pos := -1
if cardLoc != nil {
pos = cardLoc[0]
}
if quoteLoc != nil && (pos < 0 || quoteLoc[0] < pos) {
pos = quoteLoc[0]
}
if pos < 0 {
return html, ""
}
return html[:pos], html[pos:]
}
// PlaceSignatureBeforeSystemTail is the single source of truth for
// signature placement. It removes any existing signature from html, then
// inserts sigBlock at the split point between the user-authored region
// and the system-managed tail (large attachment card or history quote
// wrapper, whichever comes first).
//
// Used by both compose-time signature injection
// (mail/signature_compose.go) and edit-time insert_signature op
// (draft/patch.go), guaranteeing they produce a consistent HTML layout
// [user][sig][card?][quote?].
//
// When sigBlock is empty, behaves as a simple "remove signature" on the
// HTML string level — note that callers needing MIME-part orphan cleanup
// should handle that separately.
func PlaceSignatureBeforeSystemTail(html, sigBlock string) string {
cleaned := RemoveSignatureHTML(html)
if sigBlock == "" {
return cleaned
}
user, tail := splitAtSystemTail(cleaned)
return user + sigBlock + tail
}
// HTMLContainsLargeAttachment reports whether the given HTML fragment
// contains a large attachment card container (`<div ... id="large-file-area-..."`).
// Used to detect whether a user-supplied set_body value already carries
// a card, in which case auto-preservation is skipped.
func HTMLContainsLargeAttachment(html string) bool {
return largeFileAreaOpenRe.MatchString(html)
}
// FindHTMLBodyPart walks the MIME tree and returns the first text/html
// body part (skipping attachment-disposition parts), or nil when none exists.
func FindHTMLBodyPart(root *Part) *Part {
if root == nil {
return nil
}
if strings.EqualFold(root.MediaType, "text/html") && !strings.EqualFold(root.ContentDisposition, "attachment") {
return root
}
for _, c := range root.Children {
if f := FindHTMLBodyPart(c); f != nil {
return f
}
}
return nil
}
// FindTextBodyPart walks the MIME tree and returns the first text/plain
// body part (skipping attachment-disposition parts), or nil when none exists.
func FindTextBodyPart(root *Part) *Part {
if root == nil {
return nil
}
if strings.EqualFold(root.MediaType, "text/plain") && !strings.EqualFold(root.ContentDisposition, "attachment") {
return root
}
for _, c := range root.Children {
if f := FindTextBodyPart(c); f != nil {
return f
}
}
return nil
}
// InsertBeforeQuoteOrAppend inserts block into html right before the
// outermost quote wrapper (<div ... class="history-quote-wrapper">), or
// appends it to the end when no quote block is present. Matching uses
// quoteWrapperRe (an actual element with the class attribute), avoiding
// false positives from plain-text or code-snippet occurrences of the
// class name.
func InsertBeforeQuoteOrAppend(html, block string) string {
loc := quoteWrapperRe.FindStringIndex(html)
if loc == nil {
return html + block
}
return html[:loc[0]] + block + html[loc[0]:]
}
// ── Exported signature HTML utilities ──
// Used by both draft/patch.go (internal) and mail/signature_html.go (cross-package).
@@ -385,43 +211,20 @@ func FindMatchingCloseDiv(html string, startPos int) int {
// RemoveSignatureHTML removes the signature block and its preceding spacing from HTML.
// Returns the HTML unchanged if no signature is found.
func RemoveSignatureHTML(html string) string {
start, end, ok := locateSignatureBlock(html)
if !ok {
return html
}
return html[:start] + html[end:]
}
// ExtractSignatureBlock returns the signature block (including any
// preceding spacing that would be removed by RemoveSignatureHTML) from
// html. Returns "" when html has no signature.
//
// Symmetric to RemoveSignatureHTML: RemoveSignatureHTML(html) +
// ExtractSignatureBlock(html) reconstitutes the original html.
func ExtractSignatureBlock(html string) string {
start, end, ok := locateSignatureBlock(html)
if !ok {
return ""
}
return html[start:end]
}
// locateSignatureBlock returns the start and end offsets of the
// signature block (including any preceding spacing) in html. ok=false
// when no signature is present.
func locateSignatureBlock(html string) (start, end int, ok bool) {
loc := signatureWrapperRe.FindStringIndex(html)
if loc == nil {
return 0, 0, false
return html
}
sigStart := loc[0]
sigEnd := FindMatchingCloseDiv(html, sigStart)
// Extend backward to include preceding spacing.
beforeSig := html[:sigStart]
if spacingLoc := signatureSpacingRe.FindStringIndex(beforeSig); spacingLoc != nil {
sigStart = spacingLoc[0]
}
return sigStart, sigEnd, true
return html[:sigStart] + html[sigEnd:]
}
func summarizeHTML(html string) string {

View File

@@ -108,79 +108,3 @@ Content-Type: text/html; charset=UTF-8
t.Fatalf("BodyHTMLSummary len = %d, should be truncated", len(proj.BodyHTMLSummary))
}
}
// ---------------------------------------------------------------------------
// FindTextBodyPart / FindHTMLBodyPart skip attachment-disposition parts
// ---------------------------------------------------------------------------
func TestFindTextBodyPart_SkipsAttachment(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: alice@example.com
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary=mix
--mix
Content-Type: text/html; charset=UTF-8
<p>body</p>
--mix
Content-Type: text/plain; charset=UTF-8
Content-Disposition: attachment; filename=notes.txt
This is a .txt attachment.
--mix--
`)
got := FindTextBodyPart(snapshot.Body)
if got != nil {
t.Errorf("FindTextBodyPart should return nil when only text/plain part is an attachment, got %q", string(got.Body))
}
}
func TestFindTextBodyPart_ReturnsBodyNotAttachment(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: alice@example.com
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary=mix
--mix
Content-Type: text/plain; charset=UTF-8
real body
--mix
Content-Type: text/plain; charset=UTF-8
Content-Disposition: attachment; filename=notes.txt
This is a .txt attachment.
--mix--
`)
got := FindTextBodyPart(snapshot.Body)
if got == nil {
t.Fatal("FindTextBodyPart should return the body part")
}
if string(got.Body) != "real body" {
t.Errorf("got %q, want body part", string(got.Body))
}
}
func TestFindHTMLBodyPart_SkipsAttachment(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: alice@example.com
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary=mix
--mix
Content-Type: text/plain; charset=UTF-8
plain body
--mix
Content-Type: text/html; charset=UTF-8
Content-Disposition: attachment; filename=page.html
<html><body>attached page</body></html>
--mix--
`)
got := FindHTMLBodyPart(snapshot.Body)
if got != nil {
t.Errorf("FindHTMLBodyPart should return nil when only text/html part is an attachment, got %q", string(got.Body))
}
}

Some files were not shown because too many files have changed in this diff Show More