mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3756f3642 | ||
|
|
27a2f2758b | ||
|
|
15ae1fabec | ||
|
|
d317493e49 | ||
|
|
a8f078478e | ||
|
|
06275415b1 | ||
|
|
b4c9c09de0 | ||
|
|
7fb71c6947 | ||
|
|
020aeb87ad | ||
|
|
686c91dc71 | ||
|
|
cfd89e0e28 | ||
|
|
ac4c34f2ad | ||
|
|
3ed691b25c | ||
|
|
30ad38d4b6 | ||
|
|
4fab062219 | ||
|
|
f27b8fdf40 | ||
|
|
c100ca049e | ||
|
|
4d68e09537 | ||
|
|
a3bbe00ee0 | ||
|
|
0250054a90 | ||
|
|
d7ee5b5769 | ||
|
|
b37adfd0ee | ||
|
|
082275f32b | ||
|
|
2eb9fae575 | ||
|
|
418192507e | ||
|
|
7752afab96 | ||
|
|
f7a56f38b1 | ||
|
|
ea056d132e | ||
|
|
7fc963f455 | ||
|
|
520acb618c | ||
|
|
dce2beb91c | ||
|
|
97968b6ef2 | ||
|
|
6bb988a655 | ||
|
|
4422265d5f |
61
CHANGELOG.md
61
CHANGELOG.md
@@ -2,6 +2,64 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.24] - 2026-05-06
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add sheet management shortcuts (#722)
|
||||
- **base**: Support batch record get and delete (#630)
|
||||
- **task**: Add upload task attachment shortcut (#736)
|
||||
- **drive**: Pre-flight 10000-rune total cap for `+add-comment` `reply_elements` (#605)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **auth**: Handle missing scopes and device flow improvements (#752)
|
||||
- Add url to markdown `+create` output (#753)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Refine field update conversion guidance (#748)
|
||||
|
||||
## [v1.0.23] - 2026-04-30
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Add `+pull` shortcut for one-way Drive → local mirror (#696)
|
||||
- **drive**: Add `+push` shortcut for one-way local → Drive mirror (#709)
|
||||
- **drive**: Add `+status` shortcut for content-hash diff (#692)
|
||||
- **drive**: Support `--file-name` for drive export (#685)
|
||||
- **base**: Add markdown output for record reads (#726)
|
||||
- **minutes**: Add media upload shortcut (#725)
|
||||
- **doc**: Warn when callout uses `type=` without `background-color` (#467)
|
||||
- **cmdutil**: Support `@file` for params and data (#724)
|
||||
- Add markdown shortcuts and skill docs (#704)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Guide lark-doc v2 usage (#710)
|
||||
- **minutes**: Clarify minutes file-to-notes routing (#732)
|
||||
|
||||
## [v1.0.22] - 2026-04-29
|
||||
|
||||
### Features
|
||||
|
||||
- **task**: Add resource agent & `agent_task_step_info` (#693)
|
||||
- **task**: Support app task members by id (#712)
|
||||
- **contact**: Add `--queries` multi-name fanout to `+search-user` (#707)
|
||||
- **slides**: Add slide templates with template-first skill guidance (#684)
|
||||
- **mail**: Support calendar events in emails (#646)
|
||||
- **install**: Honor `npm_config_registry` for binary URL resolution with npmmirror fallback (#690)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **install**: Make Windows zip extraction resilient (#713)
|
||||
- **config/init**: Respect `--brand` flag in `--new` mode (#711)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Clarify base search routing (#708)
|
||||
- **base**: Align base skills and view config contracts (#653)
|
||||
|
||||
## [v1.0.21] - 2026-04-28
|
||||
|
||||
### Features
|
||||
@@ -539,6 +597,9 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.24]: https://github.com/larksuite/cli/releases/tag/v1.0.24
|
||||
[v1.0.23]: https://github.com/larksuite/cli/releases/tag/v1.0.23
|
||||
[v1.0.22]: https://github.com/larksuite/cli/releases/tag/v1.0.22
|
||||
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21
|
||||
[v1.0.20]: https://github.com/larksuite/cli/releases/tag/v1.0.20
|
||||
[v1.0.19]: https://github.com/larksuite/cli/releases/tag/v1.0.19
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 23 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 23 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 16 business domains, 200+ curated commands, 23 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/)
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
||||
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||
@@ -28,6 +28,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
@@ -139,6 +140,7 @@ lark-cli auth status
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 23 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — 23 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 16 大业务域、200+ 精选命令、23 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -28,6 +28,7 @@
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
@@ -140,6 +141,7 @@ lark-cli auth status
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
|
||||
@@ -81,8 +81,8 @@ 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)")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin, @file for file input)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
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")
|
||||
@@ -112,6 +112,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
// Validate --file mutual exclusions first.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
|
||||
@@ -123,7 +124,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
@@ -145,7 +146,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
// Parse --data as JSON map for form fields (not as body).
|
||||
var dataFields any
|
||||
if opts.Data != "" {
|
||||
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
@@ -161,7 +162,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
fileIO,
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -171,7 +172,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
// Normal path: JSON body.
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -42,7 +43,18 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not configured yet. Run `lark-cli config init` to initialize.")
|
||||
// auth list is a read-only probe; the "configured but no users"
|
||||
// branch below already returns exit 0 with a stderr hint, so we
|
||||
// keep the same contract here. We still want the hint to be
|
||||
// workspace-aware, so we pull the message+hint out of
|
||||
// NotConfiguredError() instead of hard-coding it.
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
||||
if cfgErr.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
59
cmd/auth/list_test.go
Normal file
59
cmd/auth/list_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
|
||||
// `lark-cli auth list` is a read-only probe and must not fail-hard when no
|
||||
// config exists yet — scripts and AI agents use it as an idempotent "do I
|
||||
// have any users?" check, so the exit code carries semantic weight. Pair
|
||||
// that with the existing "configured but no logged-in users" branch (also
|
||||
// exit 0) and both empty states are consistent.
|
||||
func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||
}
|
||||
// Local workspace → hint must mention init, not bind.
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "config init") {
|
||||
t.Errorf("local hint missing config init: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "config bind") {
|
||||
t.Errorf("local hint must not mention config bind: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
||||
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||
// `config bind --help` instead of the local-only `config init`.
|
||||
func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
prev := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(prev) })
|
||||
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should still succeed under agent workspace; got: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "config bind --help") {
|
||||
t.Errorf("agent hint must point at config bind --help: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "config init") {
|
||||
t.Errorf("agent hint must not mention config init: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,9 @@ For AI agents: this command blocks until the user completes authorization in the
|
||||
browser. Run it in the background and retrieve the verification URL from its output.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, user login is not allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode)
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
|
||||
"if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
@@ -243,7 +242,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL
|
||||
// Step 2: Show user code and verification URL.
|
||||
// Both branches surface AgentTimeoutHint, but on different channels:
|
||||
// JSON mode embeds it as a structured field (so an agent that captures
|
||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
||||
// text mode prints to stderr (alongside the URL prompt).
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -251,6 +254,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_uri_complete": authResp.VerificationUriComplete,
|
||||
"user_code": authResp.UserCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"agent_hint": msg.AgentTimeoutHint,
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
@@ -260,6 +264,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -346,9 +351,15 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
|
||||
// device_code already returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
|
||||
opts.DeviceCode, 5, 600, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
if shouldRemoveLoginRequestedScope(result) {
|
||||
|
||||
@@ -22,6 +22,7 @@ type loginMsg struct {
|
||||
// Non-interactive prompts (login.go)
|
||||
OpenURL string
|
||||
WaitingAuth string
|
||||
AgentTimeoutHint string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
AuthorizedUser string
|
||||
@@ -58,6 +59,7 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout ≥ 600s;如不支持长 timeout,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询,**不要短 timeout 反复重试**——每次重启会作废上一轮的 device code,导致用户授权的链接失效。",
|
||||
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
@@ -93,6 +95,7 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is ≥ 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout** — each restart invalidates the previous device code, so any URL the user already authorized becomes useless.",
|
||||
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
@@ -122,5 +125,5 @@ func getLoginMsg(lang string) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs"}
|
||||
return []string{"base", "contact", "docs", "markdown"}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -94,3 +95,21 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentTimeoutHint_CarriesKeyInfo guards the contract that the synchronous
|
||||
// auth-login output tells AI agents two things: (a) this command blocks for
|
||||
// minutes — set a long runner timeout, and (b) the alternative is the
|
||||
// --no-wait + --device-code split-flow. Without (a) AI sets a 10s timeout and
|
||||
// kills the process before the user can authorize; without (b) the AI has no
|
||||
// recovery path and just retries with the same short timeout, invalidating
|
||||
// each new device code in turn.
|
||||
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
hint := getLoginMsg(lang).AgentTimeoutHint
|
||||
for _, want := range []string{"--no-wait", "--device-code"} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("%s AgentTimeoutHint missing %q: %s", lang, want, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
if loginSucceeded {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return nil
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
detail := map[string]interface{}{
|
||||
"requested": issue.Summary.Requested,
|
||||
@@ -200,9 +200,6 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
if issue.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
|
||||
}
|
||||
if loginSucceeded {
|
||||
return nil
|
||||
}
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/zalando/go-keyring"
|
||||
@@ -371,8 +372,12 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
@@ -410,8 +415,12 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
@@ -616,8 +625,12 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
|
||||
@@ -62,11 +62,32 @@ func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.
|
||||
Short: "Bind Agent config to a workspace (source / app-id / force)",
|
||||
Long: `Bind an AI Agent's (OpenClaw / Hermes) Feishu credentials to a lark-cli workspace.
|
||||
|
||||
For AI agents: pass --source and --app-id to bind non-interactively.
|
||||
Credentials are synced once; subsequent calls in the Agent's process
|
||||
context automatically use the bound workspace.`,
|
||||
Example: ` lark-cli config bind --source openclaw --app-id <id>
|
||||
lark-cli config bind --source hermes`,
|
||||
--source is auto-detected from env (OPENCLAW_HOME / HERMES_HOME); pass it only to override.
|
||||
|
||||
For AI agents — DO NOT bind without user confirmation. Binding may
|
||||
overwrite an existing one and locks in an identity policy. Ask the user:
|
||||
|
||||
--identity bot-only bot only (safer default; no impersonation;
|
||||
cannot access user resources like personal
|
||||
calendar / mail / drive)
|
||||
--identity user-default user identity allowed (impersonates the user;
|
||||
needed for personal-resource access)
|
||||
|
||||
Default to bot-only if the user is unsure. Only run the command after
|
||||
the user confirms both intent and identity preset.
|
||||
|
||||
If lark-cli is already bound and the user only wants to change identity
|
||||
policy on the SAME app, use 'config strict-mode' — that's the policy
|
||||
switch and does not require re-bind. Use 'config bind' only when the
|
||||
underlying app itself changes.
|
||||
|
||||
Interactive terminal use: run with no flags to enter the TUI form.`,
|
||||
Example: ` # AI flow: confirm intent + identity with user FIRST, then run:
|
||||
lark-cli config bind --source openclaw --app-id <id> --identity bot-only
|
||||
lark-cli config bind --source hermes --identity user-default
|
||||
|
||||
# Interactive (terminal user) — TUI prompts for everything:
|
||||
lark-cli config bind`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
if runF != nil {
|
||||
@@ -125,6 +146,7 @@ func configBindRun(opts *BindOptions) error {
|
||||
return err
|
||||
}
|
||||
applyPreferences(appConfig, opts)
|
||||
noticeUserDefaultRisk(opts)
|
||||
|
||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
||||
}
|
||||
@@ -308,6 +330,23 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
|
||||
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
|
||||
}
|
||||
|
||||
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
|
||||
// flag-mode bind that lands on user-default. The bot-only → user-default
|
||||
// escalation is already covered by warnIdentityEscalation (errors out before
|
||||
// applyPreferences runs), and the TUI flow shows IdentityUserDefaultDesc
|
||||
// during identity selection — so this fires specifically for the case those
|
||||
// two miss: a fresh flag-mode bind that goes directly to user-default with
|
||||
// no previous bot lock to escalate from. Without this, AI agents finish such
|
||||
// a bind with only a "配置成功" message and never relay to the user that the
|
||||
// AI can now act under their identity.
|
||||
func noticeUserDefaultRisk(opts *BindOptions) {
|
||||
if opts.IsTUI || opts.Identity != "user-default" {
|
||||
return
|
||||
}
|
||||
msg := getBindMsg(opts.Lang)
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
|
||||
}
|
||||
|
||||
// applyPreferences expands the chosen identity preset into the underlying
|
||||
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
|
||||
// profile's intent survives later changes to global strict-mode settings.
|
||||
|
||||
@@ -377,16 +377,28 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unbound workspace")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
// Should be a structured ConfigError suggesting config bind, not config init.
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
|
||||
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
|
||||
}
|
||||
// Hint must point at config bind --help (NOT a ready-to-run bind command):
|
||||
// AI must read the help and confirm identity preset with the user first.
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hint must point at `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config init") {
|
||||
t.Errorf("agent hint must not mention config init; got %q", cfgErr.Hint)
|
||||
}
|
||||
// Should suggest config bind, not config init
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "openclaw context detected but lark-cli not bound to openclaw workspace",
|
||||
Hint: "run: lark-cli config bind --source openclaw",
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helper function tests (dotenv, brand, path resolution) ──
|
||||
|
||||
62
cmd/config/bind_warning_test.go
Normal file
62
cmd/config/bind_warning_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// runHermesBindWithIdentity boots a Hermes-shaped fake env, runs `config bind`
|
||||
// with the given identity preset in flag (non-TUI) mode, and returns captured
|
||||
// stderr. Hermes is the simplest source to fake (single .env file).
|
||||
func runHermesBindWithIdentity(t *testing.T, identity string) string {
|
||||
t.Helper()
|
||||
saveWorkspace(t)
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
hermesHome := t.TempDir()
|
||||
t.Setenv("HERMES_HOME", hermesHome)
|
||||
envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"
|
||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte(envContent), 0600); err != nil {
|
||||
t.Fatalf("write .env: %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{
|
||||
Factory: f,
|
||||
Source: "hermes",
|
||||
Identity: identity,
|
||||
Lang: "zh",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bind failed: %v", err)
|
||||
}
|
||||
return stderr.String()
|
||||
}
|
||||
|
||||
// TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation covers the
|
||||
// gap that previously slipped through: a fresh flag-mode bind landing on
|
||||
// user-default. warnIdentityEscalation requires a previous bot lock to fire,
|
||||
// and IdentityUserDefaultDesc only renders in TUI selection — so without
|
||||
// noticeUserDefaultRisk the user/AI never see the impersonation risk on a
|
||||
// first-time user-default bind.
|
||||
func TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation(t *testing.T) {
|
||||
out := runHermesBindWithIdentity(t, "user-default")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("user-default bind must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigBindRun_BotOnlyIdentity_NoImpersonationWarning(t *testing.T) {
|
||||
out := runHermesBindWithIdentity(t, "bot-only")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot-only bind must NOT warn about impersonation; got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -90,15 +90,15 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "not configured" {
|
||||
t.Fatalf("detail = %#v, want config/not configured", exitErr.Detail)
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,14 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
@@ -33,6 +34,13 @@ type ConfigInitOptions struct {
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||
|
||||
// ForceInit overrides the agent-workspace guard. Without it, running
|
||||
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
|
||||
// at config bind — which is what AI agents almost always want. Manual
|
||||
// users with a legitimate need for a separate app can pass --force-init
|
||||
// to bypass.
|
||||
ForceInit bool
|
||||
}
|
||||
|
||||
// NewCmdConfigInit creates the config init subcommand.
|
||||
@@ -46,10 +54,18 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.`,
|
||||
verification URL from its output.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
|
||||
refuses by default — use 'lark-cli config bind' to bind to the Agent's
|
||||
existing app instead of creating a parallel one. Pass --force-init only
|
||||
if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
if err := guardAgentWorkspace(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -63,10 +79,33 @@ verification URL from its output.`,
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
|
||||
// Hermes Agent context, because the Agent has already provisioned an app
|
||||
// and 'config bind' is the right tool for hooking lark-cli into it.
|
||||
// Running init here would create a parallel app under the agent's workspace
|
||||
// dir, breaking the binding the user actually wants. --force-init lets a
|
||||
// human user override when they really do want a separate app.
|
||||
func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
if opts.ForceInit {
|
||||
return nil
|
||||
}
|
||||
ws := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
if ws.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
return &core.ConfigError{
|
||||
Code: 2,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
|
||||
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
|
||||
return o.New || o.AppID != "" || o.AppSecretStdin
|
||||
@@ -269,7 +308,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
|
||||
// Mode 3: Create new app directly (--new)
|
||||
if opts.New {
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, core.BrandFeishu, msg)
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
69
cmd/config/init_guard_test.go
Normal file
69
cmd/config/init_guard_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
t.Setenv("OPENCLAW_CLI", "")
|
||||
t.Setenv("HERMES_HOME", "")
|
||||
|
||||
if err := guardAgentWorkspace(&ConfigInitOptions{}); err != nil {
|
||||
t.Errorf("local workspace should allow init, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", t.TempDir())
|
||||
|
||||
err := guardAgentWorkspace(&ConfigInitOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in OpenClaw context, got nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "--force-init") {
|
||||
t.Errorf("hint must mention --force-init escape hatch; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
|
||||
t.Setenv("HERMES_HOME", t.TempDir())
|
||||
|
||||
err := guardAgentWorkspace(&ConfigInitOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in Hermes context, got nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "hermes" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_ForceInitOverride(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", t.TempDir())
|
||||
|
||||
// --force-init must let the user proceed even inside an Agent context.
|
||||
if err := guardAgentWorkspace(&ConfigInitOptions{ForceInit: true}); err != nil {
|
||||
t.Errorf("--force-init should bypass the guard, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,12 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return notConfiguredError()
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
@@ -75,18 +75,3 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
// notConfiguredError returns the "not configured" error with a hint that
|
||||
// points the user to the right next step: config init for the default local
|
||||
// workspace, config bind for an Agent workspace that has not been bound yet.
|
||||
func notConfiguredError() error {
|
||||
ws := core.CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return output.ErrWithHint(output.ExitValidation, "config",
|
||||
"not configured",
|
||||
"run: lark-cli config init")
|
||||
}
|
||||
return output.ErrWithHint(output.ExitValidation, ws.Display(),
|
||||
fmt.Sprintf("%s context detected but lark-cli not bound to %s workspace", ws.Display(), ws.Display()),
|
||||
fmt.Sprintf("run: lark-cli config bind --source %s", ws.Display()))
|
||||
}
|
||||
|
||||
@@ -21,44 +21,44 @@ func NewCmdConfigStrictMode(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "strict-mode [bot|user|off]",
|
||||
Short: "View or set strict mode (identity restriction policy)",
|
||||
Long: `View or set strict mode (identity restriction policy).
|
||||
Long: `View or set strict mode — the identity restriction policy.
|
||||
|
||||
Without arguments, shows the current strict mode status and its source.
|
||||
Pass "bot", "user", or "off" to set strict mode.
|
||||
Use --global to set at the global level.
|
||||
Use --reset to clear the profile-level setting (inherit global).
|
||||
bot only bot identity allowed (user commands hidden)
|
||||
user only user identity allowed (bot commands hidden)
|
||||
off no restriction (default)
|
||||
|
||||
Modes:
|
||||
bot — only bot identity is allowed, user commands are hidden
|
||||
user — only user identity is allowed, bot commands are hidden
|
||||
off — no restriction (default)
|
||||
No args: show current mode. Switching does NOT require re-bind.
|
||||
|
||||
WARNING: Strict mode is a security policy set by the administrator.
|
||||
AI agents are strictly prohibited from modifying this setting.`,
|
||||
For AI agents: this is a security policy. DO NOT switch without
|
||||
explicit user confirmation — never run on your own initiative.`,
|
||||
Example: ` lark-cli config strict-mode # show current
|
||||
lark-cli config strict-mode user # switch (after user confirms)
|
||||
lark-cli config strict-mode bot --global # set globally
|
||||
lark-cli config strict-mode --reset # clear profile override`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
if reset {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return resetStrictMode(f, multi, app, global, args)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return showStrictMode(cmd.Context(), f, multi, app)
|
||||
}
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if !global && app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return setStrictMode(f, multi, app, args[0], global)
|
||||
},
|
||||
@@ -106,6 +106,24 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
|
||||
}
|
||||
|
||||
// Capture the old mode at the SAME scope being changed, so we can warn
|
||||
// only when the policy actually expands user-identity at that scope.
|
||||
// --global → compare raw multi.StrictMode (profiles with explicit
|
||||
// overrides are unaffected; their warning comes from the existing
|
||||
// "profile %q has strict-mode explicitly set" notice below).
|
||||
// profile → compare effective mode (override > global > default), so
|
||||
// a profile flipping from inherited bot to explicit off still warns.
|
||||
// The previous version always used the profile's effective mode, which
|
||||
// false-positived (--global change while current profile has an explicit
|
||||
// override) and false-negatived (--global broadening that doesn't affect
|
||||
// the current profile but does affect other inheriting profiles).
|
||||
var oldMode core.StrictMode
|
||||
if global {
|
||||
oldMode = multi.StrictMode
|
||||
} else {
|
||||
oldMode, _ = resolveStrictModeStatus(multi, app)
|
||||
}
|
||||
|
||||
if global {
|
||||
multi.StrictMode = mode
|
||||
for _, a := range multi.Apps {
|
||||
@@ -119,7 +137,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
}
|
||||
} else {
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
app.StrictMode = &mode
|
||||
}
|
||||
@@ -127,6 +145,11 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "⚠️ "+strictModeRelaxLang(app).IdentityEscalationMessage)
|
||||
}
|
||||
|
||||
scope := "profile"
|
||||
if global {
|
||||
scope = "global"
|
||||
@@ -135,6 +158,16 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictModeRelaxLang picks the bind-message bundle whose language matches the
|
||||
// active profile's Lang setting. Falls back to bindMsgZh when no profile is
|
||||
// available (global mutation with no current app).
|
||||
func strictModeRelaxLang(app *core.AppConfig) *bindMsg {
|
||||
if app != nil {
|
||||
return getBindMsg(app.Lang)
|
||||
}
|
||||
return getBindMsg("")
|
||||
}
|
||||
|
||||
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) {
|
||||
if app != nil && app.StrictMode != nil {
|
||||
return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName())
|
||||
|
||||
140
cmd/config/strict_mode_warning_test.go
Normal file
140
cmd/config/strict_mode_warning_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// runStrictMode is a small helper that runs `config strict-mode <args...>` and
|
||||
// returns the captured stderr — that's where success-path messages and the
|
||||
// new user-identity warning land.
|
||||
func runStrictMode(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("strict-mode %v failed: %v", args, err)
|
||||
}
|
||||
return stderr.String()
|
||||
}
|
||||
|
||||
// expandsUserIdentity covers the only two transitions where AI gains the
|
||||
// ability to act under the user's identity, and asserts the warning fires.
|
||||
// Reuses bind_messages.go's IdentityEscalationMessage as the canonical text
|
||||
// so all three call sites (bind upgrade, fresh user-default bind, strict-mode
|
||||
// relax) stay phrased identically.
|
||||
func TestStrictMode_BotToUser_WarnsAboutIdentityRisk(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot")
|
||||
|
||||
out := runStrictMode(t, "user")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot→user transition must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_BotToOff_WarnsAboutIdentityRisk(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot")
|
||||
|
||||
out := runStrictMode(t, "off")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot→off transition must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// narrowingDoesNotWarn covers the cases that revoke or keep user-identity
|
||||
// scope — those should stay quiet, otherwise AI will spam users with risk
|
||||
// text on every restrictive change.
|
||||
func TestStrictMode_UserToBot_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "user")
|
||||
|
||||
out := runStrictMode(t, "bot")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("user→bot is a narrowing change; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_OffToBot_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
// Default starts at off; explicitly set bot — narrowing.
|
||||
out := runStrictMode(t, "bot")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("off→bot is a narrowing change; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_OffToUser_NoWarning(t *testing.T) {
|
||||
// Off already permits user-identity, so off→user is not a NEW grant
|
||||
// even though it forces user identity. Don't warn.
|
||||
setupStrictModeTestConfig(t)
|
||||
out := runStrictMode(t, "user")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("off→user does not newly permit user identity; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- --global path: comparison must use multi.StrictMode, not profile's
|
||||
// effective mode. The previous (buggy) version used resolveStrictModeStatus
|
||||
// here too, leading to both false positives (current profile has explicit
|
||||
// override unaffected by --global → still warned) and false negatives
|
||||
// (current profile has explicit override that masks an actual bot → off
|
||||
// global broadening for OTHER inheriting profiles → didn't warn).
|
||||
|
||||
func TestStrictMode_GlobalBotToUser_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global")
|
||||
|
||||
out := runStrictMode(t, "user", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→user must warn (broadens user-identity for inheriting profiles); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_GlobalBotToOff_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global")
|
||||
|
||||
out := runStrictMode(t, "off", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→off must warn (newly permits user identity in inheriting profiles); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// FalsePositive: current profile has explicit "bot" override, global goes
|
||||
// off → user. The current profile is unaffected (still bot via override),
|
||||
// and off→user at the global level is not a new grant either. Must not warn.
|
||||
func TestStrictMode_GlobalOffToUser_WithProfileBotOverride_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot") // profile-level explicit bot
|
||||
runStrictMode(t, "off", "--global") // global = off
|
||||
|
||||
out := runStrictMode(t, "user", "--global")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global off→user with profile-bot-override must not warn (profile unaffected, global wasn't bot); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// FalseNegative: global = bot, current profile has explicit "off" override.
|
||||
// Running --global off broadens OTHER inheriting profiles (bot → off). The
|
||||
// current profile doesn't change effective mode, but the policy still expanded
|
||||
// user-identity, so warning must fire. The pre-fix logic compared via the
|
||||
// current profile's effective mode and missed this case.
|
||||
func TestStrictMode_GlobalBotToOff_WithProfileOffOverride_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global") // global = bot
|
||||
runStrictMode(t, "off") // profile-level explicit off (already shows the warning at profile scope)
|
||||
|
||||
out := runStrictMode(t, "off", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→off must warn even when current profile has explicit off (other profiles inherit and newly permit user identity); got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -83,7 +84,20 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
// ── 1. Config file ──
|
||||
_, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
checks = append(checks, fail("config_file", err.Error(), "run: lark-cli config init"))
|
||||
// For "config not present" cases, prefer the workspace-aware
|
||||
// NotConfiguredError message + hint (e.g. "openclaw context
|
||||
// detected but lark-cli is not bound to it" → bind --help) over
|
||||
// the OS-level "open ... no such file or directory".
|
||||
// For other errors (parse, perms), keep the raw error so the
|
||||
// underlying problem is still visible.
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
}
|
||||
checks = append(checks, fail("config_file", msg, hint))
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
checks = append(checks, pass("config_file", "config.json found"))
|
||||
|
||||
@@ -32,9 +32,9 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(name)
|
||||
|
||||
@@ -32,9 +32,9 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(oldName)
|
||||
|
||||
@@ -31,9 +31,9 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle "-" for toggle-back
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -48,10 +49,9 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
||||
Hidden: true,
|
||||
DisableFlagParsing: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, only %s identity is allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode, mode.ForcedIdentity())
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
|
||||
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,11 +343,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
|
||||
"auth", "login", "--json", "--scope", "im:message.send_as_user",
|
||||
})
|
||||
|
||||
// auth login is user-only, so it gets pruned in strict-mode-bot and the
|
||||
// stub error fires (not login.go's inline check, which is shadowed by
|
||||
// pruning).
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -364,7 +368,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -401,7 +406,8 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "user", only user-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -419,7 +425,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -436,7 +443,8 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "user", only user-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -454,7 +462,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -167,10 +167,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
|
||||
}
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
@@ -354,6 +354,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
// stdin is an io.Reader consumed at most once. Only one of --params/--data
|
||||
// may use "-" (stdin); the conflict check below prevents silent data loss.
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
// Validate --file mutual exclusions.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil {
|
||||
@@ -362,7 +363,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
@@ -431,7 +432,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
// Parse --data as form fields.
|
||||
var dataFields any
|
||||
if opts.Data != "" {
|
||||
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
@@ -447,7 +448,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
fileIO,
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -456,7 +457,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
request.Data = fd
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
@@ -142,8 +142,12 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
if interval < 1 {
|
||||
interval = 5
|
||||
}
|
||||
|
||||
const maxPollInterval = 60
|
||||
const maxPollAttempts = 200
|
||||
const maxPollAttempts = 600
|
||||
|
||||
endpoints := ResolveOAuthEndpoints(brand)
|
||||
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
@@ -5,10 +5,12 @@ package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +19,12 @@ import (
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
// TestResolveOAuthEndpoints_Feishu validates endpoints for the Feishu brand.
|
||||
func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
|
||||
ep := ResolveOAuthEndpoints(core.BrandFeishu)
|
||||
@@ -172,3 +180,33 @@ func TestLogAuthError_RecordsStructuredEntry(t *testing.T) {
|
||||
t.Fatalf("expected truncated cmdline in log, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var requests atomic.Int32
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requests.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
|
||||
if result == nil {
|
||||
t.Fatal("PollDeviceToken() returned nil result")
|
||||
}
|
||||
if result.Message != "Polling was cancelled" {
|
||||
t.Fatalf("PollDeviceToken() message = %q, want polling cancellation", result.Message)
|
||||
}
|
||||
if got := requests.Load(); got != 0 {
|
||||
t.Fatalf("PollDeviceToken() sent %d requests before context cancellation, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +160,9 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
|
||||
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
|
||||
mode := f.ResolveStrictMode(ctx)
|
||||
if mode.IsActive() && !mode.AllowsIdentity(as) {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, only %s identity is allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode, mode.ForcedIdentity())
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
|
||||
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,19 +7,20 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// ParseOptionalBody parses --data JSON for methods that accept a request body.
|
||||
// Supports stdin (-) and single-quote stripping via ResolveInput.
|
||||
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
|
||||
// Returns (nil, nil) if the method has no body or data is empty.
|
||||
func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) {
|
||||
func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.FileIO) (interface{}, error) {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
resolved, err := ResolveInput(data, stdin)
|
||||
resolved, err := ResolveInput(data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("--data: %s", err)
|
||||
}
|
||||
@@ -34,9 +35,9 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, e
|
||||
}
|
||||
|
||||
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
|
||||
// Supports stdin (-) and single-quote stripping via ResolveInput.
|
||||
func ParseJSONMap(input, label string, stdin io.Reader) (map[string]any, error) {
|
||||
resolved, err := ResolveInput(input, stdin)
|
||||
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
|
||||
func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) {
|
||||
resolved, err := ResolveInput(input, stdin, fileIO)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("%s: %s", label, err)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestParseOptionalBody(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseOptionalBody(tt.method, tt.data, nil)
|
||||
got, err := ParseOptionalBody(tt.method, tt.data, nil, nil)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
@@ -53,7 +53,7 @@ func TestParseJSONMap(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseJSONMap(tt.input, tt.label, nil)
|
||||
got, err := ParseJSONMap(tt.input, tt.label, nil, nil)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
||||
@@ -4,19 +4,27 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// ResolveInput resolves special input conventions for a raw flag value:
|
||||
// - "-" → read all bytes from stdin
|
||||
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
|
||||
// - other → return as-is
|
||||
// - "-" → read all bytes from stdin
|
||||
// - "@<path>" → read all bytes from the file at <path> via fileIO
|
||||
// - "@@..." → strip leading @ (escape for a literal @-prefixed value)
|
||||
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
|
||||
// - other → return as-is
|
||||
//
|
||||
// This allows callers to bypass shell quoting issues (especially on Windows
|
||||
// PowerShell) by piping JSON via stdin instead of command-line arguments.
|
||||
func ResolveInput(raw string, stdin io.Reader) (string, error) {
|
||||
// fileIO is required for "@<path>" inputs and goes through path validation
|
||||
// (SafeInputPath); pass nil only when callers know "@" inputs are not possible.
|
||||
//
|
||||
// Allows callers to bypass shell quoting issues (especially Windows PowerShell 5)
|
||||
// by reading JSON from a file (@path) or piping via stdin (-).
|
||||
func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
@@ -37,6 +45,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// escape: @@... → literal @... (no file read)
|
||||
if strings.HasPrefix(raw, "@@") {
|
||||
return raw[1:], nil
|
||||
}
|
||||
|
||||
// file: @path
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("file path cannot be empty after @")
|
||||
}
|
||||
data, err := ReadInputFile(fileIO, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := strings.TrimSpace(string(data))
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("file %q is empty", path)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// strip surrounding single quotes (Windows cmd.exe passes them literally)
|
||||
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
|
||||
raw = raw[1 : len(raw)-1]
|
||||
@@ -44,3 +74,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) {
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
|
||||
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
|
||||
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
|
||||
if fileIO == nil {
|
||||
return nil, fmt.Errorf("file input is not available in this context")
|
||||
}
|
||||
f, err := fileIO.Open(path)
|
||||
if err != nil {
|
||||
return nil, wrapInputFileError(path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, wrapInputFileError(path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func wrapInputFileError(path string, err error) error {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return fmt.Errorf("invalid file path %q: %w", path, err)
|
||||
}
|
||||
return fmt.Errorf("cannot read file %q: %w", path, err)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
func TestResolveInput_Stdin(t *testing.T) {
|
||||
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`))
|
||||
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -20,7 +23,7 @@ func TestResolveInput_Stdin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
|
||||
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"))
|
||||
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -30,7 +33,7 @@ func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveInput_Stdin_Empty(t *testing.T) {
|
||||
_, err := ResolveInput("-", strings.NewReader(""))
|
||||
_, err := ResolveInput("-", strings.NewReader(""), nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty stdin")
|
||||
}
|
||||
@@ -44,21 +47,21 @@ type errorReader struct{}
|
||||
func (errorReader) Read([]byte) (int, error) { return 0, fmt.Errorf("disk failure") }
|
||||
|
||||
func TestResolveInput_Stdin_ReadError(t *testing.T) {
|
||||
_, err := ResolveInput("-", errorReader{})
|
||||
_, err := ResolveInput("-", errorReader{}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "failed to read stdin") {
|
||||
t.Errorf("expected read error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_Stdin_WhitespaceOnly(t *testing.T) {
|
||||
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "))
|
||||
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "), nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for whitespace-only stdin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_Stdin_Nil(t *testing.T) {
|
||||
_, err := ResolveInput("-", nil)
|
||||
_, err := ResolveInput("-", nil, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nil stdin")
|
||||
}
|
||||
@@ -77,7 +80,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ResolveInput(tt.in, nil)
|
||||
got, err := ResolveInput(tt.in, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -89,7 +92,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveInput_Empty(t *testing.T) {
|
||||
got, err := ResolveInput("", nil)
|
||||
got, err := ResolveInput("", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -99,7 +102,7 @@ func TestResolveInput_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveInput_PlainValue(t *testing.T) {
|
||||
got, err := ResolveInput(`{"already":"valid"}`, nil)
|
||||
got, err := ResolveInput(`{"already":"valid"}`, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -108,21 +111,103 @@ func TestResolveInput_PlainValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtPrefixPassedThrough(t *testing.T) {
|
||||
// Without @file support, @-prefixed values are passed as-is
|
||||
got, err := ResolveInput("@something", nil)
|
||||
func TestResolveInput_AtFile(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ResolveInput("@params.json", nil, fio)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "@something" {
|
||||
t.Errorf("got %q, want %q", got, "@something")
|
||||
if got != `{"folder_token":"abc123"}` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_TrimsWhitespace(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("p.json", []byte("\n {\"k\":\"v\"}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ResolveInput("@p.json", nil, fio)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != `{"k":"v"}` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_NotFound(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
_, err := ResolveInput("@missing.json", nil, fio)
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Errorf("expected read error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_PathValidation(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
// Absolute paths are rejected by SafeInputPath; the error must surface
|
||||
// as an invalid-path message, not a generic read failure.
|
||||
_, err := ResolveInput("@/etc/passwd", nil, fio)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid file path") {
|
||||
t.Errorf("expected path-validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_EmptyPath(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
_, err := ResolveInput("@", nil, fio)
|
||||
if err == nil || !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("expected empty-path error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_EmptyContent(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("empty.json", []byte(" \n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := ResolveInput("@empty.json", nil, fio)
|
||||
if err == nil || !strings.Contains(err.Error(), "is empty") {
|
||||
t.Errorf("expected empty-file error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_NoFileIO(t *testing.T) {
|
||||
// When fileIO is nil, @path must error rather than silently fall back.
|
||||
_, err := ResolveInput("@params.json", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "not available") {
|
||||
t.Errorf("expected unavailable error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_DoubleAtEscape(t *testing.T) {
|
||||
got, err := ResolveInput("@@literal", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "@literal" {
|
||||
t.Errorf("got %q, want %q", got, "@literal")
|
||||
}
|
||||
}
|
||||
|
||||
// Integration: ResolveInput flows through ParseJSONMap correctly.
|
||||
func TestParseJSONMap_WithStdin(t *testing.T) {
|
||||
stdin := strings.NewReader(`{"message_id":"om_xxx","user_id_type":"open_id"}`)
|
||||
got, err := ParseJSONMap("-", "--params", stdin)
|
||||
got, err := ParseJSONMap("-", "--params", stdin, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -131,8 +216,48 @@ func TestParseJSONMap_WithStdin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Integration: @file flows through ParseJSONMap correctly.
|
||||
func TestParseJSONMap_WithAtFile(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123","type":"folder"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ParseJSONMap("@params.json", "--params", nil, fio)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("got %d keys, want 2", len(got))
|
||||
}
|
||||
if got["folder_token"] != "abc123" {
|
||||
t.Errorf("got %v, want folder_token=abc123", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptionalBody_WithAtFile(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("data.json", []byte(`{"text":"hello"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ParseOptionalBody("POST", "@data.json", nil, fio)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", got)
|
||||
}
|
||||
if m["text"] != "hello" {
|
||||
t.Errorf("got %v, want text=hello", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
|
||||
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
|
||||
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -143,7 +268,7 @@ func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
|
||||
|
||||
func TestParseOptionalBody_WithStdin(t *testing.T) {
|
||||
stdin := strings.NewReader(`{"text":"hello"}`)
|
||||
got, err := ParseOptionalBody("POST", "-", stdin)
|
||||
got, err := ParseOptionalBody("POST", "-", stdin, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -176,7 +301,7 @@ func TestParseJSONMap_WindowsShellScenarios(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseJSONMap(tt.input, "--params", nil)
|
||||
got, err := ParseJSONMap(tt.input, "--params", nil, nil)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
||||
@@ -225,7 +225,7 @@ func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
|
||||
raw, err := LoadMultiAppConfig()
|
||||
if err != nil || raw == nil || len(raw.Apps) == 0 {
|
||||
return nil, &ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
return ResolveConfigFromMulti(raw, kc, profileOverride)
|
||||
}
|
||||
|
||||
120
internal/core/notconfigured.go
Normal file
120
internal/core/notconfigured.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// LoadOrNotConfigured wraps LoadMultiAppConfig with the standard "not yet
|
||||
// configured vs. couldn't read" disambiguation that every config-required
|
||||
// command should use:
|
||||
//
|
||||
// - file missing → workspace-aware NotConfiguredError (init / bind hint)
|
||||
// - parse error / permission error → real load failure with the original
|
||||
// cause preserved, so the user can actually fix the broken file
|
||||
//
|
||||
// Without this, every call site that did `if err != nil { return
|
||||
// NotConfiguredError() }` silently coerced corrupt-config into "run init",
|
||||
// which sent users in circles when their config.json was just malformed.
|
||||
func LoadOrNotConfigured() (*MultiAppConfig, error) {
|
||||
multi, err := LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
// Surface the real cause (parse error, permission denied, etc.)
|
||||
// so the user can fix the broken file. Wrapping as ConfigError
|
||||
// keeps it on the standard structured-envelope path at the root
|
||||
// command's error sink.
|
||||
return nil, &ConfigError{
|
||||
Code: 2,
|
||||
Type: "config",
|
||||
Message: fmt.Sprintf("failed to load config: %v", err),
|
||||
}
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
return multi, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// localInitHint is the canonical "you're in a regular terminal, run
|
||||
// init" guidance — shared by NotConfiguredError and NoActiveProfileError
|
||||
// so the same session can't show two different recommended commands.
|
||||
localInitHint = "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."
|
||||
|
||||
// agentBindHint is the canonical "you're in an Agent workspace, see
|
||||
// the binding workflow" guidance. Always points at --help (never a
|
||||
// ready-to-run bind command) so the AI reads the confirmation
|
||||
// discipline (identity preset, user opt-in) before acting.
|
||||
agentBindHint = "read `lark-cli config bind --help`, then ask the user to confirm intent and identity preset (bot-only or user-default); only after both are confirmed, run `lark-cli config bind`"
|
||||
)
|
||||
|
||||
// NotConfiguredError returns the canonical "not configured" error, with a
|
||||
// hint that depends on the active workspace:
|
||||
//
|
||||
// - WorkspaceLocal → suggest `config init --new` (creates a new app).
|
||||
// - WorkspaceOpenClaw / WorkspaceHermes → point at `config bind --help`
|
||||
// rather than a ready-to-run command, because binding is policy-laden:
|
||||
// the user must pick an identity preset (bot-only vs user-default),
|
||||
// and re-binding may overwrite an existing one. The help text walks
|
||||
// the AI through the confirmation flow.
|
||||
//
|
||||
// All "config not loaded yet" call sites should use this helper rather than
|
||||
// hand-rolling a hint, so AI agents always get a workspace-correct next step.
|
||||
func NotConfiguredError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Type: "config",
|
||||
Message: "not configured",
|
||||
Hint: localInitHint,
|
||||
}
|
||||
}
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("%s context detected but lark-cli is not bound to it", ws.Display()),
|
||||
Hint: agentBindHint,
|
||||
}
|
||||
}
|
||||
|
||||
// reconfigureHint returns the workspace-aware "fix it from scratch" hint
|
||||
// used by error paths that aren't full ConfigErrors (e.g. plain fmt.Errorf
|
||||
// strings from keychain / secret validation). Local → `config init`;
|
||||
// Agent → `config bind --help` so the AI reads the binding workflow and
|
||||
// confirms identity preset with the user before running the actual command.
|
||||
func reconfigureHint() string {
|
||||
if CurrentWorkspace().IsLocal() {
|
||||
return "please run `lark-cli config init` to reconfigure"
|
||||
}
|
||||
return agentBindHint
|
||||
}
|
||||
|
||||
// NoActiveProfileError mirrors NotConfiguredError for the related
|
||||
// "config exists but the requested profile cannot be resolved" case. In agent
|
||||
// workspaces a missing profile typically means the binding was wiped while
|
||||
// the workspace marker remained — re-binding is the correct fix, not init.
|
||||
func NoActiveProfileError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Type: "config",
|
||||
Message: "no active profile",
|
||||
Hint: localInitHint,
|
||||
}
|
||||
}
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("no active profile in %s workspace", ws.Display()),
|
||||
Hint: agentBindHint,
|
||||
}
|
||||
}
|
||||
181
internal/core/notconfigured_test.go
Normal file
181
internal/core/notconfigured_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// saveAndRestoreWorkspace ensures package-level currentWorkspace is reset
|
||||
// between subtests so cross-test pollution can't make assertions pass by
|
||||
// accident.
|
||||
func saveAndRestoreWorkspace(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := CurrentWorkspace()
|
||||
t.Cleanup(func() { SetCurrentWorkspace(prev) })
|
||||
}
|
||||
|
||||
func TestNotConfiguredError_Local(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Errorf("unexpected detail: %+v", cfgErr)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config init --new") {
|
||||
t.Errorf("local hint should suggest config init --new; got %q", cfgErr.Hint)
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config bind") {
|
||||
t.Errorf("local hint must not mention config bind; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotConfiguredError_OpenClaw(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceOpenClaw)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
// Hint must point at --help (read first, confirm with user, then bind),
|
||||
// NOT a directly-executable bind command — binding is policy-laden
|
||||
// (identity preset, may overwrite existing binding).
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config init") {
|
||||
t.Errorf("agent hint must NOT mention config init (would cause AI to create a new app); got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotConfiguredError_Hermes(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceHermes)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "hermes" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hermes hint must point to `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoActiveProfileError_Local(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
|
||||
err := NoActiveProfileError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Message != "no active profile" {
|
||||
t.Errorf("message = %q, want %q", cfgErr.Message, "no active profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoActiveProfileError_AgentSuggestsBind(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceOpenClaw)
|
||||
|
||||
err := NoActiveProfileError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconfigureHint_Local(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
|
||||
got := reconfigureHint()
|
||||
if !strings.Contains(got, "config init") {
|
||||
t.Errorf("local reconfigure hint must mention config init; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconfigureHint_Agent(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceHermes)
|
||||
|
||||
got := reconfigureHint()
|
||||
if !strings.Contains(got, "config bind --help") {
|
||||
t.Errorf("agent reconfigure hint must point to `config bind --help`; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrNotConfigured_FileMissing_ReturnsNotConfigured(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
_, err := LoadOrNotConfigured()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Message != "not configured" {
|
||||
t.Errorf("message = %q, want \"not configured\"", cfgErr.Message)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config init --new") {
|
||||
t.Errorf("missing-file in local must hint `config init --new`; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrNotConfigured_CorruptFile_PreservesCause is the regression guard
|
||||
// for the previous "every load error → not configured" coercion: a malformed
|
||||
// config.json must surface its real failure cause so the user can fix it,
|
||||
// not get sent in circles by an init/bind hint that wouldn't help here.
|
||||
func TestLoadOrNotConfigured_CorruptFile_PreservesCause(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Write garbage that will fail JSON parsing.
|
||||
if err := os.WriteFile(dir+"/config.json", []byte("{not valid json"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := LoadOrNotConfigured()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for corrupt config")
|
||||
}
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "failed to load config") {
|
||||
t.Errorf("corrupt-file message must say 'failed to load config'; got %q", cfgErr.Message)
|
||||
}
|
||||
// And it must NOT pretend the user just hasn't initialised yet.
|
||||
if cfgErr.Message == "not configured" {
|
||||
t.Errorf("corrupt-file must not be coerced to 'not configured'")
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config init") || strings.Contains(cfgErr.Hint, "config bind") {
|
||||
t.Errorf("corrupt-file hint must not redirect to init/bind; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
@@ -63,9 +63,8 @@ func ValidateSecretKeyMatch(appId string, secret SecretInput) error {
|
||||
expected := secretAccountKey(appId)
|
||||
if secret.Ref.ID != expected {
|
||||
return fmt.Errorf(
|
||||
"appSecret keychain key %q does not match appId %q (expected %q); "+
|
||||
"please run `lark-cli config init` to reconfigure",
|
||||
secret.Ref.ID, appId, expected,
|
||||
"appSecret keychain key %q does not match appId %q (expected %q); %s",
|
||||
secret.Ref.ID, appId, expected, reconfigureHint(),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -203,7 +203,7 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er
|
||||
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
||||
return acct, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no credential provider returned an account; run 'lark-cli config' to set up")
|
||||
return nil, core.NotConfiguredError()
|
||||
}
|
||||
|
||||
// enrichUserInfo resolves user identity when extension provides a UAT.
|
||||
|
||||
@@ -36,7 +36,7 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
|
||||
// Load config once — used for both credentials and strict mode.
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return nil, &core.ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
|
||||
return nil, core.NotConfiguredError()
|
||||
}
|
||||
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
|
||||
|
||||
@@ -25,10 +25,26 @@ type Stub struct {
|
||||
Headers http.Header // optional full response headers (takes precedence over ContentType)
|
||||
matched bool
|
||||
|
||||
// BodyFilter (optional): match only when the captured request body satisfies
|
||||
// this predicate. Used to disambiguate multiple stubs that share a URL.
|
||||
BodyFilter func([]byte) bool
|
||||
|
||||
// OnMatch (optional): runs synchronously after the stub matches but before
|
||||
// the response is composed. Used in tests to inject panics or count
|
||||
// in-flight goroutines.
|
||||
OnMatch func(req *http.Request)
|
||||
|
||||
// Reusable (optional): when true, the stub stays available for further
|
||||
// matches after the first hit. Each match appends to CapturedBodies.
|
||||
Reusable bool
|
||||
|
||||
// CapturedHeaders records the request headers of the matched request.
|
||||
// Populated after RoundTrip matches this stub.
|
||||
CapturedHeaders http.Header
|
||||
CapturedBody []byte
|
||||
// CapturedBodies records every captured request body when Reusable is set.
|
||||
// (CapturedBody continues to record the most recent capture for back-compat.)
|
||||
CapturedBodies [][]byte
|
||||
}
|
||||
|
||||
// Registry records stubs and implements http.RoundTripper.
|
||||
@@ -51,8 +67,43 @@ func (r *Registry) Register(s *Stub) {
|
||||
func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
urlStr := req.URL.String()
|
||||
|
||||
// Read body once up-front so BodyFilter can inspect it without consuming
|
||||
// the original reader; restore for downstream consumers afterwards.
|
||||
// http.RoundTripper requires us to close the original body.
|
||||
var capturedBody []byte
|
||||
if req.Body != nil {
|
||||
var err error
|
||||
capturedBody, err = io.ReadAll(req.Body)
|
||||
_ = req.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpmock: read request body: %w", err)
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(capturedBody))
|
||||
}
|
||||
|
||||
matched := r.match(req, urlStr, capturedBody)
|
||||
|
||||
if matched != nil {
|
||||
// Restore body again in case OnMatch wants to read it.
|
||||
req.Body = io.NopCloser(bytes.NewReader(capturedBody))
|
||||
if matched.OnMatch != nil {
|
||||
matched.OnMatch(req)
|
||||
}
|
||||
resp, err := stubResponse(matched)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL)
|
||||
}
|
||||
|
||||
// match selects the first stub whose Method/URL/BodyFilter all match the
|
||||
// request, mutates its capture state, and returns it. defer-Unlock guarantees
|
||||
// a panicking user-supplied BodyFilter cannot leak the mutex.
|
||||
func (r *Registry) match(req *http.Request, urlStr string, capturedBody []byte) *Stub {
|
||||
r.mu.Lock()
|
||||
var matched *Stub
|
||||
defer r.mu.Unlock()
|
||||
for _, s := range r.stubs {
|
||||
if s.matched {
|
||||
continue
|
||||
@@ -63,25 +114,18 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if s.URL != "" && !strings.Contains(urlStr, s.URL) {
|
||||
continue
|
||||
}
|
||||
s.matched = true
|
||||
if s.BodyFilter != nil && !s.BodyFilter(capturedBody) {
|
||||
continue
|
||||
}
|
||||
if !s.Reusable {
|
||||
s.matched = true
|
||||
}
|
||||
s.CapturedHeaders = req.Header.Clone()
|
||||
if req.Body != nil {
|
||||
s.CapturedBody, _ = io.ReadAll(req.Body)
|
||||
req.Body = io.NopCloser(bytes.NewReader(s.CapturedBody))
|
||||
}
|
||||
matched = s
|
||||
break
|
||||
s.CapturedBody = capturedBody
|
||||
s.CapturedBodies = append(s.CapturedBodies, capturedBody)
|
||||
return s
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
if matched != nil {
|
||||
resp, err := stubResponse(matched)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify asserts all stubs were matched.
|
||||
@@ -90,9 +134,14 @@ func (r *Registry) Verify(t testing.TB) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, s := range r.stubs {
|
||||
if !s.matched {
|
||||
t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL)
|
||||
if s.matched {
|
||||
continue
|
||||
}
|
||||
// Reusable stubs never set s.matched; treat any captured hit as a match.
|
||||
if s.Reusable && len(s.CapturedBodies) > 0 {
|
||||
continue
|
||||
}
|
||||
t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const (
|
||||
ExitOK = 0 // 成功
|
||||
ExitAPI = 1 // API / 通用错误(含 permission、not_found、conflict、rate_limit)
|
||||
ExitValidation = 2 // 参数校验失败
|
||||
ExitAuth = 3 // 认证失败(token 无效 / 过期)
|
||||
ExitAuth = 3 // 认证失败(token 无效 / 过期),或登录成功但请求 scopes 未全部授予
|
||||
ExitNetwork = 4 // 网络错误(连接超时、DNS 解析失败等)
|
||||
ExitInternal = 5 // 内部错误(不应发生)
|
||||
ExitContentSafety = 6 // content safety violation (block mode)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
var knownArrayFields = []string{
|
||||
"items", "files", "events", "rooms", "records", "nodes",
|
||||
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
|
||||
"users",
|
||||
}
|
||||
|
||||
// FindArrayField finds the primary array field in a response's data object.
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
"en": { "title": "Mail", "description": "Email, draft, folder, and contacts management" },
|
||||
"zh": { "title": "邮箱", "description": "查看和管理用户邮箱数据,包括邮件、草稿、文件夹和联系人" }
|
||||
},
|
||||
"markdown": {
|
||||
"en": { "title": "Markdown", "description": "Drive-native Markdown file create, fetch, and overwrite" },
|
||||
"zh": { "title": "Markdown", "description": "Drive 原生 Markdown 文件的创建、读取和覆盖更新" }
|
||||
},
|
||||
"minutes": {
|
||||
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
|
||||
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.21",
|
||||
"version": "1.0.24",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -10,15 +10,16 @@ const crypto = require("crypto");
|
||||
const VERSION = require("../package.json").version.replace(/-.*$/, "");
|
||||
const REPO = "larksuite/cli";
|
||||
const NAME = "lark-cli";
|
||||
const DEFAULT_MIRROR_HOST = "https://registry.npmmirror.com";
|
||||
// Allowlist gates the *initial* request URL only. curl --location follows
|
||||
// redirects (capped by --max-redirs 3) without re-checking the target host.
|
||||
// This is acceptable because checksum verification is the primary integrity
|
||||
// control; the allowlist is defense-in-depth to reject obviously wrong URLs.
|
||||
const ALLOWED_HOSTS = [
|
||||
const ALLOWED_HOSTS = new Set([
|
||||
"github.com",
|
||||
"objects.githubusercontent.com",
|
||||
"registry.npmmirror.com",
|
||||
];
|
||||
]);
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
darwin: "darwin",
|
||||
@@ -38,18 +39,77 @@ const isWindows = process.platform === "win32";
|
||||
const ext = isWindows ? ".zip" : ".tar.gz";
|
||||
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
|
||||
const GITHUB_URL = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
|
||||
const MIRROR_URL = `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`;
|
||||
|
||||
const binDir = path.join(__dirname, "..", "bin");
|
||||
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
|
||||
|
||||
// Build the ordered list of binary mirror URLs to try. Resolution rules:
|
||||
// 1. npm_config_registry — when the user has set a non-default
|
||||
// registry (npmmirror clone, corp Verdaccio,
|
||||
// Artifactory, …), include the derived path
|
||||
// first. Many of these proxies don't actually
|
||||
// host /-/binary/<pkg>/..., so we ALWAYS
|
||||
// append the public npmmirror as a final
|
||||
// fallback so the install does not regress
|
||||
// from the previous behavior of "GitHub →
|
||||
// npmmirror".
|
||||
// 2. registry.npmmirror.com — public China mirror, always tried last.
|
||||
// The default public npmjs registry is skipped in step 1 because it does not
|
||||
// host binaries under /-/binary/...
|
||||
//
|
||||
// Non-https / malformed npm_config_registry is silently ignored so npm users
|
||||
// with http-only internal registries don't have their installs broken.
|
||||
function resolveMirrorUrls(env, archive, version) {
|
||||
const binaryPath = `/-/binary/lark-cli/v${version}/${archive}`;
|
||||
const defaultUrl = joinUrl(DEFAULT_MIRROR_HOST, binaryPath);
|
||||
|
||||
const urls = [];
|
||||
const registry = (env.npm_config_registry || "").trim();
|
||||
if (registry && !isDefaultNpmjsRegistry(registry) && isValidDownloadBase(registry)) {
|
||||
const base = new URL(registry);
|
||||
urls.push(joinUrl(base.origin + base.pathname, binaryPath));
|
||||
}
|
||||
if (!urls.includes(defaultUrl)) urls.push(defaultUrl);
|
||||
return urls;
|
||||
}
|
||||
|
||||
function joinUrl(base, suffix) {
|
||||
return base.replace(/\/+$/, "") + suffix;
|
||||
}
|
||||
|
||||
function isValidDownloadBase(raw) {
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
return parsed.protocol === "https:" && !!parsed.hostname;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDefaultNpmjsRegistry(url) {
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
return hostname === "registry.npmjs.org";
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllowedHost(url) {
|
||||
const { hostname } = new URL(url);
|
||||
if (!ALLOWED_HOSTS.includes(hostname)) {
|
||||
if (!ALLOWED_HOSTS.has(hostname)) {
|
||||
throw new Error(`Download host not allowed: ${hostname}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the mirror URL chain and admit each host. Called from install() so
|
||||
// derived hosts only become trusted when actually needed.
|
||||
function getMirrorUrls(env) {
|
||||
const urls = resolveMirrorUrls(env, archiveName, VERSION);
|
||||
for (const u of urls) ALLOWED_HOSTS.add(new URL(u).hostname);
|
||||
return urls;
|
||||
}
|
||||
|
||||
function download(url, destPath) {
|
||||
assertAllowedHost(url);
|
||||
const args = [
|
||||
@@ -65,27 +125,69 @@ function download(url, destPath) {
|
||||
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
|
||||
}
|
||||
|
||||
function extractZipWindows(archivePath, destDir) {
|
||||
const psOpts = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"];
|
||||
const psStdio = ["ignore", "inherit", "inherit"];
|
||||
const psEnv = {
|
||||
...process.env,
|
||||
LARK_CLI_ARCHIVE: archivePath,
|
||||
LARK_CLI_DEST: destDir,
|
||||
};
|
||||
|
||||
try {
|
||||
const dotnet =
|
||||
"$ErrorActionPreference='Stop';" +
|
||||
"Add-Type -AssemblyName System.IO.Compression.FileSystem;" +
|
||||
"[System.IO.Compression.ZipFile]::ExtractToDirectory($env:LARK_CLI_ARCHIVE,$env:LARK_CLI_DEST)";
|
||||
execFileSync("powershell.exe", [...psOpts, dotnet], { stdio: psStdio, env: psEnv });
|
||||
} catch (primaryErr) {
|
||||
try {
|
||||
const cmdlet =
|
||||
"$ErrorActionPreference='Stop';" +
|
||||
"Expand-Archive -LiteralPath $env:LARK_CLI_ARCHIVE -DestinationPath $env:LARK_CLI_DEST -Force";
|
||||
execFileSync("powershell.exe", [...psOpts, cmdlet], { stdio: psStdio, env: psEnv });
|
||||
} catch (fallbackErr) {
|
||||
throw new Error(
|
||||
`Failed to extract ${archivePath}. ` +
|
||||
`.NET ZipFile attempt: ${primaryErr.message}. ` +
|
||||
`Expand-Archive fallback: ${fallbackErr.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function install() {
|
||||
const mirrorUrls = getMirrorUrls(process.env);
|
||||
const downloadUrls = [GITHUB_URL, ...mirrorUrls];
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
|
||||
const archivePath = path.join(tmpDir, archiveName);
|
||||
|
||||
try {
|
||||
try {
|
||||
download(GITHUB_URL, archivePath);
|
||||
} catch (err) {
|
||||
download(MIRROR_URL, archivePath);
|
||||
// Walk the chain in order; stop at the first success. Default chain:
|
||||
// GitHub → derived(npm_config_registry)? → npmmirror. The npmmirror
|
||||
// tail preserves the pre-PR safety net when a corporate proxy doesn't
|
||||
// actually host /-/binary/<pkg>/...
|
||||
let lastErr;
|
||||
let downloaded = false;
|
||||
for (const url of downloadUrls) {
|
||||
try {
|
||||
download(url, archivePath);
|
||||
downloaded = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
}
|
||||
if (!downloaded) throw lastErr;
|
||||
|
||||
const expectedHash = getExpectedChecksum(archiveName);
|
||||
verifyChecksum(archivePath, expectedHash);
|
||||
|
||||
if (isWindows) {
|
||||
execFileSync("powershell", [
|
||||
"-Command",
|
||||
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`,
|
||||
], { stdio: "ignore" });
|
||||
extractZipWindows(archivePath, tmpDir);
|
||||
} else {
|
||||
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
|
||||
stdio: "ignore",
|
||||
@@ -176,12 +278,15 @@ if (require.main === module) {
|
||||
} catch (err) {
|
||||
console.error(`Failed to install ${NAME}:`, err.message);
|
||||
console.error(
|
||||
`\nIf you are behind a firewall or in a restricted network, try setting a proxy:\n` +
|
||||
`\nIf you are behind a firewall or in a restricted network, try one of:\n` +
|
||||
` # 1. Use a proxy:\n` +
|
||||
` export https_proxy=http://your-proxy:port\n` +
|
||||
` npm install -g @larksuite/cli`
|
||||
` npm install -g @larksuite/cli\n\n` +
|
||||
` # 2. Point to a corporate npm mirror that proxies /-/binary/lark-cli/...:\n` +
|
||||
` npm install -g @larksuite/cli --registry=https://your-corp-mirror/`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost };
|
||||
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls };
|
||||
|
||||
@@ -9,7 +9,7 @@ const os = require("os");
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
const { getExpectedChecksum, verifyChecksum, assertAllowedHost } = require("./install.js");
|
||||
const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls } = require("./install.js");
|
||||
|
||||
describe("getExpectedChecksum", () => {
|
||||
function makeTmpChecksums(content) {
|
||||
@@ -164,3 +164,117 @@ describe("assertAllowedHost", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMirrorUrls", () => {
|
||||
const ARCHIVE = "lark-cli-1.0.0-linux-amd64.tar.gz";
|
||||
const VERSION = "1.0.0";
|
||||
const DEFAULT = "https://registry.npmmirror.com/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz";
|
||||
|
||||
it("returns only the default mirror when no env vars are set", () => {
|
||||
assert.deepEqual(resolveMirrorUrls({}, ARCHIVE, VERSION), [DEFAULT]);
|
||||
});
|
||||
|
||||
it("does not derive from the default npmjs registry", () => {
|
||||
// The public npmjs registry doesn't host /-/binary/<pkg>/..., so we must
|
||||
// not point downloads at it.
|
||||
assert.deepEqual(
|
||||
resolveMirrorUrls(
|
||||
{ npm_config_registry: "https://registry.npmjs.org/" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
),
|
||||
[DEFAULT]
|
||||
);
|
||||
});
|
||||
|
||||
it("derives from non-default npm_config_registry AND keeps default as fallback", () => {
|
||||
// Critical: a corporate npm proxy (Verdaccio/Artifactory/Nexus) often
|
||||
// doesn't actually serve /-/binary/<pkg>/..., so we must keep the
|
||||
// public npmmirror as a final fallback or installs regress vs. the
|
||||
// pre-PR "GitHub → npmmirror" behavior.
|
||||
assert.deepEqual(
|
||||
resolveMirrorUrls(
|
||||
{ npm_config_registry: "https://corp.example.com/repository/npm-public/" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
),
|
||||
[
|
||||
"https://corp.example.com/repository/npm-public/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz",
|
||||
DEFAULT,
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("derived URL appears before the default in the chain", () => {
|
||||
const urls = resolveMirrorUrls(
|
||||
{ npm_config_registry: "https://corp.example.com/" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
);
|
||||
assert.equal(urls.length, 2);
|
||||
assert.match(urls[0], /^https:\/\/corp\.example\.com\//);
|
||||
assert.equal(urls[1], DEFAULT);
|
||||
});
|
||||
|
||||
it("does not duplicate the default if the registry already points at it", () => {
|
||||
// If npm_config_registry happens to be the public npmmirror, we still
|
||||
// want a single entry, not two identical ones.
|
||||
assert.deepEqual(
|
||||
resolveMirrorUrls(
|
||||
{ npm_config_registry: "https://registry.npmmirror.com/" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
),
|
||||
[DEFAULT]
|
||||
);
|
||||
});
|
||||
|
||||
it("strips trailing slashes from the registry URL", () => {
|
||||
assert.deepEqual(
|
||||
resolveMirrorUrls(
|
||||
{ npm_config_registry: "https://corp.example.com///" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
),
|
||||
[
|
||||
"https://corp.example.com/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz",
|
||||
DEFAULT,
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores empty/whitespace npm_config_registry", () => {
|
||||
assert.deepEqual(
|
||||
resolveMirrorUrls(
|
||||
{ npm_config_registry: "" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
),
|
||||
[DEFAULT]
|
||||
);
|
||||
});
|
||||
|
||||
it("silently falls back when npm_config_registry is non-https", () => {
|
||||
// Implicit feature: don't break installs whose npm registry is plain http.
|
||||
// The user didn't opt into binary-mirror behavior, so just use the default.
|
||||
assert.deepEqual(
|
||||
resolveMirrorUrls(
|
||||
{ npm_config_registry: "http://internal.example.com/" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
),
|
||||
[DEFAULT]
|
||||
);
|
||||
});
|
||||
|
||||
it("silently falls back when npm_config_registry is file://", () => {
|
||||
assert.deepEqual(
|
||||
resolveMirrorUrls(
|
||||
{ npm_config_registry: "file:///tmp" },
|
||||
ARCHIVE,
|
||||
VERSION
|
||||
),
|
||||
[DEFAULT]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,11 +112,43 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
nil,
|
||||
map[string]int{"max-version": 11, "page-size": 30},
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
|
||||
assertDryRunContains(t, dryRunRecordUpsert(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
|
||||
assertDryRunContains(t, dryRunRecordHistoryList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/record_history", "max_version=11", "page_size=30", "record_id=rec_1", "table_id=tbl_1")
|
||||
|
||||
getSingleRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"record-id": {"rec_1"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getSingleRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_1"]`)
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, getSingleRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_1"]`)
|
||||
|
||||
getSingleFieldsRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"record-id": {"rec_1"}, "field-id": {"Name", "Age"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getSingleFieldsRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_1"]`, `"select_fields":["Name","Age"]`)
|
||||
|
||||
getBatchRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"record-id": {"rec_2", "rec_1"}, "field-id": {"Name", "Age"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getBatchRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_2","rec_1"]`, `"select_fields":["Name","Age"]`)
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, getBatchRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_2","rec_1"]`)
|
||||
|
||||
getJSONRT := newBaseTestRuntime(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"record_id_list":["rec_3"],"select_fields":["Status"]}`},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_3"]`, `"select_fields":["Status"]`)
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_3"]`)
|
||||
|
||||
uploadAttachmentRT := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
|
||||
@@ -827,28 +827,6 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Run("list", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"records": map[string]interface{}{
|
||||
"schema": []interface{}{"Name", "Age"},
|
||||
"record_ids": []interface{}{"rec_1"},
|
||||
"rows": []interface{}{[]interface{}{"Alice", 18}},
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"records"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list with fields and view", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -864,7 +842,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
||||
@@ -887,7 +865,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C"}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"A,B"`) || !strings.Contains(got, `"rec_json_fields"`) {
|
||||
@@ -895,7 +873,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list new shape", func(t *testing.T) {
|
||||
t.Run("list json format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
@@ -904,13 +882,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"field_id_list": []interface{}{"fld_name", "fld_age"},
|
||||
"record_id_list": []interface{}{"rec_2"},
|
||||
"data": []interface{}{[]interface{}{"Bob", 20}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Bob"`) || !strings.Contains(got, `"rec_2"`) {
|
||||
@@ -918,6 +897,47 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list markdown format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=Name&field_id=Age&limit=2&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"field_id_list": []interface{}{"fld_name", "fld_age"},
|
||||
"record_id_list": []interface{}{"rec_1", "rec_2"},
|
||||
"data": []interface{}{
|
||||
[]interface{}{"Alice", 18},
|
||||
[]interface{}{"Bob", 20},
|
||||
},
|
||||
"has_more": false,
|
||||
"query_context": map[string]interface{}{
|
||||
"record_scope": "all_records",
|
||||
"field_scope": "selected_fields",
|
||||
},
|
||||
"ignored_fields": []interface{}{"Formula"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "2", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"`_record_id` is metadata for record operations, not a table field.",
|
||||
"| _record_id | Name | Age |",
|
||||
"| rec_1 | Alice | 18 |",
|
||||
"Meta: count=2; has_more=false; record_scope=all_records; field_scope=selected_fields; ignored_fields=1",
|
||||
"Ignored fields: Formula",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
searchStub := &httpmock.Stub{
|
||||
@@ -948,6 +968,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`,
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
@@ -968,6 +989,53 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search markdown format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Title", "Owner"},
|
||||
"field_id_list": []interface{}{"fld_title", "fld_owner"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Created by AI", "Alice"}},
|
||||
"has_more": false,
|
||||
"query_context": map[string]interface{}{
|
||||
"record_scope": "view_filtered_records",
|
||||
"field_scope": "selected_fields",
|
||||
"search_scope": "fld_title(Title)",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(
|
||||
t,
|
||||
BaseRecordSearch,
|
||||
[]string{
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"keyword":"Created","search_fields":["Title"],"select_fields":["Title","Owner"],"limit":2}`,
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"| _record_id | Title | Owner |",
|
||||
"| rec_1 | Created by AI | Alice |",
|
||||
"Meta: count=1; has_more=false; record_scope=view_filtered_records; field_scope=selected_fields; search_scope=fld_title(Title)",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list legacy fields flag rejected", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
|
||||
@@ -986,42 +1054,322 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1",
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"records": map[string]interface{}{
|
||||
"schema": []interface{}{"Name", "Age"},
|
||||
"record_ids": []interface{}{"rec_1"},
|
||||
"rows": []interface{}{[]interface{}{"Alice", 18}},
|
||||
}},
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_ids"`) || !strings.Contains(got, `"Name"`) || strings.Contains(got, `"raw"`) {
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"`_record_id` is metadata for record operations, not a table field.",
|
||||
"- `_record_id`: rec_1",
|
||||
"- `Name`: Alice",
|
||||
"- `Age`: 18",
|
||||
"Meta: count=1",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_1"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get json format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Alice"`) || !strings.Contains(got, `"Age"`) || strings.Contains(got, `"record":`) || strings.Contains(got, `"raw"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_1"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get passthrough fallback", func(t *testing.T) {
|
||||
t.Run("get with selected fields", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_2",
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"unexpected": "shape", "record_id": "rec_2"},
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2"}, factory, stdout); err != nil {
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"unexpected": "shape"`) || strings.Contains(got, `"raw"`) || strings.Contains(got, `"record":`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Name"`) || !strings.Contains(got, `"Age"`) || !strings.Contains(got, `"Alice"`) || strings.Contains(got, `"record":`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch with repeated record-id flags", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_2", "rec_1"},
|
||||
"fields": []interface{}{"Name"},
|
||||
"data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"| _record_id | Name |",
|
||||
"| rec_2 | Bob |",
|
||||
"| rec_1 | Alice |",
|
||||
"Meta: count=2",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch json format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_2", "rec_1"},
|
||||
"fields": []interface{}{"Name"},
|
||||
"data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) || !strings.Contains(got, `"Bob"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch with json selector", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_3"},
|
||||
"fields": []interface{}{"Name"},
|
||||
"data": []interface{}{[]interface{}{"Carol"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"],"select_fields":["Name"]}`, "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Carol"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_3"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get single returns batch_get error when batch_get is unavailable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{"code": 404, "msg": "not found"},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected batch_get error")
|
||||
}
|
||||
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
|
||||
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get single missing record renders not found markdown", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_missing"},
|
||||
"fields": []interface{}{"Name"},
|
||||
"data": []interface{}{[]interface{}{nil}},
|
||||
"has_more": false,
|
||||
"record_not_found": []interface{}{"rec_missing"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_missing"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Record not found.",
|
||||
"- `_record_id`: rec_missing",
|
||||
"Meta: count=1; has_more=false; record_not_found=1",
|
||||
"Missing records: rec_missing",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "- `Name`:") {
|
||||
t.Fatalf("missing record output should not render business fields:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch returns batch_get error when batch_get is unavailable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{"code": 404, "msg": "not found"},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected batch_get error")
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch with json record ids and field flags", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_4"},
|
||||
"fields": []interface{}{"Status"},
|
||||
"data": []interface{}{[]interface{}{"Done"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_4"]}`, "--field-id", "Status", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"Done"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_4"]`) || !strings.Contains(body, `"select_fields":["Status"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects duplicate record ids", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate record id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects duplicate field ids", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Name"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate field id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects mixed record-id and json", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects mixed field-id and json select_fields", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_2"],"select_fields":["Name"]}`, "--field-id", "Age"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "select_fields") || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects empty selection", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "provide at least one --record-id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
@@ -1121,17 +1469,121 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
||||
})
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"record_id": "rec_1"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || strings.Contains(got, `"deleted": true`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
|
||||
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete returns batch_delete error when unavailable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{"code": 404, "msg": "not found"},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected batch_delete error")
|
||||
}
|
||||
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
|
||||
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete batch with repeated record-id flags", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_2", "rec_1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete batch with json selector", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_3"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"]}`, "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_3"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_3"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete requires yes for batch", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete rejects duplicate record ids", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1", "--yes"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate record id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete rejects mixed record-id and json", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`, "--yes"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment", func(t *testing.T) {
|
||||
@@ -1674,7 +2126,7 @@ func TestBaseRecordExecuteListWithViewPagination(t *testing.T) {
|
||||
}, "total": 201},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1"}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_last"`) || !strings.Contains(got, `"total": 201`) {
|
||||
|
||||
@@ -18,7 +18,7 @@ var BaseFormDelete = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
|
||||
baseTokenFlag(true),
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ var BaseFormGet = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
|
||||
baseTokenFlag(true),
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ var BaseFormQuestionsList = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
|
||||
baseTokenFlag(true),
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
},
|
||||
|
||||
@@ -210,6 +210,140 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantHelp []string
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "record list",
|
||||
shortcut: BaseRecordList,
|
||||
wantHelp: []string{
|
||||
"field ID or name to include; repeat to project only needed fields",
|
||||
"view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view",
|
||||
"pagination size, range 1-200",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
|
||||
"Default output is markdown",
|
||||
"Use --field-id repeatedly to keep output small",
|
||||
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
wantHelp: []string{
|
||||
"requires keyword/search_fields",
|
||||
"optional select_fields/view_id/offset/limit",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
`lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json`,
|
||||
`"select_fields":["Name","Status"]`,
|
||||
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"]`,
|
||||
"search_fields length 1-20",
|
||||
"limit range 1-200 defaults to 10",
|
||||
"view_id scopes search to records in that view",
|
||||
"Default output is markdown",
|
||||
"only for keyword search",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record get",
|
||||
shortcut: BaseRecordGet,
|
||||
wantHelp: []string{
|
||||
"record ID (repeatable)",
|
||||
"field ID or name to project; repeat to keep only needed columns",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
|
||||
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status",
|
||||
"Default output is markdown",
|
||||
"projection boundary",
|
||||
"record_id is already known",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
for _, want := range tt.wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
assertHelpOrder(t, help, "base token", "output format")
|
||||
assertHelpOrder(t, help, "table ID", "output format")
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
wantHelp := []string{
|
||||
"complete field definition JSON object; update uses full PUT semantics, not a patch",
|
||||
}
|
||||
for _, want := range wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
|
||||
"full field-definition PUT semantics",
|
||||
"Read the current field first with +field-get",
|
||||
"Type conversion is allowlist-based",
|
||||
"web UI",
|
||||
"Formula and lookup updates require reading the corresponding guide first.",
|
||||
"lark-base skill's field-update guide",
|
||||
}
|
||||
for _, want := range wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertHelpOrder(t *testing.T, help string, before string, after string) {
|
||||
t.Helper()
|
||||
beforeIndex := strings.Index(help, before)
|
||||
afterIndex := strings.Index(help, after)
|
||||
if beforeIndex < 0 || afterIndex < 0 {
|
||||
return
|
||||
}
|
||||
if beforeIndex > afterIndex {
|
||||
t.Fatalf("flag help order mismatch: %q should appear before %q:\n%s", before, after, help)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
@@ -259,8 +393,8 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
if BaseRecordSearch.Validate == nil {
|
||||
t.Fatalf("record search validate should reject invalid JSON before dry-run")
|
||||
}
|
||||
if BaseRecordGet.Validate != nil {
|
||||
t.Fatalf("record get validate should be nil")
|
||||
if BaseRecordGet.Validate == nil {
|
||||
t.Fatalf("record get validate should reject invalid record selection before dry-run")
|
||||
}
|
||||
if BaseRecordUpsert.Validate == nil {
|
||||
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")
|
||||
|
||||
@@ -20,12 +20,16 @@ var BaseFieldUpdate = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
fieldRefFlag(true),
|
||||
{Name: "json", Desc: "field property JSON object", Required: true},
|
||||
{Name: "json", Desc: "complete field definition JSON object; update uses full PUT semantics, not a patch", Required: true},
|
||||
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"name":"Status","type":"text"}'`,
|
||||
"Agent hint: use the lark-base skill's field-update guide for usage and limits.",
|
||||
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
|
||||
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
|
||||
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
|
||||
"Formula and lookup updates require reading the corresponding guide first.",
|
||||
"Agent hint: use the lark-base skill's field-update guide for JSON shape, type-conversion rules, and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFieldUpdate(runtime)
|
||||
|
||||
10
shortcuts/base/help.go
Normal file
10
shortcuts/base/help.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func preserveFlagOrder(cmd *cobra.Command) {
|
||||
cmd.Flags().SortFlags = false
|
||||
}
|
||||
@@ -195,6 +195,62 @@ func TestRecordAndChunkHelpers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSelectionHelpers(t *testing.T) {
|
||||
recordIDs, err := normalizeRecordIDs([]string{" rec_1 ", "rec_2"})
|
||||
if err != nil || !reflect.DeepEqual(recordIDs, []string{"rec_1", "rec_2"}) {
|
||||
t.Fatalf("recordIDs=%v err=%v", recordIDs, err)
|
||||
}
|
||||
if _, err := normalizeRecordIDs([]interface{}{}); err == nil || !strings.Contains(err.Error(), "provide at least one --record-id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordIDs([]interface{}{"rec_1", "rec_1"}); err == nil || !strings.Contains(err.Error(), "duplicate record id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordIDs([]interface{}{" "}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordIDs([]interface{}{1}); err == nil || !strings.Contains(err.Error(), "must be a string") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
tooManyRecords := make([]string, maxRecordSelectionCount+1)
|
||||
if _, err := normalizeRecordIDs(tooManyRecords); err == nil || !strings.Contains(err.Error(), "exceeds maximum limit") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
fields, err := normalizeRecordGetSelectFields([]interface{}{" Name ", "fld_status"})
|
||||
if err != nil || !reflect.DeepEqual(fields, []string{"Name", "fld_status"}) {
|
||||
t.Fatalf("fields=%v err=%v", fields, err)
|
||||
}
|
||||
if fields, err := normalizeRecordGetSelectFields(nil); err != nil || fields != nil {
|
||||
t.Fatalf("fields=%v err=%v", fields, err)
|
||||
}
|
||||
if _, err := normalizeRecordGetSelectFields([]interface{}{"Name", "Name"}); err == nil || !strings.Contains(err.Error(), "duplicate field id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordGetSelectFields([]interface{}{""}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordGetSelectFields([]interface{}{1}); err == nil || !strings.Contains(err.Error(), "must be a string") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
tooManyFields := make([]string, maxBatchGetSelectFieldCount+1)
|
||||
if _, err := normalizeRecordGetSelectFields(tooManyFields); err == nil || !strings.Contains(err.Error(), "exceeds maximum limit") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}})
|
||||
if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) {
|
||||
t.Fatalf("fields=%v err=%v", fields, err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestResolveHelpers(t *testing.T) {
|
||||
fields := []map[string]interface{}{{"id": "fld_1", "name": "Name", "type": "text"}, {"field_id": "fld_2", "field_name": "Age", "type": "number", "multiple": true}}
|
||||
tables := []map[string]interface{}{{"id": "tbl_1", "name": "Orders"}}
|
||||
|
||||
@@ -12,12 +12,20 @@ import (
|
||||
var BaseRecordDelete = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-delete",
|
||||
Description: "Delete a record by ID",
|
||||
Description: "Delete one or more records by ID",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"base:record:delete"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), recordRefFlag(true)},
|
||||
DryRun: dryRunRecordDelete,
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
||||
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordSelection(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordDelete,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordDelete(runtime)
|
||||
},
|
||||
|
||||
@@ -7,21 +7,42 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var BaseRecordGet = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-get",
|
||||
Description: "Get a record by ID",
|
||||
Description: "Get one or more records by ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:record:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
||||
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
|
||||
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordSelection(runtime)
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
|
||||
"Example with projection: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status",
|
||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||
"Use --field-id as a projection boundary to avoid loading large cell values into context when they are not needed.",
|
||||
"Use +record-get when record_id is already known; otherwise use +record-search or +record-list.",
|
||||
"Agent hint: follow the lark-base record read SOP for record read routing.",
|
||||
},
|
||||
DryRun: dryRunRecordGet,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
preserveFlagOrder(cmd)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordGet(runtime)
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var BaseRecordList = common.Shortcut{
|
||||
@@ -19,13 +20,46 @@ var BaseRecordList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "field-id", Type: "string_array", Desc: "field ID or field name to include (repeatable)"},
|
||||
{Name: "view-id", Desc: "view ID"},
|
||||
recordListFieldRefFlag(),
|
||||
recordListViewRefFlag(),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
|
||||
"Example with projection: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
|
||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||
"Use --field-id repeatedly to keep output small and aligned with the task.",
|
||||
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view.",
|
||||
"For structured filters, sorting, Top/Bottom N, and link fields, follow the lark-base record read SOP.",
|
||||
},
|
||||
DryRun: dryRunRecordList,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
preserveFlagOrder(cmd)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordList(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func recordListFieldRefFlag() common.Flag {
|
||||
flag := fieldRefFlag(false)
|
||||
flag.Type = "string_array"
|
||||
flag.Desc = "field ID or name to include; repeat to project only needed fields"
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordListViewRefFlag() common.Flag {
|
||||
flag := viewRefFlag(false)
|
||||
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordReadFormatFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "format",
|
||||
Default: "markdown",
|
||||
Desc: "output format: markdown (default) | json",
|
||||
}
|
||||
}
|
||||
|
||||
337
shortcuts/base/record_markdown.go
Normal file
337
shortcuts/base/record_markdown.go
Normal file
@@ -0,0 +1,337 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const maxRecordMarkdownIgnoredFields = 20
|
||||
|
||||
func validateRecordReadFormat(runtime *common.RuntimeContext) error {
|
||||
switch runtime.Str("format") {
|
||||
case "", "json", "markdown":
|
||||
return nil
|
||||
default:
|
||||
return output.ErrValidation("--format must be json or markdown")
|
||||
}
|
||||
}
|
||||
|
||||
func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
|
||||
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordMarkdown)
|
||||
}
|
||||
|
||||
func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[string]interface{}, renderer func(map[string]interface{}) (string, error)) error {
|
||||
if runtime.JqExpr != "" {
|
||||
if !runtime.Changed("format") {
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
return output.ErrValidation("--jq and --format markdown are mutually exclusive")
|
||||
}
|
||||
rendered, err := renderer(data)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: record markdown render failed, falling back to json: %v\n", err)
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
|
||||
}
|
||||
fmt.Fprint(runtime.IO().Out, rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
|
||||
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown)
|
||||
}
|
||||
|
||||
func renderRecordGetMarkdown(data map[string]interface{}) (string, error) {
|
||||
fields := stringSliceValue(data["fields"])
|
||||
recordIDs := stringSliceValue(data["record_id_list"])
|
||||
rows, ok := data["data"].([]interface{})
|
||||
if len(fields) == 0 || !ok {
|
||||
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||
}
|
||||
if len(recordIDs) == 1 && len(rows) == 1 {
|
||||
rowItems, _ := rows[0].([]interface{})
|
||||
if recordMarkedNotFound(data["record_not_found"], recordIDs[0]) {
|
||||
return renderMissingSingleRecordMarkdown(recordIDs[0], data), nil
|
||||
}
|
||||
return renderSingleRecordMarkdown(recordIDs[0], fields, rowItems, data), nil
|
||||
}
|
||||
return renderRecordMarkdown(data)
|
||||
}
|
||||
|
||||
func renderRecordMarkdown(data map[string]interface{}) (string, error) {
|
||||
fields := stringSliceValue(data["fields"])
|
||||
recordIDs := stringSliceValue(data["record_id_list"])
|
||||
rows, ok := data["data"].([]interface{})
|
||||
if len(fields) == 0 || !ok {
|
||||
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("`_record_id` is metadata for record operations, not a table field.\n\n")
|
||||
|
||||
columns := append([]string{"_record_id"}, fields...)
|
||||
writeMarkdownRow(&b, columns)
|
||||
writeMarkdownSeparator(&b, len(columns))
|
||||
for i, rowValue := range rows {
|
||||
rowItems, _ := rowValue.([]interface{})
|
||||
cells := make([]string, 0, len(columns))
|
||||
if i < len(recordIDs) {
|
||||
cells = append(cells, recordIDs[i])
|
||||
} else {
|
||||
cells = append(cells, "")
|
||||
}
|
||||
for j := range fields {
|
||||
if j < len(rowItems) {
|
||||
cells = append(cells, markdownCell(rowItems[j]))
|
||||
} else {
|
||||
cells = append(cells, "")
|
||||
}
|
||||
}
|
||||
writeMarkdownRow(&b, cells)
|
||||
}
|
||||
|
||||
meta := recordMarkdownMeta(data)
|
||||
if len(meta) > 0 {
|
||||
b.WriteString("\nMeta: ")
|
||||
b.WriteString(strings.Join(meta, "; "))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if ignored := ignoredFieldsMarkdown(data["ignored_fields"]); ignored != "" {
|
||||
b.WriteString("Ignored fields: ")
|
||||
b.WriteString(ignored)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
|
||||
b.WriteString("Missing records: ")
|
||||
b.WriteString(missing)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func renderSingleRecordMarkdown(recordID string, fields []string, rowItems []interface{}, data map[string]interface{}) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("`_record_id` is metadata for record operations, not a table field.\n\n")
|
||||
b.WriteString("- `_record_id`: ")
|
||||
b.WriteString(markdownInlineValue(recordID))
|
||||
b.WriteByte('\n')
|
||||
for i, field := range fields {
|
||||
b.WriteString("- `")
|
||||
b.WriteString(field)
|
||||
b.WriteString("`: ")
|
||||
if i < len(rowItems) {
|
||||
b.WriteString(markdownInlineValue(rowItems[i]))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
meta := recordMarkdownMeta(data)
|
||||
if len(meta) > 0 {
|
||||
b.WriteString("\nMeta: ")
|
||||
b.WriteString(strings.Join(meta, "; "))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if ignored := ignoredFieldsMarkdown(data["ignored_fields"]); ignored != "" {
|
||||
b.WriteString("Ignored fields: ")
|
||||
b.WriteString(ignored)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
|
||||
b.WriteString("Missing records: ")
|
||||
b.WriteString(missing)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderMissingSingleRecordMarkdown(recordID string, data map[string]interface{}) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Record not found.\n\n")
|
||||
b.WriteString("- `_record_id`: ")
|
||||
b.WriteString(markdownInlineValue(recordID))
|
||||
b.WriteByte('\n')
|
||||
meta := recordMarkdownMeta(data)
|
||||
if len(meta) > 0 {
|
||||
b.WriteString("\nMeta: ")
|
||||
b.WriteString(strings.Join(meta, "; "))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
|
||||
b.WriteString("Missing records: ")
|
||||
b.WriteString(missing)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func recordMarkdownMeta(data map[string]interface{}) []string {
|
||||
meta := []string{fmt.Sprintf("count=%d", ignoredFieldsCount(data["record_id_list"]))}
|
||||
if hasMore, ok := data["has_more"]; ok {
|
||||
meta = append(meta, "has_more="+markdownInlineValue(hasMore))
|
||||
}
|
||||
if queryContext, ok := data["query_context"].(map[string]interface{}); ok {
|
||||
for _, key := range []string{"record_scope", "field_scope", "search_scope"} {
|
||||
if value, ok := queryContext[key]; ok {
|
||||
meta = append(meta, key+"="+markdownInlineValue(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
if ignoredCount := ignoredFieldsCount(data["ignored_fields"]); ignoredCount > 0 {
|
||||
meta = append(meta, fmt.Sprintf("ignored_fields=%d", ignoredCount))
|
||||
}
|
||||
if missingCount := ignoredFieldsCount(data["record_not_found"]); missingCount > 0 {
|
||||
meta = append(meta, fmt.Sprintf("record_not_found=%d", missingCount))
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func ignoredFieldsCount(value interface{}) int {
|
||||
switch v := value.(type) {
|
||||
case []interface{}:
|
||||
return len(v)
|
||||
case []string:
|
||||
return len(v)
|
||||
case nil:
|
||||
return 0
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func ignoredFieldsMarkdown(value interface{}) string {
|
||||
items := markdownListItems(value)
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
total := len(items)
|
||||
if len(items) > maxRecordMarkdownIgnoredFields {
|
||||
items = items[:maxRecordMarkdownIgnoredFields]
|
||||
items = append(items, fmt.Sprintf("...(%d total)", total))
|
||||
}
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
|
||||
func recordNotFoundMarkdown(value interface{}) string {
|
||||
return strings.Join(markdownListItems(value), ", ")
|
||||
}
|
||||
|
||||
func recordMarkedNotFound(value interface{}, recordID string) bool {
|
||||
for _, item := range markdownListItems(value) {
|
||||
if item == recordID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func markdownListItems(value interface{}) []string {
|
||||
switch v := value.(type) {
|
||||
case []interface{}:
|
||||
items := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
items = append(items, markdownInlineValue(item))
|
||||
}
|
||||
return items
|
||||
case []string:
|
||||
items := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
items = append(items, markdownInlineValue(item))
|
||||
}
|
||||
return items
|
||||
case nil:
|
||||
return nil
|
||||
default:
|
||||
return []string{markdownInlineValue(v)}
|
||||
}
|
||||
}
|
||||
|
||||
func writeMarkdownRow(b *strings.Builder, cells []string) {
|
||||
b.WriteString("| ")
|
||||
for i, cell := range cells {
|
||||
if i > 0 {
|
||||
b.WriteString(" | ")
|
||||
}
|
||||
b.WriteString(markdownTableText(cell))
|
||||
}
|
||||
b.WriteString(" |\n")
|
||||
}
|
||||
|
||||
func writeMarkdownSeparator(b *strings.Builder, columns int) {
|
||||
b.WriteString("| ")
|
||||
for i := 0; i < columns; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(" | ")
|
||||
}
|
||||
b.WriteString("---")
|
||||
}
|
||||
b.WriteString(" |\n")
|
||||
}
|
||||
|
||||
func markdownCell(value interface{}) string {
|
||||
return markdownInlineValue(value)
|
||||
}
|
||||
|
||||
func markdownInlineValue(value interface{}) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
case json.Number:
|
||||
return v.String()
|
||||
case bool:
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case float64:
|
||||
return fmt.Sprintf("%v", v)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", v)
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
|
||||
func markdownTableText(value string) string {
|
||||
value = strings.ReplaceAll(value, "\\", "\\\\")
|
||||
value = strings.ReplaceAll(value, "|", "\\|")
|
||||
value = strings.ReplaceAll(value, "\r\n", "<br>")
|
||||
value = strings.ReplaceAll(value, "\n", "<br>")
|
||||
return value
|
||||
}
|
||||
|
||||
func stringSliceValue(value interface{}) []string {
|
||||
switch v := value.(type) {
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return append([]string(nil), v...)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
298
shortcuts/base/record_markdown_test.go
Normal file
298
shortcuts/base/record_markdown_test.go
Normal file
@@ -0,0 +1,298 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type recordMarkdownCSTestProvider struct {
|
||||
alert *extcs.Alert
|
||||
}
|
||||
|
||||
func (p *recordMarkdownCSTestProvider) Name() string { return "test" }
|
||||
func (p *recordMarkdownCSTestProvider) Scan(_ context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.alert, nil
|
||||
}
|
||||
|
||||
func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeContext {
|
||||
parentCmd := &cobra.Command{Use: "lark-cli"}
|
||||
baseCmd := &cobra.Command{Use: "base"}
|
||||
cmd := &cobra.Command{Use: "+record-list"}
|
||||
cmd.Flags().String("format", "markdown", "")
|
||||
parentCmd.AddCommand(baseCmd)
|
||||
baseCmd.AddCommand(cmd)
|
||||
return &common.RuntimeContext{
|
||||
Config: &core.CliConfig{Brand: core.BrandFeishu},
|
||||
Cmd: cmd,
|
||||
Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordMarkdownEmptyResult(t *testing.T) {
|
||||
got, err := renderRecordMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{},
|
||||
"data": []interface{}{},
|
||||
"has_more": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"| _record_id | Name | Age |",
|
||||
"Meta: count=0; has_more=false",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordMarkdownEscapesTableCells(t *testing.T) {
|
||||
got, err := renderRecordMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name|Label", "Note"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"A|B", "line1\nline2"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"| _record_id | Name\\|Label | Note |",
|
||||
"| rec_1 | A\\|B | line1<br>line2 |",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordGetMarkdownSingleRecordUsesKVLayout(t *testing.T) {
|
||||
got, err := renderRecordGetMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name|Label", "Note"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"A|B", "line1\nline2"}},
|
||||
"has_more": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"- `_record_id`: rec_1",
|
||||
"- `Name|Label`: A|B",
|
||||
"- `Note`: line1\nline2",
|
||||
"Meta: count=1; has_more=false",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordGetMarkdownSingleMissingRecordUsesNotFoundLayout(t *testing.T) {
|
||||
got, err := renderRecordGetMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Note"},
|
||||
"record_id_list": []interface{}{"rec_missing"},
|
||||
"data": []interface{}{[]interface{}{nil, nil}},
|
||||
"record_not_found": []interface{}{"rec_missing"},
|
||||
"has_more": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Record not found.",
|
||||
"- `_record_id`: rec_missing",
|
||||
"Meta: count=1; has_more=false; record_not_found=1",
|
||||
"Missing records: rec_missing",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "- `Name`:") {
|
||||
t.Fatalf("missing record layout should not render business fields:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordMarkdownIncludesMissingRecords(t *testing.T) {
|
||||
got, err := renderRecordMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1", "rec_missing"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{nil}},
|
||||
"record_not_found": []interface{}{"rec_missing"},
|
||||
"has_more": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Meta: count=2; has_more=false; record_not_found=1",
|
||||
"Missing records: rec_missing",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordMarkdownTruncatesIgnoredFields(t *testing.T) {
|
||||
ignored := make([]interface{}, maxRecordMarkdownIgnoredFields+2)
|
||||
for i := range ignored {
|
||||
ignored[i] = fmt.Sprintf("Field%d", i+1)
|
||||
}
|
||||
got, err := renderRecordMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
"ignored_fields": ignored,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(got, fmt.Sprintf("ignored_fields=%d", len(ignored))) ||
|
||||
!strings.Contains(got, fmt.Sprintf("...(%d total)", len(ignored))) ||
|
||||
strings.Contains(got, "Field22") {
|
||||
t.Fatalf("ignored field truncation mismatch:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownContentSafetyWarnKeepsStdoutClean(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&recordMarkdownCSTestProvider{
|
||||
alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}},
|
||||
})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "| rec_1 | Alice |") || strings.Contains(got, "content safety") {
|
||||
t.Fatalf("stdout should contain only markdown data, got:\n%s", got)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: content safety alert") {
|
||||
t.Fatalf("stderr missing content safety warning:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownContentSafetyBlockDoesNotWriteStdout(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
extcs.Register(&recordMarkdownCSTestProvider{
|
||||
alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}},
|
||||
})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitContentSafety {
|
||||
t.Fatalf("err=%v, want content safety exit error", err)
|
||||
}
|
||||
if stdout.Len() > 0 {
|
||||
t.Fatalf("block mode should not write stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
t.Fatalf("block mode should not write warning to stderr, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownFallsBackToJSONWhenRenderFails(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"schema": []interface{}{"Name"},
|
||||
"rows": []interface{}{[]interface{}{"Alice"}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "markdown render failed") {
|
||||
t.Fatalf("stdout should not contain fallback warning:\n%s", stdout.String())
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("stdout should be JSON fallback, got err=%v stdout=%s", err, stdout.String())
|
||||
}
|
||||
if !env.OK || !strings.Contains(stdout.String(), `"records"`) {
|
||||
t.Fatalf("stdout missing JSON fallback data:\n%s", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: record markdown render failed, falling back to json") {
|
||||
t.Fatalf("stderr missing fallback warning:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownDefaultFormatAllowsJqJSONFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
runtime := newRecordMarkdownTestRuntime(stdout, stderr)
|
||||
runtime.JqExpr = ".data.record_id_list[0]"
|
||||
err := outputRecordMarkdown(runtime, map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "rec_1" {
|
||||
t.Fatalf("stdout jq fallback mismatch: %q", got)
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
t.Fatalf("stderr should be empty, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownExplicitFormatRejectsJq(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
runtime := newRecordMarkdownTestRuntime(stdout, stderr)
|
||||
runtime.JqExpr = ".data"
|
||||
if err := runtime.Cmd.Flags().Set("format", "markdown"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
err := outputRecordMarkdown(runtime, map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "--jq and --format markdown are mutually exclusive") {
|
||||
t.Fatalf("err=%v, want jq markdown conflict", err)
|
||||
}
|
||||
if stdout.Len() > 0 {
|
||||
t.Fatalf("stdout should be empty, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,194 @@ import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const maxRecordSelectionCount = 200
|
||||
const maxBatchGetSelectFieldCount = 100
|
||||
|
||||
type recordSelection struct {
|
||||
recordIDs []string
|
||||
selectFields []string
|
||||
fromJSON bool
|
||||
}
|
||||
|
||||
type stringListNormalizeOptions struct {
|
||||
typeError string
|
||||
emptyError string
|
||||
itemName string
|
||||
duplicateName string
|
||||
limitName string
|
||||
max int
|
||||
allowNil bool
|
||||
allowEmpty bool
|
||||
}
|
||||
|
||||
func validateRecordSelection(runtime *common.RuntimeContext) error {
|
||||
_, err := resolveRecordSelection(runtime)
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
|
||||
recordIDs := runtime.StrArray("record-id")
|
||||
fieldIDs := runtime.StrArray("field-id")
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if len(recordIDs) > 0 && jsonRaw != "" {
|
||||
return recordSelection{}, common.FlagErrorf("--record-id and --json are mutually exclusive")
|
||||
}
|
||||
if jsonRaw != "" {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, jsonRaw, "json")
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
recordIDListValue, ok := body["record_id_list"]
|
||||
if !ok {
|
||||
return recordSelection{}, common.FlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
recordIDItems, ok := recordIDListValue.([]interface{})
|
||||
if !ok {
|
||||
return recordSelection{}, common.FlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
normalized, err := normalizeRecordIDs(recordIDItems)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
return recordSelection{
|
||||
recordIDs: normalized,
|
||||
selectFields: selectFields,
|
||||
fromJSON: true,
|
||||
}, nil
|
||||
}
|
||||
normalized, err := normalizeRecordIDs(recordIDs)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
return recordSelection{
|
||||
recordIDs: normalized,
|
||||
selectFields: selectFields,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeRecordIDs(values interface{}) ([]string, error) {
|
||||
return normalizeStringList(values, stringListNormalizeOptions{
|
||||
typeError: "record selection must be a string array",
|
||||
emptyError: `provide at least one --record-id, or use --json with "record_id_list"`,
|
||||
itemName: "record selection item",
|
||||
duplicateName: "record id",
|
||||
limitName: "record selection",
|
||||
max: maxRecordSelectionCount,
|
||||
})
|
||||
}
|
||||
|
||||
func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
|
||||
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body == nil {
|
||||
return fromFlags, nil
|
||||
}
|
||||
rawJSONFields, ok := body["select_fields"]
|
||||
if !ok {
|
||||
return fromFlags, nil
|
||||
}
|
||||
if len(fromFlags) > 0 {
|
||||
return nil, common.FlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
|
||||
}
|
||||
items, ok := rawJSONFields.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, common.FlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
|
||||
}
|
||||
normalized, err := normalizeRecordGetSelectFields(items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
|
||||
return normalizeStringList(values, stringListNormalizeOptions{
|
||||
typeError: "field selection must be a string array",
|
||||
itemName: "field selection item",
|
||||
duplicateName: "field id",
|
||||
limitName: "field selection",
|
||||
max: maxBatchGetSelectFieldCount,
|
||||
allowNil: true,
|
||||
allowEmpty: true,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([]string, error) {
|
||||
var rawItems []interface{}
|
||||
switch typed := values.(type) {
|
||||
case nil:
|
||||
if opts.allowNil {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, common.FlagErrorf(opts.typeError)
|
||||
case []interface{}:
|
||||
rawItems = typed
|
||||
case []string:
|
||||
rawItems = make([]interface{}, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
rawItems = append(rawItems, item)
|
||||
}
|
||||
default:
|
||||
return nil, common.FlagErrorf(opts.typeError)
|
||||
}
|
||||
if len(rawItems) == 0 {
|
||||
if opts.allowEmpty {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, common.FlagErrorf(opts.emptyError)
|
||||
}
|
||||
if opts.max > 0 && len(rawItems) > opts.max {
|
||||
return nil, common.FlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
|
||||
}
|
||||
seen := make(map[string]int, len(rawItems))
|
||||
result := make([]string, 0, len(rawItems))
|
||||
for index, value := range rawItems {
|
||||
item, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s %d must be a string", opts.itemName, index+1)
|
||||
}
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
return nil, common.FlagErrorf("%s %d must not be empty", opts.itemName, index+1)
|
||||
}
|
||||
if first, exists := seen[item]; exists {
|
||||
return nil, common.FlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
|
||||
}
|
||||
seen[item] = index + 1
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func recordGetBatchBody(selection recordSelection) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"record_id_list": selection.recordIDs,
|
||||
}
|
||||
if len(selection.selectFields) > 0 {
|
||||
body["select_fields"] = selection.selectFields
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
offset := runtime.Int("offset")
|
||||
if offset < 0 {
|
||||
@@ -34,11 +218,15 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
|
||||
}
|
||||
|
||||
func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
selection, err := resolveRecordSelection(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_get").
|
||||
Body(recordGetBatchBody(selection)).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime)).
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -90,11 +278,15 @@ func dryRunRecordBatchUpdate(_ context.Context, runtime *common.RuntimeContext)
|
||||
}
|
||||
|
||||
func dryRunRecordDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
selection, err := resolveRecordSelection(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_delete").
|
||||
Body(map[string]interface{}{"record_id_list": selection.recordIDs}).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime)).
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -173,6 +365,9 @@ func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
}
|
||||
|
||||
func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
offset := runtime.Int("offset")
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
@@ -190,15 +385,29 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("format") == "markdown" {
|
||||
return outputRecordMarkdown(runtime, data)
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordGet(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil)
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
selection, err := resolveRecordSelection(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_get"), nil, recordGetBatchBody(selection))
|
||||
data, err := handleBaseAPIResult(result, err, "batch get records")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("format") == "markdown" {
|
||||
return outputRecordGetMarkdown(runtime, data)
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
@@ -213,6 +422,9 @@ func executeRecordSearch(runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("format") == "markdown" {
|
||||
return outputRecordMarkdown(runtime, data)
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
@@ -272,10 +484,17 @@ func executeRecordBatchUpdate(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeRecordDelete(runtime *common.RuntimeContext) error {
|
||||
_, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil)
|
||||
selection, err := resolveRecordSelection(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"deleted": true, "record_id": runtime.Str("record-id")}, nil)
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_delete"), nil, map[string]interface{}{
|
||||
"record_id_list": selection.recordIDs,
|
||||
})
|
||||
data, err := handleBaseAPIResult(result, err, "batch delete records")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var BaseRecordSearch = common.Shortcut{
|
||||
@@ -19,16 +20,28 @@ var BaseRecordSearch = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "record search JSON object", Required: true},
|
||||
{Name: "json", Desc: `record search JSON object; requires keyword/search_fields, optional select_fields/view_id/offset/limit`, Required: true},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
|
||||
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
|
||||
`Example: lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json '{"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}'`,
|
||||
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"],"select_fields":["<field_id_or_name>"],"view_id":"<view_id_or_name>","offset":0,"limit":10}.`,
|
||||
"JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.",
|
||||
"view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.",
|
||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||
"Use +record-search only for keyword search; use a filtered view plus +record-list for structured conditions.",
|
||||
"Agent hint: follow the lark-base record read SOP for record read routing and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordSearch,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
preserveFlagOrder(cmd)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordSearch(runtime)
|
||||
},
|
||||
|
||||
@@ -155,6 +155,19 @@ func (ctx *RuntimeContext) LarkSDK() *lark.Client {
|
||||
return ctx.larkSDK
|
||||
}
|
||||
|
||||
// EnsureScopes runs the same pre-flight scope check used by the framework
|
||||
// before Validate, but on a caller-supplied set of scopes. Use it from a
|
||||
// shortcut's Validate to enforce conditional scope requirements that depend
|
||||
// on flag values (e.g. --delete-remote needing space:document:delete) so a
|
||||
// destructive operation never starts on a token that can't finish it.
|
||||
//
|
||||
// Behavior matches checkShortcutScopes: when no token is available or the
|
||||
// resolver doesn't expose scope metadata, this is a silent no-op — the
|
||||
// downstream API call still surfaces missing_scope at runtime.
|
||||
func (ctx *RuntimeContext) EnsureScopes(scopes []string) error {
|
||||
return checkShortcutScopes(ctx.Factory, ctx.ctx, ctx.As(), ctx.Config, scopes)
|
||||
}
|
||||
|
||||
// ── Flag accessors ──
|
||||
|
||||
// Str returns a string flag value.
|
||||
@@ -882,17 +895,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
if path == "" {
|
||||
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
|
||||
}
|
||||
f, err := rctx.FileIO().Open(path)
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return FlagErrorf("--%s: invalid file path %q: %v", fl.Name, path, err)
|
||||
}
|
||||
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
|
||||
}
|
||||
data, err := io.ReadAll(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
|
||||
return FlagErrorf("--%s: %v", fl.Name, err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
||||
continue
|
||||
|
||||
@@ -5,7 +5,6 @@ package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -60,13 +59,12 @@ func TestResolveInputFlags_Stdin(t *testing.T) {
|
||||
|
||||
func TestResolveInputFlags_File(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
content := "## Hello\n\nThis is **markdown** from a file.\n"
|
||||
fpath := filepath.Join(dir, "test.md")
|
||||
os.WriteFile(fpath, []byte(content), 0644)
|
||||
if err := os.WriteFile("test.md", []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@test.md"}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
@@ -79,6 +77,25 @@ func TestResolveInputFlags_File(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_EmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
if err := os.WriteFile("empty.md", nil, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@empty.md"}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
|
||||
if err := resolveInputFlags(rctx, flags); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := rctx.Str("markdown"); got != "" {
|
||||
t.Errorf("expected empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_EmptyInput(t *testing.T) {
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": ""}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
@@ -132,9 +149,7 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
|
||||
|
||||
func TestResolveInputFlags_FileNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@nonexistent.md"}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
@@ -156,7 +171,7 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty file path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty") {
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
// Flag describes a CLI flag for a shortcut.
|
||||
type Flag struct {
|
||||
Name string // flag name (e.g. "calendar-id")
|
||||
Type string // "string" (default) | "bool" | "int" | "string_array"
|
||||
Type string // "string" (default) | "bool" | "int" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
|
||||
@@ -123,7 +123,7 @@ type searchUser struct {
|
||||
P2PChatID string `json:"p2p_chat_id"`
|
||||
HasChatted bool `json:"has_chatted"`
|
||||
Department string `json:"department"`
|
||||
Signature string `json:"signature"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
ChatRecencyHint string `json:"chat_recency_hint"`
|
||||
MatchSegments []string `json:"match_segments"`
|
||||
}
|
||||
@@ -150,18 +150,38 @@ var ContactSearchUser = common.Shortcut{
|
||||
{Name: "left-organization", Type: "bool", Desc: "restrict to users who have left the organization (omit to disable; =false rejected)"},
|
||||
{Name: "lang", Desc: "override locale for localized_name (e.g. zh_cn, en_us)"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"},
|
||||
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat users[] with matched_query plus a queries[] sidecar"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Keyword search: lark-cli contact +search-user --query 'alice' --format json",
|
||||
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me' --format json",
|
||||
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted --format json",
|
||||
"Keyword search: lark-cli contact +search-user --query 'alice'",
|
||||
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'",
|
||||
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted",
|
||||
"Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users",
|
||||
"Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'",
|
||||
"open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateSearchUser(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if raw := strings.TrimSpace(runtime.Str("queries")); raw != "" {
|
||||
queries := parseAndDedupQueries(raw)
|
||||
filter, err := buildFanoutFilter(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
api := common.NewDryRunAPI()
|
||||
for _, q := range queries {
|
||||
body := &searchUserAPIRequest{Query: q}
|
||||
if filter != nil {
|
||||
body.Filter = filter
|
||||
}
|
||||
api.POST(searchUserURL).
|
||||
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
|
||||
Body(body)
|
||||
}
|
||||
return api
|
||||
}
|
||||
body, err := buildSearchUserBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
@@ -175,6 +195,13 @@ var ContactSearchUser = common.Shortcut{
|
||||
}
|
||||
|
||||
func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("queries")) != "" {
|
||||
return executeSearchUserFanout(ctx, runtime)
|
||||
}
|
||||
return executeSearchUserSingle(ctx, runtime)
|
||||
}
|
||||
|
||||
func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildSearchUserBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -347,10 +374,32 @@ func rowFromItem(item *searchUserAPIItem, lang string, brand core.LarkBrand) sea
|
||||
func validateSearchUser(runtime *common.RuntimeContext) error {
|
||||
if !hasAnySearchInput(runtime) {
|
||||
return common.FlagErrorf(
|
||||
"specify at least one of --query, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
|
||||
"specify at least one of --query, --queries, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
|
||||
)
|
||||
}
|
||||
|
||||
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
|
||||
if queriesRaw != "" {
|
||||
if strings.TrimSpace(runtime.Str("query")) != "" {
|
||||
return common.FlagErrorf("--query and --queries are mutually exclusive")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
|
||||
return common.FlagErrorf("--user-ids and --queries are mutually exclusive")
|
||||
}
|
||||
queries := parseAndDedupQueries(queriesRaw)
|
||||
if len(queries) == 0 {
|
||||
return common.FlagErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw)
|
||||
}
|
||||
if len(queries) > maxFanoutQueries {
|
||||
return common.FlagErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries))
|
||||
}
|
||||
for _, q := range queries {
|
||||
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
|
||||
return common.FlagErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
|
||||
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
|
||||
return common.FlagErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars)
|
||||
@@ -399,6 +448,9 @@ func hasAnySearchInput(runtime *common.RuntimeContext) bool {
|
||||
if strings.TrimSpace(runtime.Str("query")) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("queries")) != "" {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
275
shortcuts/contact/contact_search_user_fanout.go
Normal file
275
shortcuts/contact/contact_search_user_fanout.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFanoutQueries = 20
|
||||
fanoutConcurrency = 5
|
||||
)
|
||||
|
||||
// parseAndDedupQueries splits the raw CSV, trims whitespace, drops empty
|
||||
// entries, and deduplicates case-sensitively while preserving first-occurrence
|
||||
// order.
|
||||
func parseAndDedupQueries(raw string) []string {
|
||||
parts := common.SplitCSV(raw)
|
||||
seen := make(map[string]bool, len(parts))
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || seen[p] {
|
||||
continue
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type fanoutResult struct {
|
||||
Index int
|
||||
Query string
|
||||
Users []searchUser
|
||||
HasMore bool
|
||||
ErrMsg string // empty = success
|
||||
ErrCode int // 0 = success or unknown; otherwise an HTTP status or Lark API code corresponding to the first error
|
||||
}
|
||||
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
|
||||
// because that summary lives on stderr and never corrupts the csv stream on
|
||||
// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate
|
||||
// for its refine hint, so adding csv here doesn't regress that path.
|
||||
func isFanoutSummaryFormat(format string) bool {
|
||||
return format == "pretty" || format == "table" || format == "csv"
|
||||
}
|
||||
|
||||
// runOneQuery converts every failure mode (transport, HTTP status, parse,
|
||||
// API code) into an ErrMsg string instead of returning a Go error. The
|
||||
// fanout dispatcher (Task 6) relies on this so a single failed query never
|
||||
// short-circuits the remaining workers.
|
||||
func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
|
||||
filter *searchUserAPIFilter) fanoutResult {
|
||||
// Pre-check ctx so queued workers see cancellation before issuing a
|
||||
// request; in-flight workers continue until DoAPI returns.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
|
||||
}
|
||||
|
||||
body := &searchUserAPIRequest{Query: query}
|
||||
if filter != nil {
|
||||
body.Filter = filter
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: searchUserURL,
|
||||
Body: body,
|
||||
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
|
||||
})
|
||||
if err != nil {
|
||||
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
|
||||
}
|
||||
if apiResp.StatusCode != http.StatusOK {
|
||||
body := strings.TrimSpace(string(apiResp.RawBody))
|
||||
const maxBody = 200
|
||||
if len(body) > maxBody {
|
||||
body = body[:maxBody] + "..."
|
||||
}
|
||||
msg := fmt.Sprintf("HTTP %d %s", apiResp.StatusCode, http.StatusText(apiResp.StatusCode))
|
||||
if body != "" {
|
||||
msg = fmt.Sprintf("%s: %s", msg, body)
|
||||
}
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: msg,
|
||||
ErrCode: apiResp.StatusCode}
|
||||
}
|
||||
|
||||
var resp searchUserAPIEnvelope
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: fmt.Sprintf("parse response failed: %v", err)}
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: fmt.Sprintf("API %d: %s", resp.Code, resp.Msg),
|
||||
ErrCode: resp.Code}
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(resp.Data, runtime.Str("lang"), runtime.Config.Brand)
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
|
||||
}
|
||||
|
||||
type fanoutUser struct {
|
||||
searchUser
|
||||
MatchedQuery string `json:"matched_query"`
|
||||
}
|
||||
|
||||
type querySummary struct {
|
||||
Query string `json:"query"`
|
||||
Error string `json:"error,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
type fanoutResponse struct {
|
||||
Users []fanoutUser `json:"users"`
|
||||
Queries []querySummary `json:"queries"`
|
||||
}
|
||||
|
||||
// buildFanoutResponse walks results by Index (input order), flattens users[]
|
||||
// with matched_query, lists every input in queries[] (including successes),
|
||||
// and returns an error only when every query failed. The error wraps the
|
||||
// first failing query's ErrMsg so the CLI exits non-zero on full failure.
|
||||
func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) {
|
||||
indexed := make([]fanoutResult, len(queries))
|
||||
for _, r := range results {
|
||||
indexed[r.Index] = r
|
||||
}
|
||||
|
||||
out := &fanoutResponse{
|
||||
Users: make([]fanoutUser, 0),
|
||||
Queries: make([]querySummary, 0, len(queries)),
|
||||
}
|
||||
failed := 0
|
||||
var firstErrMsg, firstErrQuery string
|
||||
var firstErrCode int
|
||||
for i, r := range indexed {
|
||||
out.Queries = append(out.Queries, querySummary{
|
||||
Query: queries[i],
|
||||
Error: r.ErrMsg,
|
||||
HasMore: r.HasMore,
|
||||
})
|
||||
if r.ErrMsg != "" {
|
||||
failed++
|
||||
if firstErrMsg == "" {
|
||||
firstErrMsg = r.ErrMsg
|
||||
firstErrQuery = queries[i]
|
||||
firstErrCode = r.ErrCode
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, u := range r.Users {
|
||||
out.Users = append(out.Users, fanoutUser{searchUser: u, MatchedQuery: queries[i]})
|
||||
}
|
||||
}
|
||||
if failed == len(queries) && len(queries) > 0 {
|
||||
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
|
||||
len(queries), firstErrMsg, firstErrQuery)
|
||||
// Only the HTTP-status / Lark-API-code branches in runOneQuery populate
|
||||
// ErrCode; transport, parse, panic, and ctx-canceled stay at 0. Code 0
|
||||
// means success in the Lark protocol, so don't pretend it's an API error
|
||||
// when we have nothing structured to report.
|
||||
if firstErrCode != 0 {
|
||||
return nil, output.ErrAPI(firstErrCode, msg, "")
|
||||
}
|
||||
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg, "")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func executeSearchUserFanout(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
queries := parseAndDedupQueries(runtime.Str("queries"))
|
||||
|
||||
filter, err := buildFanoutFilter(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := make([]fanoutResult, len(queries))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, fanoutConcurrency)
|
||||
|
||||
for i, q := range queries {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, q string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
results[i] = fanoutResult{
|
||||
Index: i,
|
||||
Query: q,
|
||||
ErrMsg: fmt.Sprintf("internal error: %v", r),
|
||||
}
|
||||
}
|
||||
}()
|
||||
results[i] = runOneQuery(ctx, runtime, i, q, filter)
|
||||
}(i, q)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
resp, err := buildFanoutResponse(queries, results)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
failed, hasMoreCount := 0, 0
|
||||
for _, qs := range resp.Queries {
|
||||
if qs.Error != "" {
|
||||
failed++
|
||||
}
|
||||
if qs.HasMore {
|
||||
hasMoreCount++
|
||||
}
|
||||
}
|
||||
|
||||
runtime.OutFormat(resp, &output.Meta{Count: len(resp.Users)}, func(w io.Writer) {
|
||||
if len(resp.Users) == 0 {
|
||||
fmt.Fprintln(w, "No users found.")
|
||||
return
|
||||
}
|
||||
output.PrintTable(w, prettyFanoutUserRows(resp.Users))
|
||||
})
|
||||
|
||||
if isFanoutSummaryFormat(runtime.Format) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total users; %d failed, %d with has_more\n",
|
||||
len(queries), len(resp.Users), failed, hasMoreCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFanoutFilter(runtime *common.RuntimeContext) (*searchUserAPIFilter, error) {
|
||||
filter := &searchUserAPIFilter{}
|
||||
hasFilter := false
|
||||
for _, bf := range searchUserBoolFilters {
|
||||
if runtime.Cmd.Flags().Changed(bf.Flag) && runtime.Bool(bf.Flag) {
|
||||
bf.Apply(filter)
|
||||
hasFilter = true
|
||||
}
|
||||
}
|
||||
if !hasFilter {
|
||||
return nil, nil
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func prettyFanoutUserRows(users []fanoutUser) []map[string]interface{} {
|
||||
rows := make([]map[string]interface{}, 0, len(users))
|
||||
for _, u := range users {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"matched_query": u.MatchedQuery,
|
||||
"localized_name": u.LocalizedName,
|
||||
"department": common.TruncateStr(u.Department, 50),
|
||||
"enterprise_email": u.EnterpriseEmail,
|
||||
"has_chatted": u.HasChatted,
|
||||
"chat_recency_hint": u.ChatRecencyHint,
|
||||
"open_id": u.OpenID,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -5,10 +5,14 @@ package contact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -620,6 +624,46 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Most users have no signature; the field is omitempty so an empty value
|
||||
// must not appear at all in the JSON, not as "" — agents shouldn't have to
|
||||
// distinguish "absent" from "empty string".
|
||||
func TestSearchUser_Integration_EmptySignatureOmitted(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/contact/v3/users/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "ou_a",
|
||||
"meta_data": map[string]interface{}{
|
||||
"i18n_names": map[string]interface{}{"zh_cn": "无签名用户"},
|
||||
"mail_address": "x@example.com",
|
||||
"description": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "json", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
users := got["data"].(map[string]interface{})["users"].([]interface{})
|
||||
u := users[0].(map[string]interface{})
|
||||
if _, present := u["signature"]; present {
|
||||
t.Errorf(`signature must be absent (not "") when empty; got %v`, u["signature"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchUser_Integration_NDJSONHasNoRefineHint(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -808,6 +852,345 @@ func TestSearchUser_Integration_PageSizeFlowsToQuery(t *testing.T) {
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func newSearchUserTestCommandWithQueries() *cobra.Command {
|
||||
cmd := newSearchUserTestCommand()
|
||||
cmd.Flags().String("queries", "", "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestValidateQueries_QueryAndQueriesMutex(t *testing.T) {
|
||||
cmd := newSearchUserTestCommandWithQueries()
|
||||
_ = cmd.Flags().Set("query", "alice")
|
||||
_ = cmd.Flags().Set("queries", "bob,carol")
|
||||
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
|
||||
err := validateSearchUser(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--query and --queries are mutually exclusive") {
|
||||
t.Fatalf("expected mutex error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateQueries_UserIDsAndQueriesMutex(t *testing.T) {
|
||||
cmd := newSearchUserTestCommandWithQueries()
|
||||
_ = cmd.Flags().Set("user-ids", "ou_a")
|
||||
_ = cmd.Flags().Set("queries", "bob")
|
||||
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
|
||||
err := validateSearchUser(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--user-ids and --queries are mutually exclusive") {
|
||||
t.Fatalf("expected mutex error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateQueries_AllSeparators_Errors(t *testing.T) {
|
||||
for _, raw := range []string{",,,", " , , ", ","} {
|
||||
cmd := newSearchUserTestCommandWithQueries()
|
||||
_ = cmd.Flags().Set("queries", raw)
|
||||
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
|
||||
err := validateSearchUser(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "no valid query parsed") {
|
||||
t.Fatalf("raw=%q: expected 'no valid query parsed' error, got %v", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateQueries_OverLength_Errors(t *testing.T) {
|
||||
cmd := newSearchUserTestCommandWithQueries()
|
||||
long := strings.Repeat("a", 51)
|
||||
_ = cmd.Flags().Set("queries", "short,"+long)
|
||||
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
|
||||
err := validateSearchUser(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds 50 characters") {
|
||||
t.Fatalf("expected length error mentioning 50, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateQueries_Over20_Errors(t *testing.T) {
|
||||
cmd := newSearchUserTestCommandWithQueries()
|
||||
parts := make([]string, 21)
|
||||
for i := range parts {
|
||||
parts[i] = fmt.Sprintf("q%02d", i)
|
||||
}
|
||||
_ = cmd.Flags().Set("queries", strings.Join(parts, ","))
|
||||
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
|
||||
err := validateSearchUser(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "must be at most 20 entries") {
|
||||
t.Fatalf("expected 20-cap error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQueries_TrimAndSkipEmpty(t *testing.T) {
|
||||
got := parseAndDedupQueries("a, ,b ,")
|
||||
want := []string{"a", "b"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Errorf("parseAndDedupQueries: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQueries_DedupCaseSensitive(t *testing.T) {
|
||||
got := parseAndDedupQueries("alice,Alice,alice")
|
||||
want := []string{"alice", "Alice"}
|
||||
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Errorf("got %v, want %v (case-sensitive dedup keeps first-occurrence order)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSingleQuery_OutputUnchanged(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(searchUserStub())
|
||||
|
||||
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "张三", "--format", "json", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
data, _ := got["data"].(map[string]interface{})
|
||||
if _, hasQueries := data["queries"]; hasQueries {
|
||||
t.Errorf("single-query mode must NOT emit data.queries; got=%v", data)
|
||||
}
|
||||
users, _ := data["users"].([]interface{})
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("users len = %d, want 1", len(users))
|
||||
}
|
||||
u, _ := users[0].(map[string]interface{})
|
||||
if _, hasMatched := u["matched_query"]; hasMatched {
|
||||
t.Errorf("single-query mode users[] must NOT carry matched_query; got=%v", u)
|
||||
}
|
||||
if _, hasTopHasMore := data["has_more"]; !hasTopHasMore {
|
||||
t.Errorf("single-query mode must keep top-level data.has_more; data=%v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// runOneQueryRuntime wires a Factory-backed RuntimeContext bound to the test
|
||||
// command's flag set, so runOneQuery can be exercised directly without going
|
||||
// through the cobra dispatcher. Mirrors what mountAndRun would build, minus
|
||||
// the parent-command plumbing the worker doesn't need.
|
||||
func runOneQueryRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
f, _, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
cmd := newSearchUserTestCommand()
|
||||
rt := common.TestNewRuntimeContextForAPI(context.Background(), cmd, searchUserDefaultConfig(), f, core.AsUser)
|
||||
return rt, reg
|
||||
}
|
||||
|
||||
func TestRunOneQuery_Success(t *testing.T) {
|
||||
rt, reg := runOneQueryRuntime(t)
|
||||
reg.Register(searchUserStub())
|
||||
|
||||
got := runOneQuery(context.Background(), rt, 0, "张三", nil)
|
||||
if got.ErrMsg != "" {
|
||||
t.Fatalf("unexpected ErrMsg: %q", got.ErrMsg)
|
||||
}
|
||||
if got.Index != 0 || got.Query != "张三" {
|
||||
t.Errorf("Index/Query mismatch: %+v", got)
|
||||
}
|
||||
if len(got.Users) != 1 || got.Users[0].OpenID != "ou_a" {
|
||||
t.Errorf("Users mismatch: %+v", got.Users)
|
||||
}
|
||||
if got.HasMore {
|
||||
t.Errorf("HasMore should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOneQuery_APINonZeroCode(t *testing.T) {
|
||||
rt, reg := runOneQueryRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: searchUserURL,
|
||||
Body: map[string]interface{}{"code": 99991663, "msg": "rate limited"},
|
||||
})
|
||||
|
||||
got := runOneQuery(context.Background(), rt, 3, "alice", nil)
|
||||
if got.Index != 3 || got.Query != "alice" {
|
||||
t.Errorf("Index/Query mismatch: %+v", got)
|
||||
}
|
||||
if got.ErrMsg != "API 99991663: rate limited" {
|
||||
t.Errorf("ErrMsg = %q, want 'API 99991663: rate limited'", got.ErrMsg)
|
||||
}
|
||||
if got.Users != nil || got.HasMore {
|
||||
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOneQuery_HTTPNon200(t *testing.T) {
|
||||
rt, reg := runOneQueryRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: searchUserURL,
|
||||
Status: 503,
|
||||
Body: map[string]interface{}{"reason": "upstream_unavailable"},
|
||||
})
|
||||
|
||||
got := runOneQuery(context.Background(), rt, 1, "bob", nil)
|
||||
if !strings.HasPrefix(got.ErrMsg, "HTTP 503 Service Unavailable: ") {
|
||||
t.Errorf("ErrMsg should start with status line; got %q", got.ErrMsg)
|
||||
}
|
||||
if !strings.Contains(got.ErrMsg, "upstream_unavailable") {
|
||||
t.Errorf("ErrMsg should include response body for diagnosis; got %q", got.ErrMsg)
|
||||
}
|
||||
if got.ErrCode != 503 {
|
||||
t.Errorf("ErrCode = %d, want 503", got.ErrCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOneQuery_HTTPNon200_BodyTruncated(t *testing.T) {
|
||||
rt, reg := runOneQueryRuntime(t)
|
||||
long := strings.Repeat("x", 1000)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: searchUserURL,
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"detail": long},
|
||||
})
|
||||
|
||||
got := runOneQuery(context.Background(), rt, 0, "alice", nil)
|
||||
if !strings.HasSuffix(got.ErrMsg, "...") {
|
||||
t.Errorf("oversized body should be truncated with '...' suffix; got %q", got.ErrMsg)
|
||||
}
|
||||
if len(got.ErrMsg) > 300 {
|
||||
t.Errorf("ErrMsg %d chars exceeds reasonable budget; got %q", len(got.ErrMsg), got.ErrMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// SDK-level transport / envelope-unmarshal failures arrive as Go errors from
|
||||
// runtime.DoAPI; the worker converts them by calling err.Error() rather than
|
||||
// adding its own prefix, so the assertion here is "ErrMsg is non-empty and
|
||||
// preserves the underlying message" — the exact text comes from the SDK.
|
||||
func TestRunOneQuery_TransportError(t *testing.T) {
|
||||
rt, reg := runOneQueryRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: searchUserURL,
|
||||
RawBody: []byte("{not-json"),
|
||||
})
|
||||
|
||||
got := runOneQuery(context.Background(), rt, 2, "carol", nil)
|
||||
if got.ErrMsg == "" {
|
||||
t.Fatalf("expected non-empty ErrMsg for malformed body")
|
||||
}
|
||||
if got.Index != 2 || got.Query != "carol" {
|
||||
t.Errorf("Index/Query mismatch: %+v", got)
|
||||
}
|
||||
if got.Users != nil || got.HasMore {
|
||||
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutAssemble_OrderAndShape(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{Index: 1, Query: "bob", Users: []searchUser{{OpenID: "ou_b"}}, HasMore: true},
|
||||
{Index: 0, Query: "alice", Users: []searchUser{{OpenID: "ou_a1"}, {OpenID: "ou_a2"}}, HasMore: false},
|
||||
{Index: 2, Query: "carol", ErrMsg: "API 1: nope"},
|
||||
}
|
||||
resp, err := buildFanoutResponse([]string{"alice", "bob", "carol"}, results)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(resp.Users) != 3 {
|
||||
t.Fatalf("Users length: got %d, want 3 (carol failed → 0 users)", len(resp.Users))
|
||||
}
|
||||
if resp.Users[0].OpenID != "ou_a1" || resp.Users[0].MatchedQuery != "alice" {
|
||||
t.Errorf("Users[0]: got %+v", resp.Users[0])
|
||||
}
|
||||
if resp.Users[1].OpenID != "ou_a2" || resp.Users[1].MatchedQuery != "alice" {
|
||||
t.Errorf("Users[1]: got %+v", resp.Users[1])
|
||||
}
|
||||
if resp.Users[2].OpenID != "ou_b" || resp.Users[2].MatchedQuery != "bob" {
|
||||
t.Errorf("Users[2]: got %+v", resp.Users[2])
|
||||
}
|
||||
if len(resp.Queries) != 3 {
|
||||
t.Fatalf("Queries length: got %d, want 3 (full enumeration)", len(resp.Queries))
|
||||
}
|
||||
want := []querySummary{
|
||||
{Query: "alice", Error: "", HasMore: false},
|
||||
{Query: "bob", Error: "", HasMore: true},
|
||||
{Query: "carol", Error: "API 1: nope", HasMore: false},
|
||||
}
|
||||
for i, w := range want {
|
||||
if resp.Queries[i] != w {
|
||||
t.Errorf("Queries[%d]: got %+v, want %+v", i, resp.Queries[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit"},
|
||||
{Index: 1, Query: "bob", ErrMsg: "HTTP 500 Internal Server Error"},
|
||||
}
|
||||
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when all queries failed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rate limit") {
|
||||
t.Errorf("expected first error (rate limit) to be returned; got %v", err)
|
||||
}
|
||||
// Document the count is part of the message — agents grep for it.
|
||||
if !strings.Contains(err.Error(), "all 2 queries failed") {
|
||||
t.Errorf("expected 'all 2 queries failed' substring; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Codes from the first failure must propagate through output.ErrAPI so the
|
||||
// CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
|
||||
// instead of 0, which would mean "success" in the Lark protocol.
|
||||
func TestFanoutAssemble_AllFailed_PropagatesFirstCode(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit", ErrCode: 99991663},
|
||||
{Index: 1, Query: "bob", ErrMsg: "HTTP 500", ErrCode: 500},
|
||||
}
|
||||
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rate limit") {
|
||||
t.Errorf("error should contain first ErrMsg; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutAssemble_PartialFailureOK(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{Index: 0, Query: "alice", Users: []searchUser{{OpenID: "ou_a"}}},
|
||||
{Index: 1, Query: "bob", ErrMsg: "API 5: not found"},
|
||||
}
|
||||
resp, err := buildFanoutResponse([]string{"alice", "bob"}, results)
|
||||
if err != nil {
|
||||
t.Fatalf("partial failure must NOT be a hard error; got %v", err)
|
||||
}
|
||||
if len(resp.Users) != 1 {
|
||||
t.Errorf("Users: got %d, want 1", len(resp.Users))
|
||||
}
|
||||
if resp.Queries[1].Error != "API 5: not found" {
|
||||
t.Errorf("Queries[1].Error: got %q", resp.Queries[1].Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutAssemble_NoTopLevelHasMore(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{Index: 0, Query: "alice", HasMore: true},
|
||||
}
|
||||
resp, err := buildFanoutResponse([]string{"alice"}, results)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(resp)
|
||||
var asMap map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &asMap); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if _, ok := asMap["has_more"]; ok {
|
||||
t.Errorf("fanoutResponse must not have top-level has_more; got %v", asMap)
|
||||
}
|
||||
if _, ok := asMap["users"]; !ok {
|
||||
t.Errorf("fanoutResponse missing users")
|
||||
}
|
||||
if _, ok := asMap["queries"]; !ok {
|
||||
t.Errorf("fanoutResponse missing queries")
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies that with the auto-pagination flags removed, --page-all / --page-limit
|
||||
// are no longer accepted. cobra must reject the unknown flag at parse time —
|
||||
// no stub is registered because the command should never reach the API.
|
||||
@@ -827,3 +1210,341 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/contact/v3/users/search",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "alice,bob", "--has-chatted",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(stub.CapturedBodies) < 2 {
|
||||
t.Fatalf("expected ≥2 captured request bodies, got %d", len(stub.CapturedBodies))
|
||||
}
|
||||
bodyByQuery := map[string]map[string]interface{}{}
|
||||
for i, raw := range stub.CapturedBodies {
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("unmarshal req %d: %v", i, err)
|
||||
}
|
||||
bodyByQuery[body["query"].(string)] = body
|
||||
filter, _ := body["filter"].(map[string]interface{})
|
||||
if filter == nil || filter["has_contact"] != true {
|
||||
t.Errorf("req %d (query=%v): expected filter.has_contact=true; got body=%v", i, body["query"], body)
|
||||
}
|
||||
}
|
||||
if _, ok := bodyByQuery["alice"]; !ok {
|
||||
t.Errorf("missing request for query=alice; captured=%v", bodyByQuery)
|
||||
}
|
||||
if _, ok := bodyByQuery["bob"]; !ok {
|
||||
t.Errorf("missing request for query=bob; captured=%v", bodyByQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/contact/v3/users/search",
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) },
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
|
||||
"has_more": false,
|
||||
}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/contact/v3/users/search",
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"bob"`) },
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{},
|
||||
})
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "alice,bob", "--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("partial failure should NOT propagate as error; got %v", err)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
users := data["users"].([]interface{})
|
||||
if len(users) != 1 {
|
||||
t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String())
|
||||
}
|
||||
queries := data["queries"].([]interface{})
|
||||
if len(queries) != 2 {
|
||||
t.Fatalf("queries: expected 2, got %d", len(queries))
|
||||
}
|
||||
q1 := queries[1].(map[string]interface{})
|
||||
if !strings.HasPrefix(q1["error"].(string), "HTTP 500") {
|
||||
t.Errorf("queries[1].error: got %q", q1["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_AllFailed_ExitNonZero(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/contact/v3/users/search",
|
||||
Reusable: true,
|
||||
Status: 500, Body: map[string]interface{}{"reason": "boom"},
|
||||
})
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "alice,bob", "--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when all queries failed")
|
||||
}
|
||||
// First failure's HTTP code (500) and a digestible reason must propagate
|
||||
// so agents can classify (vs. a generic ExitInternal masking the upstream).
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "500") {
|
||||
t.Errorf("error must propagate first failure's HTTP 500 code; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "all 2 queries failed") {
|
||||
t.Errorf("error must indicate the all-failed mode; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_ConcurrencyLimitFive(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
|
||||
var inFlight, peak int32
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/contact/v3/users/search",
|
||||
Reusable: true,
|
||||
OnMatch: func(req *http.Request) {
|
||||
cur := atomic.AddInt32(&inFlight, 1)
|
||||
defer atomic.AddInt32(&inFlight, -1)
|
||||
for {
|
||||
p := atomic.LoadInt32(&peak)
|
||||
if cur <= p || atomic.CompareAndSwapInt32(&peak, p, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
},
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
|
||||
})
|
||||
|
||||
queries := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", strings.Join(queries, ","),
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if peak > 5 {
|
||||
t.Errorf("concurrency peak = %d, want ≤ 5", peak)
|
||||
}
|
||||
if peak < 2 {
|
||||
t.Errorf("concurrency peak = %d, want ≥ 2 (test should observe parallelism)", peak)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_PanicRecovery(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/contact/v3/users/search",
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"boom"`) },
|
||||
OnMatch: func(req *http.Request) {
|
||||
panic("synthetic test panic")
|
||||
},
|
||||
Body: map[string]interface{}{},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/contact/v3/users/search",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "ok,boom,fine", "--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("partial panic must not bubble; got %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
_ = json.Unmarshal(stdout.Bytes(), &got)
|
||||
queries := got["data"].(map[string]interface{})["queries"].([]interface{})
|
||||
q1 := queries[1].(map[string]interface{})
|
||||
if !strings.HasPrefix(q1["error"].(string), "internal error:") {
|
||||
t.Errorf("queries[1].error: expected 'internal error:' prefix, got %q", q1["error"])
|
||||
}
|
||||
for _, marker := range []string{"goroutine ", ".go:", "runtime."} {
|
||||
if strings.Contains(stderr.String(), marker) {
|
||||
t.Errorf("stderr leaked stack-trace marker %q; got=%s", marker, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_MatchedQueryFidelity(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/contact/v3/users/search",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_x"}},
|
||||
"has_more": false,
|
||||
}},
|
||||
})
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "张三,Alice 王", "--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
_ = json.Unmarshal(stdout.Bytes(), &got)
|
||||
users := got["data"].(map[string]interface{})["users"].([]interface{})
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("users: got %d, want 2", len(users))
|
||||
}
|
||||
want := []string{"张三", "Alice 王"}
|
||||
for i, w := range want {
|
||||
mq := users[i].(map[string]interface{})["matched_query"]
|
||||
if mq != w {
|
||||
t.Errorf("users[%d].matched_query: got %v, want %q (must be original input verbatim)", i, mq, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_NDJSONStdoutClean(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/contact/v3/users/search",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
|
||||
"has_more": false,
|
||||
}},
|
||||
})
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "a,a,b", "--format", "ndjson", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
for _, marker := range []string{"queries,", "total users", "with has_more"} {
|
||||
if strings.Contains(stdout.String(), marker) {
|
||||
t.Errorf("ndjson stdout must not contain %q; got=%q", marker, stdout.String())
|
||||
}
|
||||
}
|
||||
_ = stderr
|
||||
}
|
||||
|
||||
func TestFanout_CSVHasMatchedQueryColumn(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/contact/v3/users/search",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
|
||||
"has_more": false,
|
||||
}},
|
||||
})
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "alice,bob", "--format", "csv", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "matched_query") {
|
||||
t.Errorf("csv stdout must include matched_query column; got=%q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "queries") || !strings.Contains(stderr.String(), "total users") {
|
||||
t.Errorf("csv summary should land on stderr; got=%q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanout_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
|
||||
err := mountAndRun(t, ContactSearchUser, []string{
|
||||
"+search-user", "--queries", "alice,bob", "--has-chatted", "--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"alice", "bob", "POST", "/contact/v3/users/search", "has_contact"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("dry-run output missing %q; got=%q", want, out)
|
||||
}
|
||||
}
|
||||
// One DryRunAPI description per query.
|
||||
if strings.Count(out, "/contact/v3/users/search") < 2 {
|
||||
t.Errorf("dry-run should describe ≥2 API calls (one per query); got=%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Spec §7 promises single-query --query mode is "零变化". The fanout summary
|
||||
// hint was broadened to csv (good — stderr can carry it without corrupting
|
||||
// the csv stream on stdout); the single-query refine hint must NOT inherit
|
||||
// that broadening, since pre-fanout it only fired on pretty/table.
|
||||
func TestSearchUser_Integration_CSVSingleQueryNoRefineHint(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/contact/v3/users/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
|
||||
"has_more": true,
|
||||
"page_token": "tok_next",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "csv", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if strings.Contains(stderr.String(), "refine") {
|
||||
t.Errorf("single-query --format csv must NOT emit the refine hint; got stderr=%q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A pre-canceled ctx must be observed by runOneQuery before it dispatches the
|
||||
// HTTP call. The error string is exactly "context canceled" because that's
|
||||
// what context.Context.Err().Error() returns — agents may grep for it.
|
||||
func TestRunOneQuery_CtxCanceledEarly(t *testing.T) {
|
||||
rt, _ := runOneQueryRuntime(t)
|
||||
// Deliberately register no stub: runOneQuery must short-circuit before
|
||||
// touching the transport, so the absence of a stub is the assertion.
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
got := runOneQuery(ctx, rt, 0, "alice", nil)
|
||||
if got.ErrMsg != "context canceled" {
|
||||
t.Errorf("ErrMsg: got %q, want %q", got.ErrMsg, "context canceled")
|
||||
}
|
||||
if got.Index != 0 || got.Query != "alice" {
|
||||
t.Errorf("Index/Query mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ var DocsCreate = common.Shortcut{
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"docx:document:create"},
|
||||
Tips: docsVersionSelectionTips,
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
@@ -110,6 +111,11 @@ func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
|
||||
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
warnDeprecatedV1(runtime, "+create")
|
||||
// Surface callout type= hint so users know to switch to background-color/
|
||||
// border-color when they want a colored callout. Non-blocking, advisory.
|
||||
if md := runtime.Str("markdown"); md != "" {
|
||||
WarnCalloutType(md, runtime.IO().ErrOut)
|
||||
}
|
||||
args := buildCreateArgsV1(runtime)
|
||||
result, err := common.CallMCPTool(runtime, "create-doc", args)
|
||||
if err != nil {
|
||||
@@ -122,8 +128,9 @@ func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
md := runtime.Str("markdown")
|
||||
args := map[string]interface{}{
|
||||
"markdown": runtime.Str("markdown"),
|
||||
"markdown": md,
|
||||
}
|
||||
if v := runtime.Str("title"); v != "" {
|
||||
args["title"] = v
|
||||
|
||||
@@ -49,6 +49,7 @@ var DocsFetch = common.Shortcut{
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Tips: docsVersionSelectionTips,
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
|
||||
@@ -22,7 +22,7 @@ func v2FetchFlags() []common.Flag {
|
||||
{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: "keyword", Desc: "keyword mode: substring + regex match (case-insensitive); use '|' for OR branches, e.g. 'foo|bar' or 'bug|缺陷'"},
|
||||
{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"},
|
||||
|
||||
@@ -64,6 +64,7 @@ var DocsUpdate = common.Shortcut{
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Tips: docsVersionSelectionTips,
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
@@ -159,6 +160,12 @@ func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
|
||||
}
|
||||
|
||||
// Surface callout type= hint so users know to switch to background-color/
|
||||
// border-color when they want a colored callout. Non-blocking, advisory.
|
||||
if md := runtime.Str("markdown"); md != "" {
|
||||
WarnCalloutType(md, runtime.IO().ErrOut)
|
||||
}
|
||||
|
||||
args := buildUpdateArgsV1(runtime)
|
||||
|
||||
result, err := common.CallMCPTool(runtime, "update-doc", args)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -306,6 +308,113 @@ func fixSetextAmbiguity(md string) string {
|
||||
return setextRe.ReplaceAllString(md, "$1\n\n$2")
|
||||
}
|
||||
|
||||
// calloutTypeColors maps the semantic type= shorthand to a recommended
|
||||
// [background-color, border-color] pair for Feishu callout blocks.
|
||||
// Used only for hint messages — the Markdown itself is never rewritten.
|
||||
var calloutTypeColors = map[string][2]string{
|
||||
"warning": {"light-yellow", "yellow"},
|
||||
"caution": {"light-orange", "orange"},
|
||||
"note": {"light-blue", "blue"},
|
||||
"info": {"light-blue", "blue"},
|
||||
"tip": {"light-green", "green"},
|
||||
"success": {"light-green", "green"},
|
||||
"check": {"light-green", "green"},
|
||||
"error": {"light-red", "red"},
|
||||
"danger": {"light-red", "red"},
|
||||
"important": {"light-purple", "purple"},
|
||||
}
|
||||
|
||||
// calloutOpenTagRe matches a <callout …> opening tag.
|
||||
var calloutOpenTagRe = regexp.MustCompile(`<callout(\s[^>]*)?>`)
|
||||
|
||||
// calloutTypeAttrRe extracts the value of a type= attribute (single or
|
||||
// double quoted) from a callout opening tag's attribute string. The
|
||||
// (?:^|\s) anchor instead of \b is intentional: \b sits at any
|
||||
// word/non-word boundary, and `-` is a non-word character, so
|
||||
// `\btype=` would also match the suffix of `data-type=` and yield a
|
||||
// bogus type lookup. Anchoring on start-of-string-or-whitespace
|
||||
// requires a real attribute separator before the name.
|
||||
var calloutTypeAttrRe = regexp.MustCompile(`(?:^|\s)type=(?:"([^"]*)"|'([^']*)')`)
|
||||
|
||||
// calloutBackgroundColorAttrRe matches a background-color= attribute
|
||||
// name with optional whitespace around the equals sign, so forms like
|
||||
// `background-color="..."` and `background-color = "..."` are both
|
||||
// accepted. Same (?:^|\s) anchor as calloutTypeAttrRe, for the same
|
||||
// reason: `data-background-color="..."` must not look like a present
|
||||
// background-color and silently suppress the hint.
|
||||
var calloutBackgroundColorAttrRe = regexp.MustCompile(`(?:^|\s)background-color\s*=`)
|
||||
|
||||
// WarnCalloutType scans md for callout tags that carry a type= attribute but
|
||||
// no background-color= attribute, then writes a hint line to w for each one
|
||||
// suggesting the explicit Feishu color attributes to use instead.
|
||||
//
|
||||
// Callout tags inside fenced code blocks (``` or ~~~) are skipped — they
|
||||
// are documentation samples, not real callouts the user wants Feishu to
|
||||
// render. Fence detection uses the shared codeFenceOpenMarker /
|
||||
// isCodeFenceClose helpers so both backtick and tilde fences are handled
|
||||
// (matching CommonMark §4.5).
|
||||
//
|
||||
// The Markdown is not modified — the caller is responsible for acting on
|
||||
// the hints or ignoring them. This keeps the create/update path
|
||||
// transparent: user input reaches create-doc exactly as written.
|
||||
func WarnCalloutType(md string, w io.Writer) {
|
||||
fenceMarker := ""
|
||||
for _, line := range strings.Split(md, "\n") {
|
||||
if fenceMarker != "" {
|
||||
// Inside a fenced block — skip everything until the matching
|
||||
// closer. Code samples that show literal <callout type=...>
|
||||
// must not produce a phantom hint.
|
||||
if isCodeFenceClose(line, fenceMarker) {
|
||||
fenceMarker = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
if marker := codeFenceOpenMarker(line); marker != "" {
|
||||
fenceMarker = marker
|
||||
continue
|
||||
}
|
||||
scanCalloutTagsForWarning(line, w)
|
||||
}
|
||||
}
|
||||
|
||||
// scanCalloutTagsForWarning emits a hint to w for every <callout type="...">
|
||||
// tag in s that lacks an explicit background-color= attribute. Pulled out
|
||||
// of WarnCalloutType so the line walker only handles fence state and the
|
||||
// per-tag scan is its own readable unit.
|
||||
//
|
||||
// The previous implementation routed the tag iteration through
|
||||
// calloutOpenTagRe.ReplaceAllStringFunc with a callback that always
|
||||
// returned the original tag and threw the rebuilt string away — using a
|
||||
// rewrite primitive purely for its iteration side-effect, plus a second
|
||||
// regex execution to recover the capture groups inside the callback.
|
||||
// FindAllStringSubmatch hands us both the iteration and the groups in one
|
||||
// pass, no allocation thrown away.
|
||||
func scanCalloutTagsForWarning(s string, w io.Writer) {
|
||||
for _, m := range calloutOpenTagRe.FindAllStringSubmatch(s, -1) {
|
||||
attrs := m[1]
|
||||
// Skip tags that already carry an explicit background-color.
|
||||
if calloutBackgroundColorAttrRe.MatchString(attrs) {
|
||||
continue
|
||||
}
|
||||
parts := calloutTypeAttrRe.FindStringSubmatch(attrs)
|
||||
if len(parts) < 3 {
|
||||
continue // no type= attribute
|
||||
}
|
||||
// parts[1] is the double-quoted capture, parts[2] is single-quoted.
|
||||
typeName := parts[1]
|
||||
if typeName == "" {
|
||||
typeName = parts[2]
|
||||
}
|
||||
colors, ok := calloutTypeColors[typeName]
|
||||
if !ok {
|
||||
continue // unknown type — no hint to give
|
||||
}
|
||||
fmt.Fprintf(w,
|
||||
"hint: callout type=%q has no background-color; consider: background-color=%q border-color=%q\n",
|
||||
typeName, colors[0], colors[1])
|
||||
}
|
||||
}
|
||||
|
||||
// calloutEmojiAliases maps named emoji strings that fetch-doc emits to actual
|
||||
// Unicode emoji characters that create-doc accepts.
|
||||
var calloutEmojiAliases = map[string]string{
|
||||
|
||||
@@ -359,6 +359,135 @@ func TestFixExportedMarkdown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnCalloutType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantHint bool // whether a hint line is expected
|
||||
hintContains string // substring the hint must contain
|
||||
}{
|
||||
{
|
||||
name: "warning type without background-color emits hint",
|
||||
input: `<callout type="warning" emoji="📝">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-yellow"`,
|
||||
},
|
||||
{
|
||||
name: "info type without background-color emits hint",
|
||||
input: `<callout type="info" emoji="ℹ️">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-blue"`,
|
||||
},
|
||||
{
|
||||
name: "single-quoted type attribute emits hint",
|
||||
input: `<callout type='warning' emoji="📝">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-yellow"`,
|
||||
},
|
||||
{
|
||||
name: "explicit background-color suppresses hint",
|
||||
input: `<callout type="warning" emoji="📝" background-color="light-red">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "whitespace around equals is tolerated in background-color",
|
||||
input: `<callout type="warning" emoji="📝" background-color = "light-red">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "unknown type emits no hint",
|
||||
input: `<callout type="custom" emoji="🔥">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "no type attribute emits no hint",
|
||||
input: `<callout emoji="💡" background-color="light-green">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "non-callout tag emits no hint",
|
||||
input: `<div type="warning">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "hint includes border-color suggestion",
|
||||
input: `<callout type="error" emoji="❌">`,
|
||||
wantHint: true,
|
||||
hintContains: `border-color="red"`,
|
||||
},
|
||||
{
|
||||
// Regression: the old `\btype=` regex matched the suffix of
|
||||
// `data-type=` because `-` is a non-word character, so a tag
|
||||
// carrying only data-attrs would silently get a bogus hint.
|
||||
// The (?:^|\s) anchor requires a real attribute separator.
|
||||
name: "data-type attribute does not trigger hint",
|
||||
input: `<callout data-type="warning" emoji="📝">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// Symmetric guard for the background-color regex: a future
|
||||
// `data-background-color=` attribute must not be mistaken
|
||||
// for a present background-color and silently suppress the
|
||||
// hint that the real type= would otherwise produce.
|
||||
name: "data-background-color does not suppress hint",
|
||||
input: `<callout type="warning" data-background-color="anything">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-yellow"`,
|
||||
},
|
||||
{
|
||||
// Regression for the code-fence skip: a documentation sample
|
||||
// inside a ``` fence is NOT a real callout the user wants
|
||||
// rendered, so it must produce no stderr noise.
|
||||
name: "callout inside backtick fence emits no hint",
|
||||
input: "```markdown\n" +
|
||||
`<callout type="warning" emoji="📝">` + "\n" +
|
||||
"```\n",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// Same skip works for tilde fences (CommonMark §4.5 makes
|
||||
// `~~~` an equivalent fence character).
|
||||
name: "callout inside tilde fence emits no hint",
|
||||
input: "~~~markdown\n" +
|
||||
`<callout type="info" emoji="ℹ️">` + "\n" +
|
||||
"~~~\n",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// Closing the fence must restore normal scanning: a real
|
||||
// callout that follows a documentation block still gets a
|
||||
// hint. Pins that fenceMarker is reset, not stuck.
|
||||
name: "callout after fence close still emits hint",
|
||||
input: "```markdown\n" +
|
||||
`<callout type="warning">sample</callout>` + "\n" +
|
||||
"```\n" +
|
||||
`<callout type="error" emoji="❌">real</callout>` + "\n",
|
||||
wantHint: true,
|
||||
hintContains: `border-color="red"`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
WarnCalloutType(tt.input, &buf)
|
||||
got := buf.String()
|
||||
if tt.wantHint {
|
||||
if got == "" {
|
||||
t.Errorf("WarnCalloutType(%q): expected hint, got no output", tt.input)
|
||||
return
|
||||
}
|
||||
if tt.hintContains != "" && !strings.Contains(got, tt.hintContains) {
|
||||
t.Errorf("WarnCalloutType(%q): hint %q missing %q", tt.input, got, tt.hintContains)
|
||||
}
|
||||
} else {
|
||||
if got != "" {
|
||||
t.Errorf("WarnCalloutType(%q): expected no output, got %q", tt.input, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixCalloutEmoji(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -3,7 +3,24 @@
|
||||
|
||||
package doc
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const docsServiceHelpDefault = `Document and content operations.`
|
||||
|
||||
const docsServiceHelpV2 = `Document and content operations (v2).`
|
||||
|
||||
var docsVersionSelectionTips = []string{
|
||||
"Agent version rule: use --api-version v2 only when the installed lark-doc skill explicitly instructs docs +create, docs +fetch, or docs +update to use v2; otherwise use the default v1 flags.",
|
||||
"Do not mix versions: if the skill does not mention v2, follow its legacy v1 examples and flags.",
|
||||
}
|
||||
|
||||
// Shortcuts returns all docs shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
@@ -18,3 +35,48 @@ func Shortcuts() []common.Shortcut {
|
||||
DocMediaDownload,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command.
|
||||
// The shortcut-level help remains compatible with legacy v1 skills; this parent
|
||||
// help gives agents enough context to choose v2 only when their installed skill
|
||||
// explicitly asks for `--api-version v2`.
|
||||
func ConfigureServiceHelp(cmd *cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
serviceCmd := cmd
|
||||
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
|
||||
if cmd.Flags().Lookup("api-version") == nil {
|
||||
cmd.Flags().String("api-version", "", "show docs help for API version (v1|v2)")
|
||||
cmdutil.RegisterFlagCompletion(cmd, "api-version", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"v1", "v2"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
|
||||
defaultHelp := cmd.HelpFunc()
|
||||
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd != serviceCmd {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
|
||||
apiVersion, _ := cmd.Flags().GetString("api-version")
|
||||
previousLong := cmd.Long
|
||||
if apiVersion == "v2" {
|
||||
cmd.Long = strings.TrimSpace(docsServiceHelpV2)
|
||||
} else {
|
||||
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
|
||||
}
|
||||
defer func() {
|
||||
cmd.Long = previousLong
|
||||
}()
|
||||
|
||||
defaultHelp(cmd, args)
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Tips:")
|
||||
for _, tip := range docsVersionSelectionTips {
|
||||
fmt.Fprintf(out, " • %s\n", tip)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,13 +30,6 @@ func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersion
|
||||
}
|
||||
})
|
||||
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",
|
||||
cmd.Parent().Name(), cmd.Name())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,35 @@ import (
|
||||
|
||||
const defaultLocateDocLimit = 10
|
||||
|
||||
// maxCommentTotalRunes is the cap on the combined character (rune) count
|
||||
// across all `reply_elements[].text` fields in a single
|
||||
// `drive +add-comment` request.
|
||||
//
|
||||
// The open-platform `/open-apis/drive/v1/files/{token}/new_comments`
|
||||
// endpoint returns an opaque `[1069302] Invalid or missing parameters`
|
||||
// when this is exceeded — no indication that length is the cause or
|
||||
// which element is at fault.
|
||||
//
|
||||
// Empirically (probing the live API):
|
||||
//
|
||||
// - 10000 runes in a single text element: OK (10000 ASCII / 30000
|
||||
// bytes for Chinese / 40000 bytes if all '<' — server counts the
|
||||
// raw rune count, not byte width and not the post-escape form)
|
||||
// - 10001 runes in a single text element: [1069302]
|
||||
// - 5000 + 5000 across two elements (total 10000): OK
|
||||
// - 5000 + 5001 across two elements (total 10001): [1069302]
|
||||
//
|
||||
// So the cap is applied to the *total* across all reply_elements, not
|
||||
// per element. Splitting an over-the-cap message into multiple text
|
||||
// elements does NOT help — the server enforces the same limit on the
|
||||
// sum.
|
||||
//
|
||||
// The schema doc currently advertises a 1-1000 character limit, but
|
||||
// the live API accepts up to 10000 runes; the schema is out of date.
|
||||
// If this constant ever needs to track a server-side change, re-probe
|
||||
// with `drive file.comments create_v2` against a fresh docx.
|
||||
const maxCommentTotalRunes = 10000
|
||||
|
||||
type commentDocRef struct {
|
||||
Kind string
|
||||
Token string
|
||||
@@ -604,6 +633,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
replyElements := make([]map[string]interface{}, 0, len(inputs))
|
||||
totalRunes := 0
|
||||
for i, input := range inputs {
|
||||
index := i + 1
|
||||
elementType := strings.TrimSpace(input.Type)
|
||||
@@ -612,9 +642,27 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
if strings.TrimSpace(input.Text) == "" {
|
||||
return nil, output.ErrValidation("--content element #%d type=text requires non-empty text", index)
|
||||
}
|
||||
if utf8.RuneCountInString(input.Text) > 1000 {
|
||||
return nil, output.ErrValidation("--content element #%d text exceeds 1000 characters", index)
|
||||
// Measure the raw rune count of the user input — that is what
|
||||
// the server actually counts. byte width and post-escape form
|
||||
// don't matter (10000 '<' chars succeed even though they
|
||||
// expand to 40000 bytes when escaped, and 10000 Chinese chars
|
||||
// succeed even though they encode as 30000 UTF-8 bytes).
|
||||
runes := utf8.RuneCountInString(input.Text)
|
||||
totalRunes += runes
|
||||
if totalRunes > maxCommentTotalRunes {
|
||||
return nil, output.ErrWithHint(
|
||||
output.ExitValidation,
|
||||
"text_too_long",
|
||||
fmt.Sprintf("--content reply_elements text totals %d characters at element #%d (this element: %d); the server caps the combined length at %d characters across ALL reply_elements",
|
||||
totalRunes, index, runes, maxCommentTotalRunes),
|
||||
fmt.Sprintf("shorten the comment so the combined text across all reply_elements fits within %d characters. The server enforces this cap on the TOTAL — splitting one long element into multiple smaller text elements does NOT help (they all add up against the same %d-rune budget). Server returns an opaque [1069302] on overflow, so this check is pre-flight; no escape transform changes the count (server reads raw runes).", maxCommentTotalRunes, maxCommentTotalRunes),
|
||||
)
|
||||
}
|
||||
// Escape '<' and '>' so the rendered comment displays them as
|
||||
// literal characters instead of being interpreted as markup
|
||||
// by Lark's comment renderer. This is independent of the
|
||||
// length check — the server sees the escaped form, but
|
||||
// counts characters by the raw input length above.
|
||||
replyElements = append(replyElements, map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": escapeCommentText(input.Text),
|
||||
|
||||
@@ -5,11 +5,13 @@ package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func decodeJSONMap(t *testing.T, raw string) map[string]interface{} {
|
||||
@@ -292,6 +294,186 @@ func TestParseCommentReplyElementsEscapesAngleBrackets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentReplyElementsTextLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Cap is 10000 runes total across all reply_elements text fields,
|
||||
// empirically derived from the live API. See the comment on
|
||||
// maxCommentTotalRunes for the probe results.
|
||||
exactCapASCII := strings.Repeat("a", 10000)
|
||||
overCapASCII := strings.Repeat("a", 10001)
|
||||
|
||||
// Chinese chars cost 3 bytes each in UTF-8 but the server counts
|
||||
// runes, not bytes — so the cap is the same 10000 here.
|
||||
exactCapCJK := strings.Repeat("文", 10000)
|
||||
overCapCJK := strings.Repeat("文", 10001)
|
||||
|
||||
// '<' would expand to '<' (4 bytes) under escapeCommentText, but
|
||||
// since the server counts raw runes the cap is still 10000 chars,
|
||||
// not 2500. This pins that distinction.
|
||||
exactCapAngle := strings.Repeat("<", 10000)
|
||||
overCapAngle := strings.Repeat("<", 10001)
|
||||
|
||||
// Two-element split exactly hitting the cap together.
|
||||
splitFiveK := strings.Repeat("a", 5000)
|
||||
splitFiveKPlusOne := strings.Repeat("a", 5001)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
wantHint string // substring of the hint portion; "" means don't check hint
|
||||
wantCount int // expected parsed element count when no error expected
|
||||
}{
|
||||
{
|
||||
name: "single element exactly at 10000 ASCII chars accepted",
|
||||
input: `[{"type":"text","text":"` + exactCapASCII + `"}]`,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "single element at 10001 ASCII chars rejected",
|
||||
input: `[{"type":"text","text":"` + overCapASCII + `"}]`,
|
||||
wantErr: "totals 10001 characters at element #1",
|
||||
wantHint: "splitting one long element into multiple smaller text elements does NOT help",
|
||||
},
|
||||
{
|
||||
name: "single element exactly at 10000 chinese chars accepted (server counts runes, not bytes)",
|
||||
input: `[{"type":"text","text":"` + exactCapCJK + `"}]`,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "single element at 10001 chinese chars rejected",
|
||||
input: `[{"type":"text","text":"` + overCapCJK + `"}]`,
|
||||
wantErr: "totals 10001 characters at element #1",
|
||||
},
|
||||
{
|
||||
name: "10000 angle brackets accepted (server counts raw runes, not escaped form)",
|
||||
input: `[{"type":"text","text":"` + exactCapAngle + `"}]`,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "10001 angle brackets rejected (escape state irrelevant to cap)",
|
||||
input: `[{"type":"text","text":"` + overCapAngle + `"}]`,
|
||||
wantErr: "totals 10001 characters at element #1",
|
||||
},
|
||||
{
|
||||
// Pins the multi-element TOTAL cap: two 5000-char elements
|
||||
// fit together exactly (10000 sum). This is the boundary the
|
||||
// previous PR's "split into multiple elements" advice
|
||||
// implied was a workaround — it's actually only valid if
|
||||
// the sum still fits.
|
||||
name: "two elements totalling exactly 10000 accepted",
|
||||
input: `[{"type":"text","text":"` + splitFiveK + `"},{"type":"text","text":"` + splitFiveK + `"}]`,
|
||||
wantCount: 2,
|
||||
},
|
||||
{
|
||||
// Companion to the above and the headline reason the prior
|
||||
// "split into multiple elements" hint is wrong: 5000+5001
|
||||
// sums to 10001 which the server rejects with the same
|
||||
// opaque [1069302], regardless of how many elements it's
|
||||
// distributed across.
|
||||
name: "two elements totalling 10001 rejected with index pointing at offending element",
|
||||
input: `[{"type":"text","text":"` + splitFiveK + `"},{"type":"text","text":"` + splitFiveKPlusOne + `"}]`,
|
||||
wantErr: "totals 10001 characters at element #2",
|
||||
wantHint: "splitting one long element into multiple smaller text elements does NOT help",
|
||||
},
|
||||
{
|
||||
// Streaming-cap correctness: when an EARLY element by itself
|
||||
// already overshoots, the index reported is that early
|
||||
// element (not the last one in the array).
|
||||
name: "first element over the cap reports index 1",
|
||||
input: `[{"type":"text","text":"` + overCapASCII + `"},{"type":"text","text":"trailing"}]`,
|
||||
wantErr: "totals 10001 characters at element #1",
|
||||
},
|
||||
{
|
||||
// mention_user / link elements don't count toward the
|
||||
// rune cap (their content is ID / URL, not user-visible
|
||||
// running text). Pin that a moderate text plus a mention
|
||||
// stays accepted even though the mention adds bytes.
|
||||
name: "text plus mention_user does not double-count toward cap",
|
||||
input: `[{"type":"text","text":"` + exactCapASCII + `"},{"type":"mention_user","text":"ou_1234567890abcdef"}]`,
|
||||
wantCount: 2,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := parseCommentReplyElements(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil (parsed %d elements)", tt.wantErr, len(got))
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
|
||||
}
|
||||
if tt.wantHint != "" {
|
||||
// Hint lives on ExitError.Detail.Hint, not err.Error().
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
|
||||
t.Errorf("expected hint substring %q, got %q", tt.wantHint, exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != tt.wantCount {
|
||||
t.Fatalf("expected %d reply elements, got %d", tt.wantCount, len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCommentReplyElementsHintForbidsSplitAdvice pins that the
|
||||
// over-cap hint does NOT recommend splitting into multiple text
|
||||
// elements as a workaround. An earlier version of this PR shipped
|
||||
// that advice; live-API probing showed the cap is on the *total* run
|
||||
// of characters across all reply_elements, so splitting doesn't
|
||||
// bypass it. If the hint ever drifts back into recommending a split,
|
||||
// users will be sent down a dead end where their first attempt fails
|
||||
// pre-flight, their "fixed" attempt also fails server-side, and
|
||||
// they're stuck.
|
||||
func TestParseCommentReplyElementsHintForbidsSplitAdvice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseCommentReplyElements(`[{"type":"text","text":"` + strings.Repeat("a", 10001) + `"}]`)
|
||||
if err == nil {
|
||||
t.Fatal("expected over-cap error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
|
||||
}
|
||||
hint := exitErr.Detail.Hint
|
||||
|
||||
// The hint must explicitly call out that splitting does NOT help.
|
||||
if !strings.Contains(hint, "does NOT help") {
|
||||
t.Errorf("hint must explicitly say splitting does NOT help, got: %q", hint)
|
||||
}
|
||||
// Anti-pattern check: the hint must not phrase any "split into
|
||||
// multiple elements" recommendation as a workaround. Look for the
|
||||
// previous PR's exact phrasing variants.
|
||||
for _, banned := range []string{
|
||||
"split the content across multiple",
|
||||
"split into multiple text elements",
|
||||
"renders them as one contiguous comment",
|
||||
} {
|
||||
if strings.Contains(hint, banned) {
|
||||
t.Errorf("hint must not contain the discredited %q advice, got: %q", banned, hint)
|
||||
}
|
||||
}
|
||||
// And it should reference the actual number so callers know the
|
||||
// budget without having to read the source.
|
||||
if !strings.Contains(hint, "10000") {
|
||||
t.Errorf("hint should name the 10000-rune budget, got: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentReplyElementsInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "file-name", Desc: "preferred output filename (optional)"},
|
||||
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
@@ -54,14 +55,19 @@ var DriveExport = common.Shortcut{
|
||||
// Markdown export is a special case: docx markdown comes from docs content
|
||||
// directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
return common.NewDryRunAPI().
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
GET("/open-apis/docs/v1/content").
|
||||
Params(map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
})
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
@@ -73,10 +79,15 @@ var DriveExport = common.Shortcut{
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body)
|
||||
Body(body).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveExportSpec{
|
||||
@@ -86,6 +97,7 @@ var DriveExport = common.Shortcut{
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
@@ -106,14 +118,18 @@ var DriveExport = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName := ensureExportFileExtension(sanitizeExportFileName(title, spec.Token), spec.FileExtension)
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -166,7 +182,11 @@ var DriveExport = common.Shortcut{
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
fileName := ensureExportFileExtension(sanitizeExportFileName(status.FileName, spec.Token), spec.FileExtension)
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
@@ -227,7 +247,7 @@ var DriveExport = common.Shortcut{
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
runtime.Out(map[string]interface{}{
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
@@ -238,7 +258,11 @@ var DriveExport = common.Shortcut{
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}, nil)
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -130,6 +130,155 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# custom\n",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--file-name", "custom-notes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "# custom\n" {
|
||||
t.Fatalf("markdown content = %q", string(data))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_name": "custom-notes.md"`) {
|
||||
t.Fatalf("stdout missing provided file name: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
wantURL string
|
||||
wantFileName string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "markdown",
|
||||
wantURL: "/open-apis/docs/v1/content",
|
||||
wantFileName: `"file_name": "notes.md"`,
|
||||
args: []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--file-name", "notes",
|
||||
"--output-dir", "./exports",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "async export",
|
||||
wantURL: "/open-apis/drive/v1/export_tasks",
|
||||
wantFileName: `"file_name": "report.pdf"`,
|
||||
args: []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "report",
|
||||
"--output-dir", "./exports",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, tt.args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, tt.wantURL) {
|
||||
t.Fatalf("stdout missing URL %q: %s", tt.wantURL, out)
|
||||
}
|
||||
if !strings.Contains(out, tt.wantFileName) {
|
||||
t.Fatalf("stdout missing file_name metadata %q: %s", tt.wantFileName, out)
|
||||
}
|
||||
if !strings.Contains(out, `"output_dir": "./exports"`) {
|
||||
t.Fatalf("stdout missing output_dir metadata: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# fallback\n",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "metadata unavailable",
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "# fallback\n" {
|
||||
t.Fatalf("markdown content = %q", string(data))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_name": "docx123.md"`) {
|
||||
t.Fatalf("stdout missing fallback file name: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -200,6 +349,77 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_custom"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_custom",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_custom",
|
||||
"file_name": "server-name",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_custom/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="server-name.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "custom-report",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "pdf" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_name": "custom-report.pdf"`) {
|
||||
t.Fatalf("stdout missing provided file name: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
@@ -425,6 +645,51 @@ func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportTimeoutPreservesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_name"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_name",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "quarterly-report",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_name": "quarterly-report.pdf"`) {
|
||||
t.Fatalf("stdout missing preserved file name: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
337
shortcuts/drive/drive_pull.go
Normal file
337
shortcuts/drive/drive_pull.go
Normal file
@@ -0,0 +1,337 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
drivePullIfExistsOverwrite = "overwrite"
|
||||
drivePullIfExistsSkip = "skip"
|
||||
)
|
||||
|
||||
type drivePullItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DrivePull performs a one-way file-level mirror from a Drive folder onto
|
||||
// a local directory: recursively lists --folder-token, downloads each
|
||||
// type=file entry under --local-dir, and optionally deletes local files
|
||||
// absent from Drive (--delete-local --yes).
|
||||
//
|
||||
// Only Drive entries with type=file participate; online docs (docx, sheet,
|
||||
// bitable, mindnote, slides) and shortcuts are skipped because there is no
|
||||
// equivalent local binary to write back. Directories are reproduced when
|
||||
// remote folders contain downloadable files, but local directories that
|
||||
// become orphaned after a remote folder is removed are NOT pruned —
|
||||
// --delete-local only unlinks regular files.
|
||||
var DrivePull = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+pull",
|
||||
Description: "One-way file-level mirror of a Drive folder onto a local directory (Drive → local)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "source Drive folder token", Required: true},
|
||||
{Name: "if-exists", Desc: "policy when a local file already exists", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}},
|
||||
{Name: "delete-local", Type: "bool", Desc: "delete local regular files absent from Drive (file-level mirror; empty directories are NOT pruned); requires --yes"},
|
||||
{Name: "yes", Type: "bool", Desc: "confirm --delete-local before deleting local files"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Only entries with type=file are downloaded; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
||||
"Subfolders recurse and are reproduced as local directories under --local-dir; missing parents are created automatically.",
|
||||
"--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
if localDir == "" {
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
}
|
||||
if runtime.Bool("delete-local") && !runtime.Bool("yes") {
|
||||
return output.ErrValidation("--delete-local requires --yes (high-risk: deletes local files absent from Drive)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Recursively list --folder-token, download each type=file entry into --local-dir, and (when --delete-local --yes is set) remove local files absent from Drive.").
|
||||
GET("/open-apis/drive/v1/files").
|
||||
Set("folder_token", runtime.Str("folder-token"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
ifExists := strings.TrimSpace(runtime.Str("if-exists"))
|
||||
if ifExists == "" {
|
||||
ifExists = drivePullIfExistsOverwrite
|
||||
}
|
||||
deleteLocal := runtime.Bool("delete-local")
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before we
|
||||
// touch the filesystem. SafeInputPath fully evaluates symlinks
|
||||
// across the entire path; this matters because filepath.Clean
|
||||
// alone shrinks "link/.." to "." while the kernel resolves it
|
||||
// through the symlink target's parent — meaning a raw walk on
|
||||
// the user-supplied string can land outside cwd. Walking the
|
||||
// canonical root sidesteps that, and using cwd canonical lets
|
||||
// us emit cwd-relative download targets that FileIO.Save's
|
||||
// SafeOutputPath check still accepts. The risk is much higher
|
||||
// here than in +status because --delete-local would otherwise
|
||||
// remove the wrong files outside cwd.
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return output.ErrValidation("could not resolve cwd: %s", err)
|
||||
}
|
||||
// rootRelToCwd is the localDir form FileIO.Save accepts (it
|
||||
// rejects absolute paths). For cwd itself it becomes ".", which
|
||||
// joins cleanly with the rel_paths returned by the lister.
|
||||
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Two views over the same listing:
|
||||
// - remoteFiles drives the download/skip loop (only type=file
|
||||
// has hashable bytes the local mirror can write back).
|
||||
// - remotePaths is the --delete-local guard: it carries every
|
||||
// rel_path Drive owns regardless of type, so a local file
|
||||
// shadowed by a remote folder / online doc / shortcut is NOT
|
||||
// treated as orphaned.
|
||||
remoteFiles := make(map[string]string, len(entries))
|
||||
remotePaths := make(map[string]struct{}, len(entries))
|
||||
for rel, entry := range entries {
|
||||
remotePaths[rel] = struct{}{}
|
||||
if entry.Type == driveTypeFile {
|
||||
remoteFiles[rel] = entry.FileToken
|
||||
}
|
||||
}
|
||||
|
||||
var downloaded, skipped, failed, deletedLocal int
|
||||
downloadFailed := 0
|
||||
items := make([]drivePullItem, 0)
|
||||
|
||||
// Deterministic iteration order for output stability.
|
||||
downloadablePaths := make([]string, 0, len(remoteFiles))
|
||||
for p := range remoteFiles {
|
||||
downloadablePaths = append(downloadablePaths, p)
|
||||
}
|
||||
sort.Strings(downloadablePaths)
|
||||
|
||||
for _, rel := range downloadablePaths {
|
||||
token := remoteFiles[rel]
|
||||
target := filepath.Join(rootRelToCwd, rel)
|
||||
|
||||
if info, statErr := runtime.FileIO().Stat(target); statErr == nil {
|
||||
// Mirror conflict: remote is a regular file but local
|
||||
// has a directory at the same rel_path. Neither
|
||||
// "skipped" nor "downloaded" describes reality —
|
||||
// SafeOutputPath would refuse to write a file over a
|
||||
// directory, and pretending the directory is a
|
||||
// pre-existing file under --if-exists=skip silently
|
||||
// hides the conflict. Surface as a failure.
|
||||
if info.IsDir() {
|
||||
items = append(items, drivePullItem{
|
||||
RelPath: rel,
|
||||
FileToken: token,
|
||||
Action: "failed",
|
||||
Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target),
|
||||
})
|
||||
failed++
|
||||
downloadFailed++
|
||||
continue
|
||||
}
|
||||
if ifExists == drivePullIfExistsSkip {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "skipped"})
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := drivePullDownload(ctx, runtime, token, target); err != nil {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "failed", Error: err.Error()})
|
||||
failed++
|
||||
downloadFailed++
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "downloaded"})
|
||||
downloaded++
|
||||
}
|
||||
|
||||
// Gate --delete-local on a clean download pass. With download
|
||||
// failures still in items[], proceeding to the delete walk would
|
||||
// leave the mirror in a half-synced state where some files Drive
|
||||
// owns are missing locally AND some local-only files have been
|
||||
// removed. Surface the failure first; the operator can re-run
|
||||
// after fixing whatever caused the download error.
|
||||
if deleteLocal && downloadFailed == 0 {
|
||||
// Walk the canonical absolute root, build the list of
|
||||
// rel_paths, then delete via the absolute path. Both
|
||||
// values come from the validated safeRoot, so kernel
|
||||
// path resolution cannot redirect the delete to a file
|
||||
// outside the canonical subtree.
|
||||
localAbsPaths, err := drivePullWalkLocal(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, absPath := range localAbsPaths {
|
||||
rel, relErr := filepath.Rel(safeRoot, absPath)
|
||||
if relErr != nil {
|
||||
items = append(items, drivePullItem{RelPath: absPath, Action: "delete_failed", Error: relErr.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
// Consult remotePaths (every Drive entry, regardless of
|
||||
// type) rather than remoteFiles (downloadable subset
|
||||
// only). Otherwise an online doc / shortcut at e.g.
|
||||
// "notes.docx" would leave a same-named local file
|
||||
// looking orphaned and get unlinked even though Drive
|
||||
// still knows about that path.
|
||||
if _, ok := remotePaths[rel]; ok {
|
||||
continue
|
||||
}
|
||||
// FileIO has no Remove(); the absolute path comes from
|
||||
// walking safeRoot, which validate.SafeInputPath has
|
||||
// already bounded inside cwd, so a bare os.Remove is
|
||||
// acceptable here. Shortcuts cannot import internal/vfs
|
||||
// directly (depguard rule shortcuts-no-vfs).
|
||||
if err := os.Remove(absPath); err != nil { //nolint:forbidigo // see comment above
|
||||
items = append(items, drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePullItem{RelPath: rel, Action: "deleted_local"})
|
||||
deletedLocal++
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"downloaded": downloaded,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_local": deletedLocal,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
|
||||
// Item-level failures (download error, dir/file conflict, delete
|
||||
// error) must surface as a non-zero exit so AI / script callers
|
||||
// don't have to reach into summary.failed to detect a partial
|
||||
// sync. The same structured payload rides along in error.detail
|
||||
// so forensics aren't lost. When --delete-local was skipped
|
||||
// because of an earlier download failure, callers see
|
||||
// deleted_local=0 plus the download failure that aborted it,
|
||||
// which is what makes the partial state self-explanatory.
|
||||
if failed > 0 {
|
||||
msg := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed)
|
||||
if deleteLocal && downloadFailed > 0 {
|
||||
msg += " (--delete-local was skipped because the download pass had failures)"
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "partial_failure",
|
||||
Message: msg,
|
||||
Detail: payload,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(payload, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target string) error {
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: "GET",
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body); err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drivePullWalkLocal walks the canonical absolute root and returns the
|
||||
// absolute paths of every regular file underneath it. The caller deletes
|
||||
// some of these paths, so it is critical that they are produced by
|
||||
// walking a canonical root (no symlinks in the path) — otherwise OS path
|
||||
// resolution could redirect a delete to a file outside cwd. Same threat
|
||||
// model as drive_status.go.
|
||||
func drivePullWalkLocal(root string) ([]string, error) {
|
||||
var paths []string
|
||||
// FileIO has no walker today; shortcuts cannot import internal/vfs
|
||||
// (depguard rule shortcuts-no-vfs). The root passed in is the
|
||||
// canonical absolute path returned by validate.SafeInputPath, so
|
||||
// WalkDir's default "do not follow child symlinks" policy keeps the
|
||||
// traversal inside the validated subtree.
|
||||
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
paths = append(paths, absPath)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
1026
shortcuts/drive/drive_pull_test.go
Normal file
1026
shortcuts/drive/drive_pull_test.go
Normal file
File diff suppressed because it is too large
Load Diff
717
shortcuts/drive/drive_push.go
Normal file
717
shortcuts/drive/drive_push.go
Normal file
@@ -0,0 +1,717 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
drivePushIfExistsOverwrite = "overwrite"
|
||||
drivePushIfExistsSkip = "skip"
|
||||
)
|
||||
|
||||
type drivePushItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Version string `json:"version,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
||||
// Drive folder: walks --local-dir, recursively lists --folder-token, and for
|
||||
// each rel_path uploads (or overwrites) the corresponding Drive file. With
|
||||
// --delete-remote --yes, any type=file entry on Drive that has no local
|
||||
// counterpart is removed; online docs (docx/sheet/bitable/...), shortcuts
|
||||
// and folders are never deleted, so this is "file-level" mirror — the
|
||||
// command does not attempt to remove remote-only directories or close gaps
|
||||
// in directory structure that exists on Drive but not locally.
|
||||
//
|
||||
// Only Drive entries with type=file participate in upload/overwrite/delete;
|
||||
// online documents have no equivalent local binary. Sub-folders are created
|
||||
// on Drive on demand via /open-apis/drive/v1/files/create_folder so the
|
||||
// remote tree mirrors the local tree.
|
||||
//
|
||||
// The overwrite path passes the existing file_token as a form field on
|
||||
// /open-apis/drive/v1/files/upload_all, mirroring the markdown +overwrite
|
||||
// contract in shortcuts/markdown. The Drive backend exposing that field is
|
||||
// being rolled out; until rollout completes, --if-exists defaults to "skip"
|
||||
// so the safe path (do not touch existing remote files) is the default and
|
||||
// callers must opt into "overwrite" explicitly.
|
||||
var DrivePush = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+push",
|
||||
Description: "File-level mirror of a local directory onto a Drive folder (local → Drive; remote-only directories are not removed)",
|
||||
Risk: "write",
|
||||
// Narrowed scopes follow the precedent set by drive +status / +pull:
|
||||
// drive:drive is policy-disabled in some tenants, so this shortcut sticks
|
||||
// to the smallest set the *core* path needs. space:folder:create is
|
||||
// always declared because mirroring a non-flat tree calls
|
||||
// /open-apis/drive/v1/files/create_folder on demand and we want the
|
||||
// framework's pre-flight scope check to catch missing grants before any
|
||||
// upload — otherwise a partial push could land top-level files and then
|
||||
// trip on a missing folder grant for a sub-tree, leaving a half-synced
|
||||
// state.
|
||||
//
|
||||
// space:document:delete is intentionally NOT in the default set even
|
||||
// though --delete-remote needs it. The framework pre-check (runner.go
|
||||
// checkShortcutScopes) runs unconditionally before Validate / dry-run,
|
||||
// so declaring it here would make every plain push (and every
|
||||
// --dry-run) fail for callers that only granted upload scopes.
|
||||
//
|
||||
// Instead, Validate runs a *conditional* pre-flight via
|
||||
// runtime.EnsureScopes when both --delete-remote and --yes are on, so
|
||||
// the missing grant fails the run upfront — before any upload —
|
||||
// rather than landing files first and tripping on missing_scope when
|
||||
// the cleanup pass tries to delete. That avoids the half-synced state
|
||||
// (files uploaded, orphans never cleaned up) that the unconditional
|
||||
// pre-check would otherwise prevent only by also blocking plain
|
||||
// pushes.
|
||||
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:upload", "space:folder:create"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "target Drive folder token", Required: true},
|
||||
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (default: skip — safe; opt into overwrite explicitly while the backend version field is rolling out)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSkip}},
|
||||
{Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"},
|
||||
{Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"},
|
||||
},
|
||||
Tips: []string{
|
||||
"This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.",
|
||||
"Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.",
|
||||
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero.",
|
||||
"--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
|
||||
"--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.",
|
||||
"Item-level failures (upload, overwrite, folder, delete) bump summary.failed and the run exits non-zero. If any upload or folder step fails, the --delete-remote phase is skipped entirely so a partial upload never triggers remote deletion.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
if localDir == "" {
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
}
|
||||
if runtime.Bool("delete-remote") && !runtime.Bool("yes") {
|
||||
return output.ErrValidation("--delete-remote requires --yes (high-risk: deletes Drive files absent locally)")
|
||||
}
|
||||
// Conditional scope pre-check: when --delete-remote --yes is set, the
|
||||
// run will issue DELETE /open-apis/drive/v1/files/<token> after the
|
||||
// upload phase. The default Scopes list intentionally omits
|
||||
// space:document:delete so plain pushes don't get blocked on a grant
|
||||
// they don't need (see the Scopes block above), but at this point we
|
||||
// know the run will need it — pre-flight here so a missing grant
|
||||
// fails before any upload, instead of after, which would otherwise
|
||||
// leave the tenant in a half-synced state (files uploaded, remote
|
||||
// orphans never cleaned up). EnsureScopes is a silent no-op when no
|
||||
// token / scope metadata is available, so test envs and tenants
|
||||
// where the resolver doesn't expose scopes still proceed and rely on
|
||||
// the API-level missing_scope error.
|
||||
if runtime.Bool("delete-remote") && runtime.Bool("yes") {
|
||||
if err := runtime.EnsureScopes([]string{"space:document:delete"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Walk --local-dir, recursively list --folder-token, then upload new files, overwrite (when --if-exists=overwrite) or skip existing, and (when --delete-remote --yes is set) delete Drive files absent locally.").
|
||||
GET("/open-apis/drive/v1/files").
|
||||
Set("folder_token", runtime.Str("folder-token"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
ifExists := strings.TrimSpace(runtime.Str("if-exists"))
|
||||
if ifExists == "" {
|
||||
// Default to the safe "skip" policy: do not touch already-present
|
||||
// remote files. Callers must pass --if-exists=overwrite to opt
|
||||
// into the overwrite-with-version path that depends on the
|
||||
// rolling-out upload_all `file_token`/`version` protocol field.
|
||||
ifExists = drivePushIfExistsSkip
|
||||
}
|
||||
deleteRemote := runtime.Bool("delete-remote")
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before walking.
|
||||
// SafeInputPath fully evaluates symlinks across the entire path,
|
||||
// which closes the kernel-level escape route that filepath.Clean
|
||||
// alone misses (e.g. "link/.." string-cleans to "." but the kernel
|
||||
// resolves through link's target's parent). Walking the canonical
|
||||
// root sidesteps that, and the matching cwd canonical lets each
|
||||
// absolute walk hit be converted to a cwd-relative path that
|
||||
// FileIO.Open's SafeInputPath check still accepts.
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return output.ErrValidation("could not resolve cwd: %s", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
|
||||
localFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Two views over the same listing:
|
||||
// - remoteFiles drives upload / overwrite / orphan-delete
|
||||
// decisions (only type=file entries are upload candidates;
|
||||
// online docs / shortcuts are intentionally never overwritten
|
||||
// or deleted by --delete-remote).
|
||||
// - remoteFolders is the create_folder cache: lets the upload
|
||||
// path skip create_folder when an intermediate folder already
|
||||
// exists, and keeps directory recreation idempotent across
|
||||
// reruns.
|
||||
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
|
||||
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
|
||||
for rel, entry := range entries {
|
||||
switch entry.Type {
|
||||
case driveTypeFile:
|
||||
remoteFiles[rel] = entry
|
||||
case driveTypeFolder:
|
||||
remoteFolders[rel] = entry
|
||||
}
|
||||
}
|
||||
|
||||
var uploaded, skipped, failed, deletedRemote int
|
||||
items := make([]drivePushItem, 0)
|
||||
// uploadFailed tracks whether any folder-creation, upload or
|
||||
// overwrite step failed. The --delete-remote phase only runs when
|
||||
// this stays false: a partial upload that then proceeds to delete
|
||||
// remote orphans would leave the tenant half-synced (files missing
|
||||
// locally and now on Drive too), which is the worst-of-both-worlds
|
||||
// outcome the review flagged.
|
||||
uploadFailed := false
|
||||
|
||||
// folderCache holds rel_path → folder_token. Seeded from the remote
|
||||
// listing (so we don't recreate folders that already exist) and
|
||||
// extended in-place as drivePushEnsureFolder mints new ones.
|
||||
folderCache := map[string]string{"": folderToken}
|
||||
for relDir, entry := range remoteFolders {
|
||||
folderCache[relDir] = entry.FileToken
|
||||
}
|
||||
|
||||
// Mirror local directory structure first, so empty directories
|
||||
// are not silently dropped. Pre-creating also frees the upload
|
||||
// loop from doing on-demand mkdir for every file's parent chain
|
||||
// (the cache makes both paths idempotent, but pre-creation keeps
|
||||
// items[] in a tidy "folders, then files" shape).
|
||||
for _, relDir := range localDirs {
|
||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||
// Folder already exists on Drive — nothing to do; staying
|
||||
// silent (no items[] entry) avoids noise on reruns.
|
||||
continue
|
||||
}
|
||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||
items = append(items, drivePushItem{RelPath: relDir, Action: "failed", Error: ensureErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created"})
|
||||
}
|
||||
|
||||
// Upload local-only and overwrite/skip already-present files in a
|
||||
// stable order so output is reproducible.
|
||||
localPaths := make([]string, 0, len(localFiles))
|
||||
for p := range localFiles {
|
||||
localPaths = append(localPaths, p)
|
||||
}
|
||||
sort.Strings(localPaths)
|
||||
|
||||
for _, rel := range localPaths {
|
||||
localFile := localFiles[rel]
|
||||
|
||||
if entry, ok := remoteFiles[rel]; ok {
|
||||
if ifExists == drivePushIfExistsSkip {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "skipped", SizeBytes: localFile.Size})
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, folderToken)
|
||||
if upErr != nil {
|
||||
// Token contract on overwrite failure: an in-place
|
||||
// overwrite preserves the file's token, so the
|
||||
// existing entry.FileToken is normally still the
|
||||
// authoritative pointer to the (possibly already
|
||||
// rewritten) Drive file. But the protocol does not
|
||||
// strictly forbid the backend from minting a new
|
||||
// token, and a partial-success response can return a
|
||||
// non-empty file_token alongside an error (the
|
||||
// missing-version case below is the immediate
|
||||
// concern: bytes hit the disk, version field
|
||||
// missing, so we surface a structured error). Prefer
|
||||
// the freshly returned token when one was produced,
|
||||
// fall back to entry.FileToken otherwise — that way
|
||||
// callers still have a usable handle to whatever
|
||||
// state Drive ended up in.
|
||||
failedToken := token
|
||||
if failedToken == "" {
|
||||
failedToken = entry.FileToken
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: failedToken, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "overwritten", Version: version, SizeBytes: localFile.Size})
|
||||
uploaded++
|
||||
continue
|
||||
}
|
||||
|
||||
parentRel := drivePushParentRel(rel)
|
||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||
if ensureErr != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: ensureErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||
if upErr != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "uploaded", SizeBytes: localFile.Size})
|
||||
uploaded++
|
||||
}
|
||||
|
||||
// Skip the delete phase entirely on any upstream failure. The orphan
|
||||
// loop deletes by remote token and is unrecoverable; running it
|
||||
// after a failed upload risks deleting a file the partial upload
|
||||
// would have replaced on a successful re-run, leaving the tenant
|
||||
// in a worse state than where we started. Surface the skipped
|
||||
// delete as a hint in stderr so operators know the cleanup pass
|
||||
// is pending and can re-run after fixing the upload.
|
||||
if deleteRemote && uploadFailed {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"Skipping --delete-remote: %d earlier failure(s) — re-run after resolving them.\n",
|
||||
failed)
|
||||
}
|
||||
if deleteRemote && !uploadFailed {
|
||||
// Stable iteration order so failures (and tests) are deterministic.
|
||||
remoteRelPaths := make([]string, 0, len(remoteFiles))
|
||||
for p := range remoteFiles {
|
||||
remoteRelPaths = append(remoteRelPaths, p)
|
||||
}
|
||||
sort.Strings(remoteRelPaths)
|
||||
|
||||
for _, rel := range remoteRelPaths {
|
||||
if _, ok := localFiles[rel]; ok {
|
||||
continue
|
||||
}
|
||||
entry := remoteFiles[rel]
|
||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
|
||||
deletedRemote++
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"uploaded": uploaded,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_remote": deletedRemote,
|
||||
},
|
||||
"items": items,
|
||||
}, nil)
|
||||
// Bump the exit code on any item-level failure (upload, overwrite,
|
||||
// folder, or delete) so callers / scripts / agents can react. The
|
||||
// summary + items[] envelope was just written to stdout via Out(),
|
||||
// so ErrBare here only affects the exit code — the structured
|
||||
// per-item context is still in the stdout JSON.
|
||||
if failed > 0 {
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// drivePushLocalFile records what we need to upload a local regular file:
|
||||
// a rel_path used for output and Drive layout, the cwd-relative path that
|
||||
// FileIO.Open accepts, the file size (drives single/multipart selection),
|
||||
// and the basename used as Drive's file_name.
|
||||
type drivePushLocalFile struct {
|
||||
RelPath string
|
||||
OpenPath string
|
||||
FileName string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// drivePushWalkLocal walks the canonical absolute root produced by
|
||||
// SafeInputPath. Same threat model as +pull/+status: the validated root
|
||||
// is not a symlink itself, and WalkDir's default policy (do not follow
|
||||
// child symlinks) keeps the traversal inside that canonical subtree, so
|
||||
// the OpenPath we hand to FileIO.Open stays inside cwd.
|
||||
//
|
||||
// Returns two views:
|
||||
// - files: rel_path → file metadata; drives the upload/skip/overwrite loop.
|
||||
// - dirs: every non-root directory rel_path encountered. Used to mirror
|
||||
// empty directories (which would otherwise be silently dropped because
|
||||
// the upload loop only iterates files); non-empty directories appear
|
||||
// here too but are harmless because drivePushEnsureFolder is cached.
|
||||
func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFile, []string, error) {
|
||||
files := make(map[string]drivePushLocalFile)
|
||||
dirsSet := make(map[string]struct{})
|
||||
// FileIO has no walker today and shortcuts can't import internal/vfs
|
||||
// (depguard rule shortcuts-no-vfs). The walk root is the canonical
|
||||
// absolute path returned by validate.SafeInputPath, so it is no
|
||||
// longer a symlink itself, and WalkDir's default child-symlink
|
||||
// policy keeps the traversal inside the validated subtree.
|
||||
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(root, absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
if d.IsDir() {
|
||||
// Skip the root itself ("."): that is --folder-token, already
|
||||
// the parent we mirror into, not a sub-folder we need to
|
||||
// create.
|
||||
if relSlash != "." {
|
||||
dirsSet[relSlash] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
relToCwd, err := filepath.Rel(cwdCanonical, absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[relSlash] = drivePushLocalFile{
|
||||
RelPath: relSlash,
|
||||
OpenPath: relToCwd,
|
||||
FileName: filepath.Base(rel),
|
||||
Size: info.Size(),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
|
||||
}
|
||||
dirs := make([]string, 0, len(dirsSet))
|
||||
for d := range dirsSet {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
// Shallow-first ordering ensures parents are created before children;
|
||||
// drivePushEnsureFolder also handles parent recursion on its own, but
|
||||
// emitting items[] in shallow-first order matches what users expect.
|
||||
sort.Slice(dirs, func(i, j int) bool {
|
||||
di, dj := strings.Count(dirs[i], "/"), strings.Count(dirs[j], "/")
|
||||
if di != dj {
|
||||
return di < dj
|
||||
}
|
||||
return dirs[i] < dirs[j]
|
||||
})
|
||||
return files, dirs, nil
|
||||
}
|
||||
|
||||
// drivePushEnsureFolder ensures a folder chain (rel_dir relative to the root
|
||||
// folder identified by rootFolderToken) exists on Drive, creating any
|
||||
// missing segments via /open-apis/drive/v1/files/create_folder. Returns the
|
||||
// token of the deepest folder, suitable as parent_node for the upload.
|
||||
//
|
||||
// folderCache is shared with the caller so each segment is only created
|
||||
// once per push, and so subsequent uploads under the same sub-tree reuse
|
||||
// the freshly minted folder token without an extra round trip.
|
||||
func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext, rootFolderToken, relDir string, folderCache map[string]string) (string, error) {
|
||||
if token, ok := folderCache[relDir]; ok {
|
||||
return token, nil
|
||||
}
|
||||
parentRel, name := drivePushSplitRel(relDir)
|
||||
parentToken, err := drivePushEnsureFolder(ctx, runtime, rootFolderToken, parentRel, folderCache)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/files/create_folder",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"name": name,
|
||||
"folder_token": parentToken,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := common.GetString(data, "token")
|
||||
if token == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "create_folder for %q returned no folder token", relDir)
|
||||
}
|
||||
folderCache[relDir] = token
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// drivePushUploadFile uploads (or overwrites) a single local file. When
|
||||
// existingToken is non-empty, the request adds the file_token form field to
|
||||
// trigger overwrite-with-version semantics on the backend; the response is
|
||||
// expected to carry a non-empty `version`, which is propagated to the
|
||||
// caller for the items[].version field. When existingToken is empty, this
|
||||
// is a fresh upload under parentToken.
|
||||
//
|
||||
// Files larger than common.MaxDriveMediaUploadSinglePartSize fall back to
|
||||
// the three-step prepare/part/finish flow, which mirrors drive +upload's
|
||||
// existing multipart logic.
|
||||
func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
|
||||
if file.Size > common.MaxDriveMediaUploadSinglePartSize {
|
||||
token, err := drivePushUploadMultipart(ctx, runtime, file, existingToken, parentToken)
|
||||
// Multipart finish does not return version on the existing
|
||||
// /open-apis/drive/v1/files/upload_finish contract; surface an
|
||||
// empty version in that case rather than fabricating one. The
|
||||
// markdown +overwrite path has the same gap and is tracked for a
|
||||
// follow-up once the multipart endpoint exposes the field.
|
||||
return token, "", err
|
||||
}
|
||||
return drivePushUploadAll(ctx, runtime, file, existingToken, parentToken)
|
||||
}
|
||||
|
||||
func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
|
||||
f, err := runtime.FileIO().Open(file.OpenPath)
|
||||
if err != nil {
|
||||
return "", "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", file.FileName)
|
||||
fd.AddField("parent_type", driveUploadParentTypeExplorer)
|
||||
fd.AddField("parent_node", parentToken)
|
||||
fd.AddField("size", fmt.Sprintf("%d", file.Size))
|
||||
if existingToken != "" {
|
||||
// Overwrite mode: the backend interprets a non-empty file_token on
|
||||
// upload_all as "replace this file's content and bump its version",
|
||||
// matching the markdown +overwrite contract.
|
||||
fd.AddField("file_token", existingToken)
|
||||
}
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", "", err
|
||||
}
|
||||
return "", "", output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
|
||||
}
|
||||
// Extract the token before the larkCode check: the backend can produce
|
||||
// a partial-success response (code != 0 alongside a non-empty
|
||||
// data.file_token) where bytes have already landed under that token.
|
||||
// Returning "" here would force the caller to fall back to
|
||||
// entry.FileToken and silently lose the token Drive actually used,
|
||||
// defeating the overwrite-error token-stability handling in Execute.
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
token := common.GetString(data, "file_token")
|
||||
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return token, "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
|
||||
}
|
||||
if token == "" {
|
||||
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
}
|
||||
version := common.GetString(data, "version")
|
||||
if version == "" {
|
||||
// Some backends return the version under data_version; accept either
|
||||
// per the markdown +overwrite contract.
|
||||
version = common.GetString(data, "data_version")
|
||||
}
|
||||
if existingToken != "" && version == "" {
|
||||
// The protocol guarantees a non-empty version on overwrite. If the
|
||||
// deployed backend hasn't shipped the field yet we surface the gap
|
||||
// rather than report a phantom success — callers can downgrade to
|
||||
// --if-exists=skip in the meantime.
|
||||
return token, "", output.Errorf(output.ExitAPI, "api_error", "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
|
||||
}
|
||||
return token, version, nil
|
||||
}
|
||||
|
||||
func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, error) {
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": file.FileName,
|
||||
"parent_type": driveUploadParentTypeExplorer,
|
||||
"parent_node": parentToken,
|
||||
"size": file.Size,
|
||||
}
|
||||
if existingToken != "" {
|
||||
prepareBody["file_token"] = existingToken
|
||||
}
|
||||
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
uploadID := common.GetString(prepareResult, "upload_id")
|
||||
blockSize := int64(common.GetFloat(prepareResult, "block_size"))
|
||||
blockNum := int(common.GetFloat(prepareResult, "block_num"))
|
||||
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error",
|
||||
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
|
||||
uploadID, blockSize, blockNum)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload: %s, block size %s, %d block(s)\n",
|
||||
common.FormatSize(file.Size), common.FormatSize(blockSize), blockNum)
|
||||
|
||||
// Open the local file ONCE for the whole multipart loop. fileio.File
|
||||
// implements io.ReaderAt, so each block is a fresh
|
||||
// io.NewSectionReader over a shared fd — no need to reopen N times
|
||||
// (which is what drive +upload's existing multipart helper does and
|
||||
// what the original drive_push copy inherited; that pattern wastes
|
||||
// one Open + Close + path-validation per block).
|
||||
partFile, err := runtime.FileIO().Open(file.OpenPath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer partFile.Close()
|
||||
|
||||
for seq := 0; seq < blockNum; seq++ {
|
||||
offset := int64(seq) * blockSize
|
||||
partSize := blockSize
|
||||
if remaining := file.Size - offset; partSize > remaining {
|
||||
partSize = remaining
|
||||
}
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", uploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", partSize))
|
||||
fd.AddFile("file", io.NewSectionReader(partFile, offset, partSize))
|
||||
|
||||
apiResp, doErr := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if doErr != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(doErr, &exitErr) {
|
||||
return "", doErr
|
||||
}
|
||||
return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, doErr)
|
||||
}
|
||||
|
||||
var partResult map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
|
||||
}
|
||||
if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 {
|
||||
msg, _ := partResult["msg"].(string)
|
||||
return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
|
||||
}
|
||||
|
||||
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": uploadID,
|
||||
"block_num": blockNum,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := common.GetString(finishResult, "file_token")
|
||||
if token == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// drivePushDeleteFile deletes a single Drive file (type=file). Folders are
|
||||
// never reached here because --delete-remote only iterates the type=file
|
||||
// subset of the remote listing.
|
||||
func drivePushDeleteFile(_ context.Context, runtime *common.RuntimeContext, fileToken string) error {
|
||||
_, err := runtime.CallAPI(
|
||||
"DELETE",
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(fileToken)),
|
||||
map[string]interface{}{"type": driveTypeFile},
|
||||
nil,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// drivePushParentRel returns the parent rel_path of rel ("" when the file
|
||||
// lives at the root). The local walker emits forward-slash rel_paths so
|
||||
// path.Dir is the right primitive here, not filepath.Dir.
|
||||
func drivePushParentRel(rel string) string {
|
||||
dir := path.Dir(rel)
|
||||
if dir == "." || dir == "/" {
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// drivePushSplitRel splits a non-empty rel into (parent, basename), both
|
||||
// using forward slashes.
|
||||
func drivePushSplitRel(rel string) (string, string) {
|
||||
idx := strings.LastIndex(rel, "/")
|
||||
if idx < 0 {
|
||||
return "", rel
|
||||
}
|
||||
return rel[:idx], rel[idx+1:]
|
||||
}
|
||||
1197
shortcuts/drive/drive_push_test.go
Normal file
1197
shortcuts/drive/drive_push_test.go
Normal file
File diff suppressed because it is too large
Load Diff
265
shortcuts/drive/drive_status.go
Normal file
265
shortcuts/drive/drive_status.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveStatusEntry struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
}
|
||||
|
||||
// DriveStatus walks --local-dir, recursively lists --folder-token, and reports
|
||||
// four buckets (new_local, new_remote, modified, unchanged) by SHA-256 hash.
|
||||
//
|
||||
// Only Drive entries with type=file are compared; online docs (docx, sheet,
|
||||
// bitable, mindnote, slides) and shortcuts are skipped because there is no
|
||||
// equivalent local binary to hash against.
|
||||
//
|
||||
// SafeInputPath (applied by runtime.FileIO()) rejects absolute paths and any
|
||||
// path that resolves outside cwd, which keeps the local side bounded to the
|
||||
// caller's working directory.
|
||||
var DriveStatus = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+status",
|
||||
Description: "Compare a local directory with a Drive folder by content hash",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "Drive folder token", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
||||
"Files present on both sides are downloaded and SHA-256 hashed in memory to decide modified vs unchanged; expect noticeable I/O on large folders.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
if localDir == "" {
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
// Path safety (absolute paths, traversal, symlink escape) is enforced
|
||||
// upfront by the framework helper so the error message references the
|
||||
// correct flag name; FileIO().Stat below would do the same check, but
|
||||
// surface --file in its hint.
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256.").
|
||||
GET("/open-apis/drive/v1/files").
|
||||
Set("folder_token", runtime.Str("folder-token"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before walking.
|
||||
// SafeInputPath fully evaluates symlinks across the entire path,
|
||||
// which closes the kernel-level escape route that filepath.Clean
|
||||
// alone misses: e.g. "link/.." string-cleans to "." but the kernel
|
||||
// resolves through link's target's parent, so a raw walk on the
|
||||
// user-supplied string can land outside cwd. Walking the canonical
|
||||
// root sidesteps that — and the matching cwd canonical lets each
|
||||
// absolute walk hit be converted to a cwd-relative path that
|
||||
// FileIO.Open's SafeInputPath check still accepts.
|
||||
//
|
||||
// Validate already ran SafeLocalFlagPath (with the proper flag
|
||||
// name in the error message), so a failure here is unexpected and
|
||||
// only possible under a Validate↔Execute race.
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return output.ErrValidation("could not resolve cwd: %s", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
|
||||
localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// +status only diffs binary content, so collapse the unified
|
||||
// listing to type=file. Online docs / shortcuts have no
|
||||
// hashable bytes and are intentionally absent from the diff
|
||||
// view (a docx living next to a same-named local file is a
|
||||
// known no-op).
|
||||
remoteFiles := make(map[string]string, len(entries))
|
||||
for rel, entry := range entries {
|
||||
if entry.Type == driveTypeFile {
|
||||
remoteFiles[rel] = entry.FileToken
|
||||
}
|
||||
}
|
||||
|
||||
paths := mergeStatusPaths(localHashes, remoteFiles)
|
||||
|
||||
var newLocal, newRemote, modified, unchanged []driveStatusEntry
|
||||
for _, relPath := range paths {
|
||||
localHash, hasLocal := localHashes[relPath]
|
||||
remoteToken, hasRemote := remoteFiles[relPath]
|
||||
switch {
|
||||
case hasLocal && !hasRemote:
|
||||
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
|
||||
case !hasLocal && hasRemote:
|
||||
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken})
|
||||
default:
|
||||
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken}
|
||||
if localHash == remoteHash {
|
||||
unchanged = append(unchanged, entry)
|
||||
} else {
|
||||
modified = append(modified, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"new_local": emptyIfNil(newLocal),
|
||||
"new_remote": emptyIfNil(newRemote),
|
||||
"modified": emptyIfNil(modified),
|
||||
"unchanged": emptyIfNil(unchanged),
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// walkLocalForStatus walks the canonical absolute root produced by
|
||||
// SafeInputPath. Using the canonical root keeps the kernel from
|
||||
// following any symlink hidden inside the user-supplied --local-dir
|
||||
// (e.g. "link/..", which filepath.Clean shrinks to "." but which OS
|
||||
// path resolution would resolve through the symlink target). For each
|
||||
// hit, we report rel_path relative to root for the JSON output, and
|
||||
// convert the absolute path to a cwd-relative form so FileIO.Open's
|
||||
// SafeInputPath check (which rejects absolute paths) still applies.
|
||||
func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical string) (map[string]string, error) {
|
||||
files := make(map[string]string)
|
||||
// FileIO has no walker today and shortcuts can't import internal/vfs.
|
||||
// The walk root is the canonical absolute path returned by
|
||||
// validate.SafeInputPath, so it is no longer a symlink itself, and
|
||||
// WalkDir's default policy (do not follow child symlinks) keeps the
|
||||
// traversal inside that canonical subtree.
|
||||
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relToCwd, err := filepath.Rel(cwdCanonical, absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum, err := hashLocalForStatus(runtime, relToCwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[filepath.ToSlash(rel)] = sum
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
|
||||
f, err := runtime.FileIO().Open(path)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "io", "hash %s: %s", path, err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (string, error) {
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: "GET",
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return "", output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, resp.Body); err != nil {
|
||||
return "", output.ErrNetwork("hash remote %s: %s", common.MaskToken(fileToken), err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func mergeStatusPaths(local, remote map[string]string) []string {
|
||||
seen := make(map[string]struct{}, len(local)+len(remote))
|
||||
for p := range local {
|
||||
seen[p] = struct{}{}
|
||||
}
|
||||
for p := range remote {
|
||||
seen[p] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for p := range seen {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func emptyIfNil(s []driveStatusEntry) []driveStatusEntry {
|
||||
if s == nil {
|
||||
return []driveStatusEntry{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
498
shortcuts/drive/drive_status_test.go
Normal file
498
shortcuts/drive/drive_status_test.go
Normal file
@@ -0,0 +1,498 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestDriveStatusCategorizesByHash exercises the four-bucket classification
|
||||
// against a real walk of the temp dir and a mocked Drive listing.
|
||||
func TestDriveStatusCategorizesByHash(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
// Local layout:
|
||||
// local/a.txt — also on remote with different content → modified
|
||||
// local/b.txt — only local → new_local
|
||||
// local/sub/c.txt — also on remote with same content → unchanged
|
||||
// Remote-only:
|
||||
// d.txt → new_remote
|
||||
if err := os.MkdirAll("local/sub", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/a.txt", []byte("aaa"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a.txt: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/b.txt", []byte("bbb"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile b.txt: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/sub/c.txt", []byte("ccc"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile sub/c.txt: %v", err)
|
||||
}
|
||||
|
||||
// Root folder list — order matters: stubs match in registration order.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
|
||||
map[string]interface{}{"token": "tok_sub", "name": "sub", "type": "folder"},
|
||||
map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file"},
|
||||
// noise: an online doc and a shortcut should be ignored
|
||||
map[string]interface{}{"token": "tok_doc", "name": "ignored.docx", "type": "docx"},
|
||||
map[string]interface{}{"token": "tok_sc", "name": "ignored.lnk", "type": "shortcut"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Subfolder list
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=tok_sub",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_c", "name": "c.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Download a.txt: remote content differs from local "aaa" → modified.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_a/download",
|
||||
Status: 200,
|
||||
Body: []byte("AAA"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
// Download c.txt: remote content matches local "ccc" → unchanged.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_c/download",
|
||||
Status: 200,
|
||||
Body: []byte("ccc"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
checks := []struct {
|
||||
bucket string
|
||||
path string
|
||||
token string
|
||||
}{
|
||||
{"new_local", "b.txt", ""},
|
||||
{"new_remote", "d.txt", "tok_d"},
|
||||
{"modified", "a.txt", "tok_a"},
|
||||
{"unchanged", "sub/c.txt", "tok_c"},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(out, `"`+c.bucket+`":`) {
|
||||
t.Errorf("output missing bucket %q\noutput: %s", c.bucket, out)
|
||||
}
|
||||
if !strings.Contains(out, `"rel_path": "`+c.path+`"`) {
|
||||
t.Errorf("output missing rel_path %q (expected in %s)\noutput: %s", c.path, c.bucket, out)
|
||||
}
|
||||
if c.token != "" && !strings.Contains(out, `"file_token": "`+c.token+`"`) {
|
||||
t.Errorf("output missing file_token %q (expected in %s)\noutput: %s", c.token, c.bucket, out)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(out, "ignored.docx") || strings.Contains(out, "ignored.lnk") {
|
||||
t.Errorf("output should skip docx/shortcut entries\noutput: %s", out)
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
// TestDriveStatusPaginatesRemoteListing pins multi-page handling end-to-end
|
||||
// AND the dual-field tolerance of common.PaginationMeta. Page 1 surfaces
|
||||
// `next_page_token` (Drive's historical name); page 2 surfaces `page_token`
|
||||
// (what the shared helper also accepts). If the shortcut had hard-coded
|
||||
// either field name, one of the two pages' files would be silently dropped
|
||||
// from the comparison and would land in the wrong bucket. Stub order is
|
||||
// significant: httpmock matches in registration order, and both stubs key on
|
||||
// the GET .../files URL — they pop in turn, so page 1's response (with the
|
||||
// continuation token) must be registered before page 2's terminator.
|
||||
func TestDriveStatusPaginatesRemoteListing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
// Page 1: returns one file plus a continuation token via
|
||||
// next_page_token (the field Drive currently emits).
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_p1", "name": "page1.txt", "type": "file"},
|
||||
},
|
||||
"has_more": true,
|
||||
"next_page_token": "cursor-page-2",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Page 2: returns the second file with has_more=false. This stub uses
|
||||
// page_token (the alternate spelling) to lock in that the shared
|
||||
// PaginationMeta helper accepts BOTH field names.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_p2", "name": "page2.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// Both pages contributed to new_remote (local is empty).
|
||||
for _, want := range []string{
|
||||
`"rel_path": "page1.txt"`,
|
||||
`"file_token": "tok_p1"`,
|
||||
`"rel_path": "page2.txt"`,
|
||||
`"file_token": "tok_p2"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q (a page must have been silently dropped)\noutput: %s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDriveStatusRejectsMissingLocalDir(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "does-not-exist",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing local dir, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveStatusRejectsLocalFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("not-a-dir.txt", []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "not-a-dir.txt",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error when --local-dir is a file, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a directory") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveStatusRejectsAbsoluteLocalDir(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "/etc",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for absolute --local-dir, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusRejectsEmptyFolderToken covers the Validate-stage required
|
||||
// check that runs before ResourceName: an empty --folder-token must surface
|
||||
// a structured FlagError referencing the flag name.
|
||||
func TestDriveStatusRejectsEmptyFolderToken(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty --folder-token, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--folder-token") {
|
||||
t.Fatalf("error must reference --folder-token, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusDoesNotEscapeViaSymlinkParentRef is the regression for the
|
||||
// "link/.." escape: filepath.Clean string-shrinks "link/.." to ".", so a
|
||||
// raw walk on the user-supplied input can land on the kernel-resolved
|
||||
// path through link's target's parent — outside cwd. The fix is to walk
|
||||
// SafeInputPath's canonical absolute root instead of the raw input.
|
||||
//
|
||||
// Setup: an "escape" sibling directory contains a sentinel file; cwd
|
||||
// contains a "link" symlink pointing into that escape directory.
|
||||
// Calling +status with --local-dir "link/.." must not surface the
|
||||
// sentinel — the walk must stay inside cwd.
|
||||
func TestDriveStatusDoesNotEscapeViaSymlinkParentRef(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
// Sentinel lives outside cwd; the agent must never see it.
|
||||
escapeDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(escapeDir, "secret.txt"), []byte("S3CRET"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile secret: %v", err)
|
||||
}
|
||||
|
||||
// cwd has a symlink that points into the sentinel's parent.
|
||||
cwdDir := t.TempDir()
|
||||
withDriveWorkingDir(t, cwdDir)
|
||||
if err := os.Symlink(escapeDir, filepath.Join(cwdDir, "link")); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
// A normal file inside cwd just to make the walk non-trivial.
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "ok.txt"), []byte("ok"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile ok: %v", err)
|
||||
}
|
||||
|
||||
// Empty remote folder so any path that surfaces in the output
|
||||
// must have come from the local walk.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "link/..",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "secret.txt") || strings.Contains(out, "S3CRET") {
|
||||
t.Fatalf("walk escaped via link/..: secret.txt leaked into output\noutput:\n%s", out)
|
||||
}
|
||||
// ok.txt is in cwd and must classify as new_local (no remote stub for it).
|
||||
if !strings.Contains(out, `"rel_path": "ok.txt"`) {
|
||||
t.Fatalf("expected ok.txt in new_local, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusSkipsSymlinkInsideRoot pins down WalkDir's default policy
|
||||
// for symlinks discovered as child entries: they are reported with a
|
||||
// non-regular file mode and the callback skips them, so a symlink inside
|
||||
// the validated root pointing into an out-of-tree directory cannot leak
|
||||
// the target's contents.
|
||||
func TestDriveStatusSkipsSymlinkInsideRoot(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
// Sentinel sits outside cwd; a child symlink inside the walked root
|
||||
// points there. If the walker followed child symlinks (it must not),
|
||||
// the sentinel's name would surface in new_local.
|
||||
escapeDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(escapeDir, "secret.txt"), []byte("S3CRET"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile secret: %v", err)
|
||||
}
|
||||
|
||||
cwdDir := t.TempDir()
|
||||
withDriveWorkingDir(t, cwdDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "ok.txt"), []byte("ok"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile ok: %v", err)
|
||||
}
|
||||
// Child-of-root symlink that resolves out of the validated subtree.
|
||||
if err := os.Symlink(escapeDir, filepath.Join("local", "sub", "escape")); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "secret.txt") || strings.Contains(out, "S3CRET") {
|
||||
t.Fatalf("walk followed child symlink and leaked sentinel:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"rel_path": "ok.txt"`) {
|
||||
t.Fatalf("expected ok.txt in new_local; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusSurvivesCircularSymlinkInsideRoot makes sure WalkDir
|
||||
// terminates even when a child symlink points back at one of its
|
||||
// ancestors. WalkDir's default policy already declines to follow child
|
||||
// symlinks; this test pins that contract for our caller.
|
||||
func TestDriveStatusSurvivesCircularSymlinkInsideRoot(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
cwdDir := t.TempDir()
|
||||
withDriveWorkingDir(t, cwdDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "sub", "real.txt"), []byte("real"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
// loop symlink: cwd/local/sub/loop -> cwd/local (an ancestor).
|
||||
loopTarget, err := filepath.Abs(filepath.Join("local"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs: %v", err)
|
||||
}
|
||||
if err := os.Symlink(loopTarget, filepath.Join("local", "sub", "loop")); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// If WalkDir followed the loop, this test would never finish; the
|
||||
// test runner's per-test timeout would surface that as a failure.
|
||||
err = mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"rel_path": "sub/real.txt"`) {
|
||||
t.Fatalf("expected sub/real.txt in new_local; got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusRejectsMalformedFolderToken covers the ResourceName format
|
||||
// guard: a token with control characters (newline) must be rejected before
|
||||
// any API call is made.
|
||||
func TestDriveStatusRejectsMalformedFolderToken(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "tok\nwithnewline",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for malformed --folder-token, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--folder-token") {
|
||||
t.Fatalf("error must reference --folder-token, got: %v", err)
|
||||
}
|
||||
}
|
||||
116
shortcuts/drive/list_remote.go
Normal file
116
shortcuts/drive/list_remote.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
driveListRemotePageSize = 200
|
||||
driveTypeFile = "file"
|
||||
driveTypeFolder = "folder"
|
||||
)
|
||||
|
||||
// driveRemoteEntry is one Drive entry returned by listRemoteFolder. It
|
||||
// carries enough metadata for every shortcut that consumes the listing
|
||||
// to build its own per-shortcut view by filtering on Type.
|
||||
type driveRemoteEntry struct {
|
||||
// FileToken is the Drive token for this entry. For type=folder this
|
||||
// is the folder_token; for everything else it is the file_token.
|
||||
FileToken string
|
||||
// Type is the Drive entry kind verbatim from the API:
|
||||
// "file" | "folder" | "docx" | "doc" | "sheet" | "bitable" |
|
||||
// "mindnote" | "slides" | "shortcut" | …
|
||||
Type string
|
||||
// RelPath is the entry's path relative to the listing root. Encoded
|
||||
// with "/" separators on every platform so it matches the rel_paths
|
||||
// produced by the shortcuts' local walkers.
|
||||
RelPath string
|
||||
}
|
||||
|
||||
// listRemoteFolder recursively lists folderToken under relBase and
|
||||
// returns one entry per Drive item, keyed by rel_path. Subfolders are
|
||||
// descended into and the folder's own entry is also recorded — callers
|
||||
// can reason about "this rel_path is occupied by a folder" without
|
||||
// re-listing.
|
||||
//
|
||||
// This is the shared backbone for the three sync-disk shortcuts. None
|
||||
// of them need every field at every call site, so each one filters
|
||||
// on Type:
|
||||
//
|
||||
// - +status (drive_status.go) keeps Type=="file" and uses FileToken
|
||||
// to drive content-hash diffs against the local tree.
|
||||
// - +pull (drive_pull.go) keeps Type=="file" + FileToken for the
|
||||
// download set, and the full key set (every rel_path) as the
|
||||
// guard for --delete-local.
|
||||
// - +push (drive_push.go) keeps Type=="file" + FileToken for upload /
|
||||
// overwrite / orphan-delete decisions, and Type=="folder" + FileToken
|
||||
// for the create_folder cache.
|
||||
//
|
||||
// Pagination uses common.PaginationMeta, which accepts both
|
||||
// page_token and next_page_token — the Drive list endpoint has
|
||||
// historically returned the latter, but the helper future-proofs
|
||||
// against a backend rename.
|
||||
func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]driveRemoteEntry, error) {
|
||||
out := make(map[string]driveRemoteEntry)
|
||||
pageToken := ""
|
||||
for {
|
||||
params := map[string]interface{}{
|
||||
"folder_token": folderToken,
|
||||
"page_size": fmt.Sprint(driveListRemotePageSize),
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawFiles, _ := result["files"].([]interface{})
|
||||
for _, item := range rawFiles {
|
||||
f, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fType := common.GetString(f, "type")
|
||||
fName := common.GetString(f, "name")
|
||||
fToken := common.GetString(f, "token")
|
||||
if fName == "" || fToken == "" {
|
||||
continue
|
||||
}
|
||||
rel := joinRelDrive(relBase, fName)
|
||||
out[rel] = driveRemoteEntry{FileToken: fToken, Type: fType, RelPath: rel}
|
||||
if fType == driveTypeFolder {
|
||||
sub, err := listRemoteFolder(ctx, runtime, fToken, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range sub {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
hasMore, nextToken := common.PaginationMeta(result)
|
||||
if !hasMore || nextToken == "" {
|
||||
break
|
||||
}
|
||||
pageToken = nextToken
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// joinRelDrive joins a rel_path base with an entry name using "/".
|
||||
// Empty base means the entry sits at the listing root. Mirrors the
|
||||
// behavior the per-shortcut helpers used to ship and keeps rel_paths
|
||||
// stable across +status / +pull / +push.
|
||||
func joinRelDrive(base, name string) string {
|
||||
if base == "" {
|
||||
return name
|
||||
}
|
||||
return base + "/" + name
|
||||
}
|
||||
@@ -18,6 +18,9 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveImport,
|
||||
DriveMove,
|
||||
DriveDelete,
|
||||
DriveStatus,
|
||||
DrivePush,
|
||||
DrivePull,
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveSearch,
|
||||
|
||||
@@ -21,6 +21,9 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+import",
|
||||
"+move",
|
||||
"+delete",
|
||||
"+status",
|
||||
"+push",
|
||||
"+pull",
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+search",
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"mime"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
@@ -215,11 +216,24 @@ type PatchOp struct {
|
||||
Target AttachmentTarget `json:"target,omitempty"`
|
||||
SignatureID string `json:"signature_id,omitempty"`
|
||||
|
||||
// Calendar event fields, used by set_calendar. The raw ISO 8601 strings
|
||||
// are shown in dry-run output; the shortcut layer pre-builds the ICS
|
||||
// blob into CalendarICS below before Apply runs.
|
||||
EventSummary string `json:"event_summary,omitempty"`
|
||||
EventStart string `json:"event_start,omitempty"`
|
||||
EventEnd string `json:"event_end,omitempty"`
|
||||
EventLocation string `json:"event_location,omitempty"`
|
||||
|
||||
// RenderedSignatureHTML is set by the shortcut layer (not from JSON) after
|
||||
// fetching and interpolating the signature. The patch layer uses this
|
||||
// pre-rendered content for insert_signature ops.
|
||||
RenderedSignatureHTML string `json:"-"`
|
||||
SignatureImages []SignatureImage `json:"-"`
|
||||
|
||||
// CalendarICS holds the pre-built RFC 5545 ICS blob for a set_calendar
|
||||
// op. Populated by the shortcut layer after the snapshot is parsed and
|
||||
// organizer/attendee addresses can be resolved. Not serialised.
|
||||
CalendarICS []byte `json:"-"`
|
||||
}
|
||||
|
||||
// SignatureImage holds pre-downloaded image data for signature inline images.
|
||||
@@ -327,6 +341,26 @@ func (op PatchOp) Validate() error {
|
||||
}
|
||||
case "remove_signature":
|
||||
// No required fields.
|
||||
case "set_calendar":
|
||||
if strings.TrimSpace(op.EventSummary) == "" {
|
||||
return fmt.Errorf("set_calendar requires event_summary")
|
||||
}
|
||||
if strings.TrimSpace(op.EventStart) == "" || strings.TrimSpace(op.EventEnd) == "" {
|
||||
return fmt.Errorf("set_calendar requires event_start and event_end")
|
||||
}
|
||||
start, err := parseISO8601(op.EventStart)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set_calendar: event_start must be a valid ISO 8601 timestamp")
|
||||
}
|
||||
end, err := parseISO8601(op.EventEnd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set_calendar: event_end must be a valid ISO 8601 timestamp")
|
||||
}
|
||||
if !end.After(start) {
|
||||
return fmt.Errorf("set_calendar: event_end must be after event_start")
|
||||
}
|
||||
case "remove_calendar":
|
||||
// No required fields.
|
||||
default:
|
||||
return fmt.Errorf("unsupported op %q", op.Op)
|
||||
}
|
||||
@@ -400,3 +434,19 @@ func MustJSON(v interface{}) string {
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// parseISO8601 tries common ISO 8601 timestamp layouts, accepting both
|
||||
// with-seconds (RFC 3339) and without-seconds variants.
|
||||
func parseISO8601(s string) (time.Time, error) {
|
||||
for _, layout := range []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04Z07:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("cannot parse %q as ISO 8601", s)
|
||||
}
|
||||
|
||||
@@ -136,6 +136,10 @@ func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchO
|
||||
return insertSignatureOp(snapshot, op)
|
||||
case "remove_signature":
|
||||
return removeSignatureOp(snapshot)
|
||||
case "set_calendar":
|
||||
return applyCalendarSet(snapshot, op.CalendarICS)
|
||||
case "remove_calendar":
|
||||
return applyCalendarRemove(snapshot)
|
||||
default:
|
||||
return fmt.Errorf("unsupported patch op %q", op.Op)
|
||||
}
|
||||
|
||||
188
shortcuts/mail/draft/patch_calendar.go
Normal file
188
shortcuts/mail/draft/patch_calendar.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package draft
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const calendarMediaType = "text/calendar"
|
||||
|
||||
// applyCalendarSet installs or replaces the text/calendar MIME part in the
|
||||
// snapshot. The caller is expected to have pre-built icsData using the
|
||||
// snapshot's From/To/Cc addresses.
|
||||
func applyCalendarSet(snapshot *DraftSnapshot, icsData []byte) error {
|
||||
if len(icsData) == 0 {
|
||||
return fmt.Errorf("set_calendar: ICS data is empty (shortcut layer must pre-build it)")
|
||||
}
|
||||
setCalendarPart(snapshot, icsData)
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyCalendarRemove strips the text/calendar part from the snapshot.
|
||||
// No-op if no calendar part exists.
|
||||
func applyCalendarRemove(snapshot *DraftSnapshot) error {
|
||||
removeCalendarPart(snapshot)
|
||||
return nil
|
||||
}
|
||||
|
||||
// setCalendarPart places exactly one text/calendar part inside
|
||||
// multipart/alternative, matching the Feishu client behavior. Any existing
|
||||
// text/calendar parts elsewhere in the tree are removed first.
|
||||
func setCalendarPart(snapshot *DraftSnapshot, icsData []byte) {
|
||||
newPart := &Part{
|
||||
MediaType: calendarMediaType,
|
||||
MediaParams: map[string]string{"charset": "UTF-8", "method": "REQUEST"},
|
||||
Body: icsData,
|
||||
Dirty: true,
|
||||
}
|
||||
|
||||
if snapshot.Body == nil {
|
||||
snapshot.Body = newPart
|
||||
return
|
||||
}
|
||||
|
||||
// Remove all existing text/calendar parts from everywhere in the tree.
|
||||
if strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
|
||||
snapshot.Body = newPart
|
||||
return
|
||||
}
|
||||
removeAllPartsByMediaType(snapshot.Body, calendarMediaType)
|
||||
|
||||
// Place inside the existing multipart/alternative.
|
||||
if alt := FindPartByMediaType(snapshot.Body, "multipart/alternative"); alt != nil {
|
||||
alt.Children = append(alt.Children, newPart)
|
||||
alt.Dirty = true
|
||||
return
|
||||
}
|
||||
|
||||
// No multipart/alternative exists. If the body is a single leaf,
|
||||
// wrap it in multipart/alternative together with the calendar.
|
||||
if !snapshot.Body.IsMultipart() {
|
||||
original := *snapshot.Body
|
||||
// Reset all header-carrying fields so the serializer constructs a fresh
|
||||
// Content-Type from MediaType instead of reusing the stale leaf headers.
|
||||
snapshot.Body.Headers = nil
|
||||
snapshot.Body.MediaType = "multipart/alternative"
|
||||
snapshot.Body.MediaParams = nil
|
||||
snapshot.Body.ContentDisposition = ""
|
||||
snapshot.Body.ContentDispositionArg = nil
|
||||
snapshot.Body.ContentID = ""
|
||||
snapshot.Body.PartID = ""
|
||||
snapshot.Body.Body = nil
|
||||
snapshot.Body.TransferEncoding = ""
|
||||
snapshot.Body.RawEntity = nil
|
||||
snapshot.Body.Preamble = nil
|
||||
snapshot.Body.Epilogue = nil
|
||||
snapshot.Body.EncodingProblem = false
|
||||
snapshot.Body.Children = []*Part{&original, newPart}
|
||||
snapshot.Body.Dirty = true
|
||||
return
|
||||
}
|
||||
|
||||
// Multipart body without an alternative sub-part (e.g. multipart/mixed
|
||||
// with a text/html child). Find the first text/* child and wrap it in
|
||||
// a new multipart/alternative that also contains the calendar.
|
||||
for i, child := range snapshot.Body.Children {
|
||||
if child != nil && strings.HasPrefix(strings.ToLower(child.MediaType), "text/") {
|
||||
alt := &Part{
|
||||
MediaType: "multipart/alternative",
|
||||
Children: []*Part{child, newPart},
|
||||
Dirty: true,
|
||||
}
|
||||
snapshot.Body.Children[i] = alt
|
||||
snapshot.Body.Dirty = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: append to the root multipart container.
|
||||
snapshot.Body.Children = append(snapshot.Body.Children, newPart)
|
||||
snapshot.Body.Dirty = true
|
||||
}
|
||||
|
||||
func removeCalendarPart(snapshot *DraftSnapshot) {
|
||||
if snapshot.Body == nil {
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
|
||||
snapshot.Body = nil
|
||||
return
|
||||
}
|
||||
removeAllPartsByMediaType(snapshot.Body, calendarMediaType)
|
||||
}
|
||||
|
||||
// FindPartByMediaType walks the MIME tree and returns the first part with
|
||||
// the given media type, or nil when not found.
|
||||
func FindPartByMediaType(root *Part, mediaType string) *Part {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(root.MediaType, mediaType) {
|
||||
return root
|
||||
}
|
||||
for _, child := range root.Children {
|
||||
if found := FindPartByMediaType(child, mediaType); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findAllPartsByMediaType walks the MIME tree and returns every part with
|
||||
// the given media type. Used in tests to assert tree contents.
|
||||
func findAllPartsByMediaType(root *Part, mediaType string) []*Part {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
var result []*Part
|
||||
if strings.EqualFold(root.MediaType, mediaType) {
|
||||
result = append(result, root)
|
||||
}
|
||||
for _, child := range root.Children {
|
||||
result = append(result, findAllPartsByMediaType(child, mediaType)...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// removePartByMediaType removes the first part with the given media type from
|
||||
// the MIME tree. The parent is marked dirty when a removal happens.
|
||||
func removePartByMediaType(root *Part, mediaType string) {
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
for i, child := range root.Children {
|
||||
if child != nil && strings.EqualFold(child.MediaType, mediaType) {
|
||||
root.Children = append(root.Children[:i], root.Children[i+1:]...)
|
||||
root.Dirty = true
|
||||
return
|
||||
}
|
||||
removePartByMediaType(child, mediaType)
|
||||
}
|
||||
}
|
||||
|
||||
// removeAllPartsByMediaType removes every part with the given media type from
|
||||
// the MIME tree, at all nesting levels.
|
||||
func removeAllPartsByMediaType(root *Part, mediaType string) {
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
var kept []*Part
|
||||
removed := false
|
||||
for _, child := range root.Children {
|
||||
if child != nil && strings.EqualFold(child.MediaType, mediaType) {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, child)
|
||||
}
|
||||
if removed {
|
||||
root.Children = kept
|
||||
root.Dirty = true
|
||||
}
|
||||
for _, child := range root.Children {
|
||||
removeAllPartsByMediaType(child, mediaType)
|
||||
}
|
||||
}
|
||||
429
shortcuts/mail/draft/patch_calendar_test.go
Normal file
429
shortcuts/mail/draft/patch_calendar_test.go
Normal file
@@ -0,0 +1,429 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package draft
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const fixtureCalData = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_calendar — validate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetCalendar_ValidateRequiresSummary(t *testing.T) {
|
||||
err := PatchOp{Op: "set_calendar", EventStart: "2026-04-25T10:00+08:00", EventEnd: "2026-04-25T11:00+08:00"}.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "event_summary") {
|
||||
t.Errorf("expected event_summary error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendar_ValidateRequiresStartAndEnd(t *testing.T) {
|
||||
err := PatchOp{Op: "set_calendar", EventSummary: "Meeting", EventStart: "2026-04-25T10:00+08:00"}.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "event_start and event_end") {
|
||||
t.Errorf("expected start/end error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendar_ValidateInvalidStartFormat(t *testing.T) {
|
||||
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "not-a-date", EventEnd: "2026-04-25T11:00+08:00"}.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "event_start") {
|
||||
t.Errorf("expected event_start error for bad format, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendar_ValidateInvalidEndFormat(t *testing.T) {
|
||||
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "2026-04-25T10:00+08:00", EventEnd: "not-a-date"}.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "event_end") {
|
||||
t.Errorf("expected event_end error for bad format, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendar_ValidateEndNotAfterStart(t *testing.T) {
|
||||
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "2026-04-25T11:00+08:00", EventEnd: "2026-04-25T10:00+08:00"}.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "after") {
|
||||
t.Errorf("expected end-after-start error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendar_ValidateOK(t *testing.T) {
|
||||
err := PatchOp{
|
||||
Op: "set_calendar",
|
||||
EventSummary: "Meeting",
|
||||
EventStart: "2026-04-25T10:00+08:00",
|
||||
EventEnd: "2026-04-25T11:00+08:00",
|
||||
}.Validate()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_calendar — Apply adds text/calendar part when none exists
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetCalendar_AddsCalendarPartToHTMLDraft(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p>`)
|
||||
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{
|
||||
Op: "set_calendar",
|
||||
EventSummary: "Meeting",
|
||||
EventStart: "2026-04-25T10:00+08:00",
|
||||
EventEnd: "2026-04-25T11:00+08:00",
|
||||
CalendarICS: []byte(fixtureCalData),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
|
||||
part := FindPartByMediaType(snapshot.Body, calendarMediaType)
|
||||
if part == nil {
|
||||
t.Fatal("text/calendar part not added to draft")
|
||||
}
|
||||
if string(part.Body) != fixtureCalData {
|
||||
t.Errorf("calendar part body mismatch: got %q", part.Body)
|
||||
}
|
||||
if part.MediaParams["method"] != "REQUEST" {
|
||||
t.Errorf("calendar part missing method=REQUEST in MediaParams: %v", part.MediaParams)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_calendar — Apply replaces existing text/calendar part
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetCalendar_ReplacesExistingCalendarPart(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="b1"
|
||||
|
||||
--b1
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p>
|
||||
--b1
|
||||
Content-Type: text/calendar; charset=UTF-8
|
||||
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
SUMMARY:OLD
|
||||
END:VCALENDAR
|
||||
--b1--`)
|
||||
|
||||
newICS := []byte("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nSUMMARY:NEW\r\nEND:VCALENDAR\r\n")
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{
|
||||
Op: "set_calendar",
|
||||
EventSummary: "NEW",
|
||||
EventStart: "2026-04-25T10:00+08:00",
|
||||
EventEnd: "2026-04-25T11:00+08:00",
|
||||
CalendarICS: newICS,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
|
||||
part := FindPartByMediaType(snapshot.Body, calendarMediaType)
|
||||
if part == nil {
|
||||
t.Fatal("text/calendar part missing")
|
||||
}
|
||||
if !strings.Contains(string(part.Body), "SUMMARY:NEW") {
|
||||
t.Errorf("expected new SUMMARY, got %q", part.Body)
|
||||
}
|
||||
if strings.Contains(string(part.Body), "SUMMARY:OLD") {
|
||||
t.Errorf("old SUMMARY not replaced")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_calendar — Apply requires pre-built ICS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetCalendar_EmptyICSIsError(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p>`)
|
||||
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{
|
||||
Op: "set_calendar",
|
||||
EventSummary: "Meeting",
|
||||
EventStart: "2026-04-25T10:00+08:00",
|
||||
EventEnd: "2026-04-25T11:00+08:00",
|
||||
// CalendarICS intentionally nil — simulates missing pre-process.
|
||||
}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing CalendarICS")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ICS data is empty") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// remove_calendar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRemoveCalendar_StripsCalendarPart(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="b1"
|
||||
|
||||
--b1
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p>
|
||||
--b1
|
||||
Content-Type: text/calendar; charset=UTF-8
|
||||
|
||||
BEGIN:VCALENDAR
|
||||
END:VCALENDAR
|
||||
--b1--`)
|
||||
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "remove_calendar"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
|
||||
if part := FindPartByMediaType(snapshot.Body, calendarMediaType); part != nil {
|
||||
t.Errorf("text/calendar part should be removed, but still found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveCalendar_NoOpWhenAbsent(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Plain
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p>`)
|
||||
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "remove_calendar"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
// Body remains intact.
|
||||
if snapshot.Body == nil {
|
||||
t.Fatal("body unexpectedly nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal MIME helpers (coverage)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFindPartByMediaType_CaseInsensitive(t *testing.T) {
|
||||
root := &Part{
|
||||
MediaType: "multipart/mixed",
|
||||
Children: []*Part{
|
||||
{MediaType: "TEXT/Calendar"},
|
||||
},
|
||||
}
|
||||
got := FindPartByMediaType(root, "text/calendar")
|
||||
if got == nil {
|
||||
t.Fatal("expected to find part despite case mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovePartByMediaType_MarksParentDirty(t *testing.T) {
|
||||
root := &Part{
|
||||
MediaType: "multipart/mixed",
|
||||
Children: []*Part{
|
||||
{MediaType: "text/calendar"},
|
||||
{MediaType: "text/html"},
|
||||
},
|
||||
}
|
||||
removePartByMediaType(root, "text/calendar")
|
||||
if len(root.Children) != 1 {
|
||||
t.Fatalf("expected 1 remaining child, got %d", len(root.Children))
|
||||
}
|
||||
if !root.Dirty {
|
||||
t.Error("parent not marked dirty after removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendar_CollapsesToOneInsideAlternative(t *testing.T) {
|
||||
// Feishu client creates two text/calendar copies: one inside
|
||||
// multipart/alternative and one as an inline attachment in
|
||||
// multipart/mixed. set_calendar must collapse them to a single
|
||||
// copy inside multipart/alternative.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed; boundary="outer"
|
||||
|
||||
--outer
|
||||
Content-Type: multipart/alternative; boundary="inner"
|
||||
|
||||
--inner
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<p>Hello</p>
|
||||
--inner
|
||||
Content-Type: text/calendar; charset=UTF-8
|
||||
|
||||
BEGIN:VCALENDAR
|
||||
SUMMARY:OLD
|
||||
END:VCALENDAR
|
||||
--inner--
|
||||
--outer
|
||||
Content-Type: text/calendar; charset=UTF-8; name="invite.ics"
|
||||
Content-Id: <invite.ics>
|
||||
|
||||
BEGIN:VCALENDAR
|
||||
SUMMARY:OLD
|
||||
END:VCALENDAR
|
||||
--outer--`)
|
||||
|
||||
newICS := []byte("BEGIN:VCALENDAR\r\nSUMMARY:NEW\r\nEND:VCALENDAR\r\n")
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{
|
||||
Op: "set_calendar",
|
||||
EventSummary: "NEW",
|
||||
EventStart: "2026-04-25T10:00+08:00",
|
||||
EventEnd: "2026-04-25T11:00+08:00",
|
||||
CalendarICS: newICS,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
|
||||
// Exactly one text/calendar part should remain, inside alternative.
|
||||
parts := findAllPartsByMediaType(snapshot.Body, calendarMediaType)
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("expected 1 text/calendar part, got %d", len(parts))
|
||||
}
|
||||
if !strings.Contains(string(parts[0].Body), "SUMMARY:NEW") {
|
||||
t.Errorf("expected SUMMARY:NEW, got %q", parts[0].Body)
|
||||
}
|
||||
|
||||
// The calendar part must be a child of multipart/alternative.
|
||||
alt := FindPartByMediaType(snapshot.Body, "multipart/alternative")
|
||||
if alt == nil {
|
||||
t.Fatal("multipart/alternative not found")
|
||||
}
|
||||
found := false
|
||||
for _, child := range alt.Children {
|
||||
if strings.EqualFold(child.MediaType, calendarMediaType) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("text/calendar part not inside multipart/alternative")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveCalendar_RootLevelCalendarBody(t *testing.T) {
|
||||
// When the snapshot body is itself a text/calendar leaf (no multipart
|
||||
// wrapper), removeCalendarPart must nil out snapshot.Body rather than
|
||||
// trying to remove it from a parent's children slice.
|
||||
snapshot := &DraftSnapshot{
|
||||
Body: &Part{
|
||||
MediaType: "text/calendar",
|
||||
Body: []byte(fixtureCalData),
|
||||
},
|
||||
}
|
||||
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "remove_calendar"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if snapshot.Body != nil {
|
||||
t.Errorf("snapshot.Body should be nil after removing root-level text/calendar, got %+v", snapshot.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendarPart_OnNilBodyCreatesLeaf(t *testing.T) {
|
||||
snapshot := &DraftSnapshot{}
|
||||
setCalendarPart(snapshot, []byte(fixtureCalData))
|
||||
if snapshot.Body == nil {
|
||||
t.Fatal("body should be created")
|
||||
}
|
||||
if !strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
|
||||
t.Errorf("expected %s leaf, got %s", calendarMediaType, snapshot.Body.MediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendarPart_MixedWithoutAlternativeWrapsTextChild(t *testing.T) {
|
||||
// multipart/mixed with a text/html child but no alternative sub-part.
|
||||
// setCalendarPart should wrap the text/html in a new alternative.
|
||||
snapshot := &DraftSnapshot{
|
||||
Body: &Part{
|
||||
MediaType: "multipart/mixed",
|
||||
Children: []*Part{
|
||||
{MediaType: "text/html", Body: []byte("<p>Hi</p>")},
|
||||
{MediaType: "application/pdf", Body: []byte("pdf-data")},
|
||||
},
|
||||
},
|
||||
}
|
||||
setCalendarPart(snapshot, []byte(fixtureCalData))
|
||||
|
||||
if snapshot.Body.MediaType != "multipart/mixed" {
|
||||
t.Fatalf("root should stay multipart/mixed, got %s", snapshot.Body.MediaType)
|
||||
}
|
||||
alt := FindPartByMediaType(snapshot.Body, "multipart/alternative")
|
||||
if alt == nil {
|
||||
t.Fatal("expected a multipart/alternative child to be created")
|
||||
}
|
||||
if len(alt.Children) != 2 {
|
||||
t.Fatalf("alternative should have 2 children, got %d", len(alt.Children))
|
||||
}
|
||||
if !strings.EqualFold(alt.Children[0].MediaType, "text/html") {
|
||||
t.Errorf("first alternative child should be text/html, got %s", alt.Children[0].MediaType)
|
||||
}
|
||||
if !strings.EqualFold(alt.Children[1].MediaType, calendarMediaType) {
|
||||
t.Errorf("second alternative child should be text/calendar, got %s", alt.Children[1].MediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCalendarPart_FallbackAppendsToMultipart(t *testing.T) {
|
||||
// multipart/mixed with only non-text children (no text/* to wrap).
|
||||
snapshot := &DraftSnapshot{
|
||||
Body: &Part{
|
||||
MediaType: "multipart/mixed",
|
||||
Children: []*Part{
|
||||
{MediaType: "application/pdf", Body: []byte("pdf-data")},
|
||||
},
|
||||
},
|
||||
}
|
||||
setCalendarPart(snapshot, []byte(fixtureCalData))
|
||||
|
||||
found := false
|
||||
for _, child := range snapshot.Body.Children {
|
||||
if strings.EqualFold(child.MediaType, calendarMediaType) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("text/calendar should be appended as fallback child")
|
||||
}
|
||||
}
|
||||
@@ -420,8 +420,9 @@ func (b Builder) HTMLBody(body []byte) Builder {
|
||||
}
|
||||
|
||||
// CalendarBody sets the text/calendar body (e.g. for meeting invitations).
|
||||
// May be combined with TextBody and/or HTMLBody; the resulting parts are wrapped
|
||||
// in multipart/alternative.
|
||||
// When combined with TextBody or HTMLBody, the calendar part is placed inside
|
||||
// multipart/alternative alongside the body parts, matching the Feishu client
|
||||
// convention for calendar invitation emails.
|
||||
func (b Builder) CalendarBody(body []byte) Builder {
|
||||
b.calendarBody = body
|
||||
return b
|
||||
@@ -731,6 +732,9 @@ func (b Builder) Build() ([]byte, error) {
|
||||
// ── Body ───────────────────────────────────────────────────────────────────
|
||||
// Full MIME hierarchy (outer layers only present when needed):
|
||||
// multipart/mixed → multipart/related → multipart/alternative → body parts
|
||||
//
|
||||
// text/calendar lives inside multipart/alternative as an alternative
|
||||
// representation of the message body, matching the Feishu client behavior.
|
||||
if len(b.attachments) > 0 {
|
||||
outerB := newBoundary()
|
||||
writeHeader(&buf, "Content-Type", "multipart/mixed; boundary="+outerB)
|
||||
@@ -809,27 +813,27 @@ func writePrimaryBody(buf *bytes.Buffer, b Builder) {
|
||||
}
|
||||
}
|
||||
|
||||
// writeAlternativeOrSingleBody writes the text body block.
|
||||
// If multiple body types (text/plain, text/html, text/calendar) are present,
|
||||
// they are wrapped in multipart/alternative. Otherwise a single part is written.
|
||||
// writeAlternativeOrSingleBody writes the body block. When multiple content
|
||||
// types coexist (text/plain, text/html, text/calendar), they are wrapped in
|
||||
// multipart/alternative. text/calendar lives inside alternative as an
|
||||
// alternative representation, matching the Feishu client behavior.
|
||||
func writeAlternativeOrSingleBody(buf *bytes.Buffer, b Builder) {
|
||||
hasText := len(b.textBody) > 0
|
||||
hasHTML := len(b.htmlBody) > 0
|
||||
hasCal := len(b.calendarBody) > 0
|
||||
|
||||
bodyCount := 0
|
||||
partCount := 0
|
||||
if hasText {
|
||||
bodyCount++
|
||||
partCount++
|
||||
}
|
||||
if hasHTML {
|
||||
bodyCount++
|
||||
partCount++
|
||||
}
|
||||
if hasCal {
|
||||
bodyCount++
|
||||
partCount++
|
||||
}
|
||||
|
||||
switch {
|
||||
case bodyCount > 1:
|
||||
if partCount > 1 {
|
||||
boundary := newBoundary()
|
||||
writeHeader(buf, "Content-Type", "multipart/alternative; boundary="+boundary)
|
||||
buf.WriteByte('\n')
|
||||
@@ -840,15 +844,15 @@ func writeAlternativeOrSingleBody(buf *bytes.Buffer, b Builder) {
|
||||
writeBodyPart(buf, boundary, "text/html", b.htmlBody)
|
||||
}
|
||||
if hasCal {
|
||||
writeBodyPart(buf, boundary, "text/calendar", b.calendarBody)
|
||||
fmt.Fprintf(buf, "--%s\n", boundary)
|
||||
writeCalendarPart(buf, b.calendarBody)
|
||||
}
|
||||
fmt.Fprintf(buf, "--%s--\n", boundary)
|
||||
case hasHTML:
|
||||
} else if hasHTML {
|
||||
writeSingleBodyPartHeaders(buf, "text/html", b.htmlBody)
|
||||
case hasCal:
|
||||
writeSingleBodyPartHeaders(buf, "text/calendar", b.calendarBody)
|
||||
default:
|
||||
// text/plain (also handles empty body)
|
||||
} else if hasCal {
|
||||
writeCalendarPart(buf, b.calendarBody)
|
||||
} else {
|
||||
writeSingleBodyPartHeaders(buf, "text/plain", b.textBody)
|
||||
}
|
||||
}
|
||||
@@ -992,6 +996,35 @@ func writeSingleBodyPartHeaders(buf *bytes.Buffer, ct string, body []byte) {
|
||||
writeFoldedBody(buf, encodeBodyContent(body, cte), lineWidthForCTE(cte))
|
||||
}
|
||||
|
||||
// writeCalendarPart writes the text/calendar MIME part. The method= parameter
|
||||
// is derived from the METHOD property in the ICS body (defaulting to REQUEST
|
||||
// when absent) so that passthrough ICS with METHOD:CANCEL or METHOD:REPLY
|
||||
// produce a Content-Type that matches the body.
|
||||
func writeCalendarPart(buf *bytes.Buffer, body []byte) {
|
||||
method := extractICSMethod(body)
|
||||
if method == "" {
|
||||
method = "REQUEST"
|
||||
}
|
||||
cte := selectCTE(body)
|
||||
fmt.Fprintf(buf, "Content-Type: text/calendar; method=%s; charset=UTF-8\n", method)
|
||||
fmt.Fprintf(buf, "Content-Transfer-Encoding: %s\n\n", cte)
|
||||
writeFoldedBody(buf, encodeBodyContent(body, cte), lineWidthForCTE(cte))
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
|
||||
// extractICSMethod scans the ICS body for the top-level METHOD property and
|
||||
// returns its value (e.g. "REQUEST", "CANCEL", "REPLY"). Returns "" when the
|
||||
// property is absent so callers can apply their own default.
|
||||
func extractICSMethod(body []byte) string {
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if strings.HasPrefix(strings.ToUpper(line), "METHOD:") {
|
||||
return strings.TrimSpace(line[7:])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// writeAttachmentPart writes a MIME attachment part.
|
||||
// Body is always base64 (StdEncoding), written in 76-character lines per RFC 2045.
|
||||
func writeAttachmentPart(buf *bytes.Buffer, att attachment) {
|
||||
|
||||
@@ -678,6 +678,8 @@ func TestBuild_CalendarWithText(t *testing.T) {
|
||||
}
|
||||
eml := string(raw)
|
||||
|
||||
// text/calendar lives inside multipart/alternative as an alternative
|
||||
// representation of the body, matching Feishu client behavior.
|
||||
if !strings.Contains(eml, "multipart/alternative") {
|
||||
t.Errorf("expected multipart/alternative for text+calendar:\n%s", eml)
|
||||
}
|
||||
@@ -1359,3 +1361,35 @@ func TestHeaderValueTabAllowed(t *testing.T) {
|
||||
t.Errorf("Header with tab in value: expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCalendarPart_MethodFromBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ics string
|
||||
wantCT string
|
||||
}{
|
||||
{"request", "BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nEND:VCALENDAR\r\n", "method=REQUEST"},
|
||||
{"cancel", "BEGIN:VCALENDAR\r\nMETHOD:CANCEL\r\nEND:VCALENDAR\r\n", "method=CANCEL"},
|
||||
{"reply", "BEGIN:VCALENDAR\r\nMETHOD:REPLY\r\nEND:VCALENDAR\r\n", "method=REPLY"},
|
||||
{"no method defaults to REQUEST", "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", "method=REQUEST"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
eml, err := New().
|
||||
From("", "sender@example.com").
|
||||
To("", "recipient@example.com").
|
||||
Subject("Test").
|
||||
Date(fixedDate).
|
||||
MessageID("test-method@x").
|
||||
HTMLBody([]byte("<p>hi</p>")).
|
||||
CalendarBody([]byte(tc.ics)).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(eml), tc.wantCT) {
|
||||
t.Errorf("expected Content-Type to contain %q\n%s", tc.wantCT, eml)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
"github.com/larksuite/cli/shortcuts/mail/ics"
|
||||
)
|
||||
|
||||
// hintIdentityFirst prints a one-line tip to stderr for read-only mail shortcuts
|
||||
@@ -184,6 +185,15 @@ func printMessageOutputSchema(runtime *common.RuntimeContext) {
|
||||
"attachments[].attachment_type": "Attachment type. Values: 1 = normal, 2 = large attachment",
|
||||
"attachments[].is_inline": "true = inline image, false = regular attachment",
|
||||
"attachments[].cid": "Content-ID for inline images (maps to <img src='cid:...'>)",
|
||||
"calendar_event": "Parsed calendar invitation; present when the email contains a text/calendar part",
|
||||
"calendar_event.method": "iTIP method, e.g. REQUEST, CANCEL, REPLY",
|
||||
"calendar_event.uid": "Globally unique event identifier (UID property)",
|
||||
"calendar_event.summary": "Event title (SUMMARY property)",
|
||||
"calendar_event.start": "Event start time in RFC 3339 / ISO 8601 format (UTC)",
|
||||
"calendar_event.end": "Event end time in RFC 3339 / ISO 8601 format (UTC)",
|
||||
"calendar_event.location": "Event location string; omitted when not set",
|
||||
"calendar_event.organizer": "Organizer email address",
|
||||
"calendar_event.attendees": "List of attendee email addresses",
|
||||
},
|
||||
"thread_extra_fields": map[string]string{
|
||||
"thread_id": "Thread ID",
|
||||
@@ -1199,11 +1209,23 @@ type normalizedMessageForCompose struct {
|
||||
BodyPlainText string `json:"body_plain_text"`
|
||||
BodyPreview string `json:"body_preview"`
|
||||
BodyHTML string `json:"body_html,omitempty"`
|
||||
CalendarEvent *calendarEventOutput `json:"calendar_event,omitempty"`
|
||||
Attachments []mailAttachmentOutput `json:"attachments"`
|
||||
Images []mailImageOutput `json:"images"`
|
||||
Warnings []warningEntry `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type calendarEventOutput struct {
|
||||
Method string `json:"method,omitempty"`
|
||||
UID string `json:"uid,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Organizer string `json:"organizer,omitempty"`
|
||||
Attendees []string `json:"attendees,omitempty"`
|
||||
}
|
||||
|
||||
// fetchAttachmentURLs fetches download URLs for the given attachment IDs in batches of 20.
|
||||
// List params are embedded directly in the URL (SDK workaround for repeated query params).
|
||||
// It never returns an error: failed batches/IDs are converted to structured warnings so caller can continue.
|
||||
@@ -1349,6 +1371,9 @@ func buildMessageOutput(msg map[string]interface{}, html bool) map[string]interf
|
||||
if html && normalized.BodyHTML != "" {
|
||||
out["body_html"] = normalized.BodyHTML
|
||||
}
|
||||
if normalized.CalendarEvent != nil {
|
||||
out["calendar_event"] = normalized.CalendarEvent
|
||||
}
|
||||
out["attachments"] = buildPublicAttachments(msg)
|
||||
|
||||
return out
|
||||
@@ -1458,6 +1483,29 @@ func buildMessageForCompose(msg map[string]interface{}, urlMap map[string]string
|
||||
out.BodyHTML = decodeBase64URL(strVal(msg["body_html"]))
|
||||
}
|
||||
|
||||
// Calendar event
|
||||
if bodyCalendar := strVal(msg["body_calendar"]); bodyCalendar != "" {
|
||||
if decoded := decodeBase64URL(bodyCalendar); decoded != "" {
|
||||
if parsed := ics.ParseEvent(decoded); parsed != nil {
|
||||
ce := &calendarEventOutput{
|
||||
Method: parsed.Method,
|
||||
UID: parsed.UID,
|
||||
Summary: parsed.Summary,
|
||||
Location: parsed.Location,
|
||||
Organizer: parsed.Organizer,
|
||||
Attendees: parsed.Attendees,
|
||||
}
|
||||
if !parsed.Start.IsZero() {
|
||||
ce.Start = parsed.Start.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !parsed.End.IsZero() {
|
||||
ce.End = parsed.End.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out.CalendarEvent = ce
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments
|
||||
attachments := make([]mailAttachmentOutput, 0)
|
||||
images := make([]mailImageOutput, 0)
|
||||
@@ -1568,6 +1616,7 @@ type composeSourceMessage struct {
|
||||
ForwardAttachments []forwardSourceAttachment
|
||||
InlineImages []inlineSourcePart
|
||||
FailedAttachmentIDs map[string]bool
|
||||
OriginalCalendarICS []byte // raw ICS bytes from body_calendar (for forward passthrough)
|
||||
}
|
||||
|
||||
// fetchComposeSourceMessage loads a message via the +message pipeline and converts it
|
||||
@@ -1577,6 +1626,12 @@ func fetchComposeSourceMessage(runtime *common.RuntimeContext, mailboxID, messag
|
||||
if err != nil {
|
||||
return composeSourceMessage{}, err
|
||||
}
|
||||
var originalCalICS []byte
|
||||
if bodyCalendar := strVal(msg["body_calendar"]); bodyCalendar != "" {
|
||||
if decoded := decodeBase64URL(bodyCalendar); decoded != "" {
|
||||
originalCalICS = []byte(decoded)
|
||||
}
|
||||
}
|
||||
attIDs := extractAttachmentIDs(msg)
|
||||
urlMap, warnings := fetchAttachmentURLs(runtime, mailboxID, messageID, attIDs)
|
||||
failedIDs := make(map[string]bool)
|
||||
@@ -1592,6 +1647,7 @@ func fetchComposeSourceMessage(runtime *common.RuntimeContext, mailboxID, messag
|
||||
ForwardAttachments: toForwardSourceAttachments(out),
|
||||
InlineImages: toInlineSourceParts(out),
|
||||
FailedAttachmentIDs: failedIDs,
|
||||
OriginalCalendarICS: originalCalICS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2252,6 +2308,21 @@ func inlineSpecFilePaths(specs []InlineSpec) []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
// validateEventSendTimeExclusion checks that --send-time and --event-* are not
|
||||
// used together. This is enforced here (in Validate, before Execute) because the
|
||||
// Shortcut framework does not expose a cobra-level hook for MarkFlagsMutuallyExclusive.
|
||||
func validateEventSendTimeExclusion(runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("send-time") == "" {
|
||||
return nil
|
||||
}
|
||||
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
|
||||
if runtime.Str(f) != "" {
|
||||
return common.FlagErrorf("--send-time and --event-* are mutually exclusive: a calendar invitation must be sent immediately so recipients can respond before the event")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSendTime checks that --send-time, if provided, requires --confirm-send,
|
||||
// is a valid Unix timestamp in seconds, and is at least 5 minutes in the future.
|
||||
func validateSendTime(runtime *common.RuntimeContext) error {
|
||||
@@ -2391,3 +2462,143 @@ func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFl
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCalendarBodyFromArgs builds ICS from explicit string arguments (for draft-edit).
|
||||
// Callers are expected to have pre-validated startStr/endStr via parseEventTimeRange;
|
||||
// parse errors are silently ignored here and produce a zero-time DTSTART/DTEND.
|
||||
func buildCalendarBodyFromArgs(summary, startStr, endStr, location, senderEmail, toAddrs, ccAddrs string) []byte {
|
||||
if summary == "" {
|
||||
return nil
|
||||
}
|
||||
start, _ := parseISO8601(startStr)
|
||||
end, _ := parseISO8601(endStr)
|
||||
|
||||
var attendees []ics.Address
|
||||
for _, addr := range parseNetAddrs(toAddrs) {
|
||||
if addr.Address != "" {
|
||||
attendees = append(attendees, ics.Address{Name: addr.Name, Email: addr.Address})
|
||||
}
|
||||
}
|
||||
for _, addr := range parseNetAddrs(ccAddrs) {
|
||||
if addr.Address != "" {
|
||||
attendees = append(attendees, ics.Address{Name: addr.Name, Email: addr.Address})
|
||||
}
|
||||
}
|
||||
|
||||
return ics.Build(ics.Event{
|
||||
Summary: summary,
|
||||
Location: location,
|
||||
Start: start,
|
||||
End: end,
|
||||
Organizer: ics.Address{Email: senderEmail},
|
||||
Attendees: attendees,
|
||||
})
|
||||
}
|
||||
|
||||
// joinAddresses joins draft Address list into comma-separated string.
|
||||
func joinAddresses(addrs []draftpkg.Address) string {
|
||||
if len(addrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, len(addrs))
|
||||
for i, a := range addrs {
|
||||
parts[i] = a.Address
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// Calendar event flag definitions, shared by all compose shortcuts.
|
||||
// Declared as individual vars (like priorityFlag and signatureFlag) so
|
||||
// callers can list them explicitly in their Flags slice without relying
|
||||
// on slice-index access.
|
||||
var (
|
||||
eventSummaryFlag = common.Flag{Name: "event-summary", Desc: "Calendar event title. Setting this enables calendar invitation mode."}
|
||||
eventStartFlag = common.Flag{Name: "event-start", Desc: "Event start time (ISO 8601, e.g. 2026-04-20T14:00+08:00). Required when --event-summary is set."}
|
||||
eventEndFlag = common.Flag{Name: "event-end", Desc: "Event end time (ISO 8601). Required when --event-summary is set."}
|
||||
eventLocationFlag = common.Flag{Name: "event-location", Desc: "Event location (optional)."}
|
||||
)
|
||||
|
||||
// validateEventFlags checks that --event-summary, --event-start, --event-end are either all set or all empty.
|
||||
func validateEventFlags(runtime *common.RuntimeContext) error {
|
||||
summary := runtime.Str("event-summary")
|
||||
start := runtime.Str("event-start")
|
||||
end := runtime.Str("event-end")
|
||||
location := runtime.Str("event-location")
|
||||
|
||||
hasAny := summary != "" || start != "" || end != "" || location != ""
|
||||
hasAll := summary != "" && start != "" && end != ""
|
||||
|
||||
if hasAny && !hasAll {
|
||||
return fmt.Errorf("--event-summary, --event-start, and --event-end must all be provided together")
|
||||
}
|
||||
if summary == "" {
|
||||
return nil
|
||||
}
|
||||
if _, _, err := parseEventTimeRange(start, end); err != nil {
|
||||
return prefixEventRangeError("--event-", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseEventTimeRange parses start/end ISO 8601 strings and verifies that
|
||||
// end is strictly after start. Shared by validateEventFlags (compose path)
|
||||
// and buildDraftEditPatch (draft-edit path) so the rules stay in one place.
|
||||
func parseEventTimeRange(start, end string) (time.Time, time.Time, error) {
|
||||
startT, err := parseISO8601(start)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("start: invalid ISO 8601 time %q", start)
|
||||
}
|
||||
endT, err := parseISO8601(end)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("end: invalid ISO 8601 time %q", end)
|
||||
}
|
||||
if !endT.After(startT) {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("end time must be after start time")
|
||||
}
|
||||
return startT, endT, nil
|
||||
}
|
||||
|
||||
// prefixEventRangeError rewrites parseEventTimeRange's "start:" / "end:"
|
||||
// error with the caller's flag-name prefix so users see the exact flag
|
||||
// that caused the failure.
|
||||
func prefixEventRangeError(flagPrefix string, err error) error {
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.HasPrefix(msg, "start: "):
|
||||
return fmt.Errorf("%sstart: %s", flagPrefix, strings.TrimPrefix(msg, "start: "))
|
||||
case strings.HasPrefix(msg, "end: "):
|
||||
return fmt.Errorf("%send: %s", flagPrefix, strings.TrimPrefix(msg, "end: "))
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// parseISO8601 parses common ISO 8601 time formats.
|
||||
func parseISO8601(s string) (time.Time, error) {
|
||||
formats := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02T15:04Z07:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, f := range formats {
|
||||
if t, err := time.Parse(f, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("cannot parse %q as ISO 8601", s)
|
||||
}
|
||||
|
||||
// buildCalendarBody generates an ICS VCALENDAR from compose flags and returns the bytes.
|
||||
// Returns nil if --event-summary is not set.
|
||||
func buildCalendarBody(runtime *common.RuntimeContext, senderEmail string, toAddrs, ccAddrs string) []byte {
|
||||
return buildCalendarBodyFromArgs(
|
||||
runtime.Str("event-summary"),
|
||||
runtime.Str("event-start"),
|
||||
runtime.Str("event-end"),
|
||||
runtime.Str("event-location"),
|
||||
senderEmail, toAddrs, ccAddrs,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1085,7 +1085,39 @@ func TestValidateSendTime_Valid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePriority verifies parse priority.
|
||||
func TestValidateEventSendTimeExclusion(t *testing.T) {
|
||||
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
|
||||
cases := []struct {
|
||||
name string
|
||||
eventFlag string
|
||||
eventVal string
|
||||
}{
|
||||
{"event-summary triggers exclusion", "event-summary", "Team meeting"},
|
||||
{"event-start triggers exclusion", "event-start", "2026-05-01T10:00+08:00"},
|
||||
{"event-end triggers exclusion", "event-end", "2026-05-01T11:00+08:00"},
|
||||
{"event-location triggers exclusion", "event-location", "Room 5F"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("send-time", "", "")
|
||||
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
|
||||
cmd.Flags().String(f, "", "")
|
||||
}
|
||||
_ = cmd.Flags().Set("send-time", future)
|
||||
_ = cmd.Flags().Set(tc.eventFlag, tc.eventVal)
|
||||
rt := &common.RuntimeContext{Cmd: cmd}
|
||||
err := validateEventSendTimeExclusion(rt)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when --send-time and --%s are both set", tc.eventFlag)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--event-*") {
|
||||
t.Errorf("expected error to mention --event-*, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePriority(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -1334,7 +1366,6 @@ func newRequestReceiptRuntime(t *testing.T, requestReceipt bool) *common.Runtime
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// TestRequireSenderForRequestReceipt verifies require sender for request receipt.
|
||||
func TestRequireSenderForRequestReceipt(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -1365,7 +1396,6 @@ func TestRequireSenderForRequestReceipt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShellQuoteForHint verifies shell quote for hint.
|
||||
func TestShellQuoteForHint(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -1391,7 +1421,6 @@ func TestShellQuoteForHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeForSingleLine verifies sanitize for single line.
|
||||
func TestSanitizeForSingleLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -1415,7 +1444,6 @@ func TestSanitizeForSingleLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateHeaderAddress verifies validate header address.
|
||||
func TestValidateHeaderAddress(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -1447,3 +1475,199 @@ func TestValidateHeaderAddress(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseEventTimeRange
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseEventTimeRange_OK(t *testing.T) {
|
||||
s, e, err := parseEventTimeRange("2026-04-25T14:00+08:00", "2026-04-25T15:00+08:00")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !e.After(s) {
|
||||
t.Errorf("end should be after start; got start=%v end=%v", s, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEventTimeRange_EndBeforeStart(t *testing.T) {
|
||||
_, _, err := parseEventTimeRange("2026-04-25T15:00+08:00", "2026-04-25T14:00+08:00")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when end < start")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "end time must be after start time") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEventTimeRange_EndEqualsStart(t *testing.T) {
|
||||
_, _, err := parseEventTimeRange("2026-04-25T14:00+08:00", "2026-04-25T14:00+08:00")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when end == start (zero duration)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEventTimeRange_InvalidStart(t *testing.T) {
|
||||
_, _, err := parseEventTimeRange("not-a-time", "2026-04-25T15:00+08:00")
|
||||
if err == nil || !strings.Contains(err.Error(), "start: invalid ISO 8601") {
|
||||
t.Errorf("expected start parse error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEventTimeRange_InvalidEnd(t *testing.T) {
|
||||
_, _, err := parseEventTimeRange("2026-04-25T14:00+08:00", "not-a-time")
|
||||
if err == nil || !strings.Contains(err.Error(), "end: invalid ISO 8601") {
|
||||
t.Errorf("expected end parse error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrefixEventRangeError(t *testing.T) {
|
||||
start := fmt.Errorf("start: invalid ISO 8601 time %q", "x")
|
||||
if got := prefixEventRangeError("--event-", start).Error(); got != `--event-start: invalid ISO 8601 time "x"` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
end := fmt.Errorf("end: invalid ISO 8601 time %q", "x")
|
||||
if got := prefixEventRangeError("--set-event-", end).Error(); got != `--set-event-end: invalid ISO 8601 time "x"` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
// Non-prefixed error passes through unchanged.
|
||||
other := fmt.Errorf("end time must be after start time")
|
||||
if got := prefixEventRangeError("--event-", other).Error(); got != "end time must be after start time" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateEventFlags (runtime-backed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func newEventFlagsRuntime(t *testing.T, summary, start, end string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("event-summary", "", "")
|
||||
cmd.Flags().String("event-start", "", "")
|
||||
cmd.Flags().String("event-end", "", "")
|
||||
cmd.Flags().String("event-location", "", "")
|
||||
if summary != "" {
|
||||
_ = cmd.Flags().Set("event-summary", summary)
|
||||
}
|
||||
if start != "" {
|
||||
_ = cmd.Flags().Set("event-start", start)
|
||||
}
|
||||
if end != "" {
|
||||
_ = cmd.Flags().Set("event-end", end)
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestValidateEventFlags_AllEmptyOK(t *testing.T) {
|
||||
rt := newEventFlagsRuntime(t, "", "", "")
|
||||
if err := validateEventFlags(rt); err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEventFlags_AllSetOK(t *testing.T) {
|
||||
rt := newEventFlagsRuntime(t, "Meeting", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00")
|
||||
if err := validateEventFlags(rt); err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEventFlags_PartialRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
summary string
|
||||
start string
|
||||
end string
|
||||
}{
|
||||
{"only_summary", "Meeting", "", ""},
|
||||
{"only_start", "", "2026-04-25T10:00+08:00", ""},
|
||||
{"only_end", "", "", "2026-04-25T11:00+08:00"},
|
||||
{"missing_end", "Meeting", "2026-04-25T10:00+08:00", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt := newEventFlagsRuntime(t, tc.summary, tc.start, tc.end)
|
||||
err := validateEventFlags(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "must all be provided together") {
|
||||
t.Errorf("expected 'all together' error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEventFlags_EndBeforeStartRejected(t *testing.T) {
|
||||
rt := newEventFlagsRuntime(t, "Meeting", "2026-04-25T11:00+08:00", "2026-04-25T10:00+08:00")
|
||||
err := validateEventFlags(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "after start") {
|
||||
t.Errorf("expected end-after-start error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEventFlags_InvalidTimeFormatRejected(t *testing.T) {
|
||||
rt := newEventFlagsRuntime(t, "Meeting", "not-a-time", "2026-04-25T11:00+08:00")
|
||||
err := validateEventFlags(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--event-start") {
|
||||
t.Errorf("expected --event-start error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildCalendarBodyFromArgs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildCalendarBodyFromArgs_EmptySummaryReturnsNil(t *testing.T) {
|
||||
got := buildCalendarBodyFromArgs("", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00", "", "sender@example.com", "to@example.com", "")
|
||||
if got != nil {
|
||||
t.Errorf("expected nil for empty summary, got %d bytes", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCalendarBodyFromArgs_IncludesSummaryAndAddresses(t *testing.T) {
|
||||
got := buildCalendarBodyFromArgs(
|
||||
"Product Review",
|
||||
"2026-04-25T14:00+08:00",
|
||||
"2026-04-25T15:00+08:00",
|
||||
"5F Room",
|
||||
"sender@example.com",
|
||||
"a@example.com,b@example.com",
|
||||
"c@example.com",
|
||||
)
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil ICS bytes")
|
||||
}
|
||||
s := string(got)
|
||||
checks := []string{
|
||||
"BEGIN:VCALENDAR",
|
||||
"SUMMARY:Product Review",
|
||||
"LOCATION:5F Room",
|
||||
"sender@example.com",
|
||||
"a@example.com",
|
||||
"b@example.com",
|
||||
"c@example.com",
|
||||
}
|
||||
for _, want := range checks {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in generated ICS:\n%s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCalendarBodyFromArgs_NoCcWorks(t *testing.T) {
|
||||
got := buildCalendarBodyFromArgs(
|
||||
"Meeting",
|
||||
"2026-04-25T10:00+08:00",
|
||||
"2026-04-25T11:00+08:00",
|
||||
"",
|
||||
"sender@example.com",
|
||||
"to@example.com",
|
||||
"",
|
||||
)
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil ICS bytes")
|
||||
}
|
||||
if !strings.Contains(string(got), "to@example.com") {
|
||||
t.Error("attendee missing")
|
||||
}
|
||||
}
|
||||
|
||||
180
shortcuts/mail/ics/builder.go
Normal file
180
shortcuts/mail/ics/builder.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package ics provides RFC 5545 iCalendar generation and parsing for mail calendar invitations.
|
||||
package ics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Event holds the data needed to generate an ICS VCALENDAR invitation.
|
||||
type Event struct {
|
||||
UID string // auto-generated if empty
|
||||
Summary string // SUMMARY (required)
|
||||
Location string // LOCATION (optional)
|
||||
Start time.Time // DTSTART (required)
|
||||
End time.Time // DTEND (required)
|
||||
Organizer Address // ORGANIZER
|
||||
Attendees []Address // ATTENDEE list (To + Cc, excluding Bcc)
|
||||
}
|
||||
|
||||
// Address represents a name + email pair for ORGANIZER / ATTENDEE.
|
||||
type Address struct {
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
// Build generates a RFC 5545 VCALENDAR byte slice with METHOD:REQUEST.
|
||||
// The output is suitable for use as a text/calendar MIME part.
|
||||
func Build(event Event) []byte {
|
||||
uid := event.UID
|
||||
if uid == "" {
|
||||
uid = uuid.New().String()
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
nowICS := formatICSTime(now)
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("BEGIN:VCALENDAR\r\n")
|
||||
b.WriteString("CALSCALE:GREGORIAN\r\n")
|
||||
b.WriteString("VERSION:2.0\r\n")
|
||||
b.WriteString("PRODID:-//Lark CLI//EN\r\n")
|
||||
b.WriteString("METHOD:REQUEST\r\n")
|
||||
b.WriteString("X-LARK-MAIL-DRAFT:TRUE\r\n")
|
||||
b.WriteString("BEGIN:VEVENT\r\n")
|
||||
writeFolded(&b, "UID", uid)
|
||||
writeFolded(&b, "DTSTAMP", nowICS)
|
||||
writeFolded(&b, "CREATED", nowICS)
|
||||
writeFolded(&b, "LAST-MODIFIED", nowICS)
|
||||
writeFolded(&b, "DTSTART", formatICSTime(event.Start.UTC()))
|
||||
writeFolded(&b, "DTEND", formatICSTime(event.End.UTC()))
|
||||
writeFolded(&b, "SUMMARY", escapeTextValue(event.Summary))
|
||||
if event.Location != "" {
|
||||
writeFolded(&b, "LOCATION", escapeTextValue(event.Location))
|
||||
}
|
||||
b.WriteString("STATUS:CONFIRMED\r\n")
|
||||
b.WriteString("TRANSP:OPAQUE\r\n")
|
||||
b.WriteString("SEQUENCE:0\r\n")
|
||||
if event.Organizer.Email != "" {
|
||||
organizer := "ORGANIZER;ROLE=CHAIR"
|
||||
if event.Organizer.Name != "" {
|
||||
organizer += ";CN=" + quoteCNParam(event.Organizer.Name)
|
||||
} else {
|
||||
organizer += ";CN=" + quoteCNParam(event.Organizer.Email)
|
||||
}
|
||||
writeFolded(&b, organizer, mailtoScheme+sanitizeMailtoAddress(event.Organizer.Email))
|
||||
}
|
||||
for _, a := range event.Attendees {
|
||||
attendee := "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL"
|
||||
if a.Name != "" {
|
||||
attendee += ";CN=" + quoteCNParam(a.Name)
|
||||
} else {
|
||||
attendee += ";CN=" + quoteCNParam(a.Email)
|
||||
}
|
||||
attendee += ";PARTSTAT=NEEDS-ACTION"
|
||||
writeFolded(&b, attendee, mailtoScheme+sanitizeMailtoAddress(a.Email))
|
||||
}
|
||||
b.WriteString("END:VEVENT\r\n")
|
||||
b.WriteString("END:VCALENDAR\r\n")
|
||||
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// formatICSTime formats a time.Time as ICS UTC: YYYYMMDDTHHMMSSZ.
|
||||
func formatICSTime(t time.Time) string {
|
||||
return t.Format("20060102T150405Z")
|
||||
}
|
||||
|
||||
// escapeTextValue escapes a string for use as an ICS TEXT value per RFC 5545
|
||||
// §3.3.11: backslash, newline, semicolon, and comma carry structural meaning
|
||||
// and must be escaped. Applied to SUMMARY, LOCATION, DESCRIPTION etc. — not
|
||||
// to identifiers (UID), date-times (DTSTART/DTEND), or URIs.
|
||||
//
|
||||
// Without this, a user-supplied summary containing a newline or colon would
|
||||
// let the payload inject a fake property line, e.g.
|
||||
//
|
||||
// --event-summary "foo\nDTSTART:20000101T000000Z"
|
||||
//
|
||||
// would turn into a second DTSTART line after folding.
|
||||
func escapeTextValue(s string) string {
|
||||
// Normalise CR / CRLF so downstream only sees LF.
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\n")
|
||||
// Order matters: escape backslash first so its own replacement is not
|
||||
// picked up by later rules.
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, ";", `\;`)
|
||||
s = strings.ReplaceAll(s, ",", `\,`)
|
||||
return s
|
||||
}
|
||||
|
||||
// quoteCNParam wraps a CN parameter value in double-quotes per RFC 5545 §3.2
|
||||
// when the value contains characters that are not allowed in an unquoted
|
||||
// paramtext (, ; :). Characters that are illegal inside a quoted-string are
|
||||
// stripped: DQUOTE (%x22) is excluded by QSAFE-CHAR, and control characters
|
||||
// (%x00–%x08, %x0A–%x1F, %x7F) would break the property line structure.
|
||||
func quoteCNParam(s string) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if r == '"' || r < 0x09 || (r >= 0x0A && r <= 0x1F) || r == 0x7F {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
if strings.ContainsAny(s, ",:;") {
|
||||
return `"` + s + `"`
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// writeFolded writes a property line with RFC 5545 line folding (75-octet limit).
|
||||
// Long lines are folded by inserting CRLF + space at UTF-8 character boundaries.
|
||||
// Continuation lines begin with a single SPACE (1 octet), so their content is
|
||||
// limited to 74 octets to keep the total physical line at ≤ 75 octets.
|
||||
func writeFolded(b *strings.Builder, name, value string) {
|
||||
line := fmt.Sprintf("%s:%s", name, value)
|
||||
const maxLineOctets = 75 // RFC 5545 §3.1: lines SHOULD NOT be longer than 75 octets
|
||||
limit := maxLineOctets
|
||||
for len(line) > limit {
|
||||
// Find the last complete UTF-8 character that fits within the limit.
|
||||
cut := 0
|
||||
for i := 0; i < len(line); {
|
||||
_, size := utf8.DecodeRuneInString(line[i:])
|
||||
if i+size > limit {
|
||||
break
|
||||
}
|
||||
i += size
|
||||
cut = i
|
||||
}
|
||||
if cut == 0 {
|
||||
// Single character exceeds limit (shouldn't happen in practice).
|
||||
cut = limit
|
||||
}
|
||||
b.WriteString(line[:cut])
|
||||
b.WriteString("\r\n ")
|
||||
line = line[cut:]
|
||||
limit = maxLineOctets - 1 // continuation lines: 1-octet SPACE + 74 content = 75
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
|
||||
// sanitizeMailtoAddress strips control characters (CR, LF, and other chars
|
||||
// below 0x20 or equal to 0x7F) from an email address before embedding it in a
|
||||
// MAILTO: URI value. Prevents property-injection attacks analogous to the CN
|
||||
// parameter protection in quoteCNParam.
|
||||
func sanitizeMailtoAddress(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7F {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user