mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
686c91dc71 | ||
|
|
cfd89e0e28 | ||
|
|
ac4c34f2ad | ||
|
|
3ed691b25c | ||
|
|
30ad38d4b6 | ||
|
|
4fab062219 | ||
|
|
f27b8fdf40 | ||
|
|
c100ca049e | ||
|
|
4d68e09537 | ||
|
|
a3bbe00ee0 | ||
|
|
0250054a90 | ||
|
|
d7ee5b5769 |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -2,6 +2,25 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
@@ -560,6 +579,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -122,5 +122,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"}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.22",
|
||||
"version": "1.0.23",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -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)
|
||||
@@ -1674,7 +1742,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,102 @@ 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",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
|
||||
"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 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") {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var BaseRecordGet = common.Shortcut{
|
||||
@@ -21,7 +22,15 @@ var BaseRecordGet = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
|
||||
"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",
|
||||
}
|
||||
}
|
||||
|
||||
237
shortcuts/base/record_markdown.go
Normal file
237
shortcuts/base/record_markdown.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// 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 {
|
||||
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 := renderRecordMarkdown(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 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')
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
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 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
|
||||
}
|
||||
}
|
||||
229
shortcuts/base/record_markdown_test.go
Normal file
229
shortcuts/base/record_markdown_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
// 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 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())
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,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,6 +193,9 @@ 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
|
||||
}
|
||||
@@ -213,6 +219,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
530
shortcuts/markdown/helpers.go
Normal file
530
shortcuts/markdown/helpers.go
Normal file
@@ -0,0 +1,530 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"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 markdownSinglePartSizeLimit = common.MaxDriveMediaUploadSinglePartSize
|
||||
const markdownEmptyContentError = "empty markdown content is not supported; cannot create or overwrite an empty file"
|
||||
|
||||
type markdownUploadSpec struct {
|
||||
FileToken string
|
||||
FileName string
|
||||
FolderToken string
|
||||
FilePath string
|
||||
Content string
|
||||
ContentSet bool
|
||||
FileSet bool
|
||||
}
|
||||
|
||||
type markdownUploadResult struct {
|
||||
FileToken string
|
||||
Version string
|
||||
}
|
||||
|
||||
type markdownMultipartSession struct {
|
||||
UploadID string
|
||||
BlockSize int64
|
||||
BlockNum int
|
||||
}
|
||||
|
||||
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
|
||||
switch {
|
||||
case spec.ContentSet && spec.FileSet:
|
||||
return common.FlagErrorf("--content and --file are mutually exclusive")
|
||||
case !spec.ContentSet && !spec.FileSet:
|
||||
return common.FlagErrorf("specify exactly one of --content or --file")
|
||||
}
|
||||
|
||||
if runtime.Changed("folder-token") && strings.TrimSpace(spec.FolderToken) == "" {
|
||||
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
|
||||
}
|
||||
if spec.FolderToken != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if requireName && spec.ContentSet {
|
||||
if strings.TrimSpace(spec.FileName) == "" {
|
||||
return common.FlagErrorf("--name is required when using --content")
|
||||
}
|
||||
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if spec.FileSet {
|
||||
if strings.TrimSpace(spec.FilePath) == "" {
|
||||
return common.FlagErrorf("--file cannot be empty")
|
||||
}
|
||||
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
|
||||
return output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
if err := validateMarkdownFileName(filepath.Base(spec.FilePath), "--file"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if spec.FileName != "" {
|
||||
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMarkdownFileName(name, flagName string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return common.FlagErrorf("%s cannot be empty", flagName)
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(trimmed), ".md") {
|
||||
return common.FlagErrorf("%s must end with .md", flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func finalMarkdownFileName(spec markdownUploadSpec) string {
|
||||
if strings.TrimSpace(spec.FileName) != "" {
|
||||
return strings.TrimSpace(spec.FileName)
|
||||
}
|
||||
if strings.TrimSpace(spec.FilePath) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(spec.FilePath)
|
||||
}
|
||||
|
||||
func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec) (int64, error) {
|
||||
var size int64
|
||||
if spec.ContentSet {
|
||||
size = int64(len(spec.Content))
|
||||
} else {
|
||||
if strings.TrimSpace(spec.FilePath) == "" {
|
||||
return 0, common.FlagErrorf("--file cannot be empty")
|
||||
}
|
||||
|
||||
info, err := runtime.FileIO().Stat(spec.FilePath)
|
||||
if err != nil {
|
||||
return 0, common.WrapInputStatError(err)
|
||||
}
|
||||
size = info.Size()
|
||||
}
|
||||
if size == 0 {
|
||||
return 0, output.ErrValidation("%s", markdownEmptyContentError)
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func markdownDryRunFileField(spec markdownUploadSpec) string {
|
||||
if spec.FilePath != "" {
|
||||
return "@" + spec.FilePath
|
||||
}
|
||||
return "<markdown content>"
|
||||
}
|
||||
|
||||
func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
|
||||
if !multipart {
|
||||
body := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
"file": markdownDryRunFileField(spec),
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
body["file_token"] = spec.FileToken
|
||||
}
|
||||
|
||||
desc := "multipart/form-data upload"
|
||||
if spec.FileToken != "" {
|
||||
desc = "multipart/form-data overwrite upload"
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/upload_all").
|
||||
Body(body)
|
||||
}
|
||||
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
prepareBody["file_token"] = spec.FileToken
|
||||
}
|
||||
|
||||
desc := "3-step multipart upload"
|
||||
if spec.FileToken != "" {
|
||||
desc = "3-step multipart overwrite upload"
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/upload_prepare").
|
||||
Desc("[1] Initialize multipart upload").
|
||||
Body(prepareBody).
|
||||
POST("/open-apis/drive/v1/files/upload_part").
|
||||
Desc("[2] Upload file parts (repeated)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/files/upload_finish").
|
||||
Desc("[3] Finalize upload and get file_token/version").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
}
|
||||
|
||||
func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
|
||||
fileName := strings.TrimSpace(spec.FileName)
|
||||
if fileName == "" && spec.FileSet {
|
||||
fileName = finalMarkdownFileName(spec)
|
||||
}
|
||||
if fileName != "" {
|
||||
spec.FileName = fileName
|
||||
return markdownUploadDryRun(spec, fileSize, multipart)
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI().Desc("Fetch the existing file name, then overwrite the file content")
|
||||
dry.POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("[1] Read current file metadata to preserve the existing file name").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": spec.FileToken,
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec.FileName = "<existing_remote_name_or_" + spec.FileToken + ".md>"
|
||||
if !multipart {
|
||||
dry.POST("/open-apis/drive/v1/files/upload_all").
|
||||
Desc("[2] Overwrite file contents with multipart/form-data upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.FileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
"file": markdownDryRunFileField(spec),
|
||||
"file_token": spec.FileToken,
|
||||
})
|
||||
return dry
|
||||
}
|
||||
|
||||
dry.POST("/open-apis/drive/v1/files/upload_prepare").
|
||||
Desc("[2] Initialize multipart overwrite upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.FileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
"file_token": spec.FileToken,
|
||||
}).
|
||||
POST("/open-apis/drive/v1/files/upload_part").
|
||||
Desc("[3] Upload file parts (repeated)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/files/upload_finish").
|
||||
Desc("[4] Finalize upload and get file_token/version").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
return dry
|
||||
}
|
||||
|
||||
func uploadMarkdownContent(runtime *common.RuntimeContext, spec markdownUploadSpec, payload []byte) (markdownUploadResult, error) {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
fileSize := int64(len(payload))
|
||||
if fileSize > markdownSinglePartSizeLimit {
|
||||
return uploadMarkdownFileMultipart(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
|
||||
}
|
||||
return uploadMarkdownFileAll(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
|
||||
}
|
||||
|
||||
func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUploadSpec, fileSize int64) (markdownUploadResult, error) {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
f, err := runtime.FileIO().Open(spec.FilePath)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if fileSize > markdownSinglePartSizeLimit {
|
||||
return uploadMarkdownFileMultipart(runtime, spec, f, fileName, fileSize)
|
||||
}
|
||||
return uploadMarkdownFileAll(runtime, spec, f, fileName, fileSize)
|
||||
}
|
||||
|
||||
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", "explorer")
|
||||
fd.AddField("parent_node", spec.FolderToken)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
if spec.FileToken != "" {
|
||||
fd.AddField("file_token", spec.FileToken)
|
||||
}
|
||||
fd.AddFile("file", fileReader)
|
||||
|
||||
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 markdownUploadResult{}, err
|
||||
}
|
||||
return markdownUploadResult{}, output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
|
||||
data, err := common.ParseDriveMediaUploadResponse(apiResp, "upload failed")
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
return parseMarkdownUploadResult(data, spec.FileToken != "")
|
||||
}
|
||||
|
||||
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
prepareBody["file_token"] = spec.FileToken
|
||||
}
|
||||
|
||||
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
session, err := parseMarkdownMultipartSession(prepareResult)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, common.FormatSize(session.BlockSize))
|
||||
|
||||
if err := uploadMarkdownMultipartParts(runtime, fileReader, fileSize, session); err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": session.UploadID,
|
||||
"block_num": session.BlockNum,
|
||||
})
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
return parseMarkdownUploadResult(finishResult, spec.FileToken != "")
|
||||
}
|
||||
|
||||
func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipartSession, error) {
|
||||
session := markdownMultipartSession{
|
||||
UploadID: common.GetString(data, "upload_id"),
|
||||
BlockSize: int64(common.GetFloat(data, "block_size")),
|
||||
BlockNum: int(common.GetFloat(data, "block_num")),
|
||||
}
|
||||
if session.UploadID == "" || session.BlockSize <= 0 || session.BlockNum <= 0 {
|
||||
return markdownMultipartSession{}, output.Errorf(output.ExitAPI, "api_error",
|
||||
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
|
||||
session.UploadID, session.BlockSize, session.BlockNum)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.Reader, payloadSize int64, session markdownMultipartSession) error {
|
||||
expectedBlocks := int((payloadSize + session.BlockSize - 1) / session.BlockSize)
|
||||
if session.BlockNum != expectedBlocks {
|
||||
return output.Errorf(
|
||||
output.ExitAPI,
|
||||
"api_error",
|
||||
"upload_prepare returned inconsistent chunk plan: block_size=%d, block_num=%d, expected_block_num=%d, payload_size=%d",
|
||||
session.BlockSize,
|
||||
session.BlockNum,
|
||||
expectedBlocks,
|
||||
payloadSize,
|
||||
)
|
||||
}
|
||||
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
if session.BlockSize > maxInt {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
|
||||
buffer := make([]byte, int(session.BlockSize))
|
||||
remaining := payloadSize
|
||||
|
||||
for seq := 0; seq < session.BlockNum; seq++ {
|
||||
chunkSize := session.BlockSize
|
||||
if remaining > 0 && chunkSize > remaining {
|
||||
chunkSize = remaining
|
||||
}
|
||||
|
||||
n, readErr := io.ReadFull(fileReader, buffer[:int(chunkSize)])
|
||||
if readErr != nil {
|
||||
return output.ErrValidation("cannot read file: %s", readErr)
|
||||
}
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", session.UploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", n))
|
||||
fd.AddFile("file", bytes.NewReader(buffer[:n]))
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("upload part %d/%d failed: %v", seq+1, session.BlockNum, err)
|
||||
}
|
||||
|
||||
if _, err := common.ParseDriveMediaUploadResponse(apiResp, fmt.Sprintf("upload part %d/%d failed", seq+1, session.BlockNum)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, common.FormatSize(int64(n)))
|
||||
remaining -= int64(n)
|
||||
}
|
||||
if remaining != 0 {
|
||||
return output.Errorf(
|
||||
output.ExitAPI,
|
||||
"api_error",
|
||||
"upload_prepare returned inconsistent chunk plan: %d bytes remain after %d blocks",
|
||||
remaining,
|
||||
session.BlockNum,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMarkdownUploadResult(data map[string]interface{}, requireVersion bool) (markdownUploadResult, error) {
|
||||
result := markdownUploadResult{
|
||||
FileToken: common.GetString(data, "file_token"),
|
||||
Version: common.GetString(data, "version"),
|
||||
}
|
||||
if result.Version == "" {
|
||||
result.Version = common.GetString(data, "data_version")
|
||||
}
|
||||
if result.FileToken == "" {
|
||||
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
}
|
||||
if requireVersion && result.Version == "" {
|
||||
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "overwrite failed: no version returned")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": fileToken,
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
metas := common.GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
meta, _ := metas[0].(map[string]interface{})
|
||||
return common.GetString(meta, "title"), nil
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownWrite(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
|
||||
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
|
||||
version := common.GetString(data, "version")
|
||||
if version == "" {
|
||||
version = common.GetString(data, "data_version")
|
||||
}
|
||||
if version != "" {
|
||||
fmt.Fprintf(w, "version: %s\n", version)
|
||||
}
|
||||
fmt.Fprintf(w, "size_bytes: %d\n", int64(common.GetFloat(data, "size_bytes")))
|
||||
if grant := common.GetMap(data, "permission_grant"); grant != nil {
|
||||
fmt.Fprintf(w, "permission_grant.status: %s\n", common.GetString(grant, "status"))
|
||||
fmt.Fprintf(w, "permission_grant.perm: %s\n", common.GetString(grant, "perm"))
|
||||
}
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownSavedFile(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
|
||||
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
|
||||
fmt.Fprintf(w, "saved_path: %s\n", common.GetString(data, "saved_path"))
|
||||
fmt.Fprintf(w, "size_bytes: %d\n", int64(common.GetFloat(data, "size_bytes")))
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownContent(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprint(w, common.GetString(data, "content"))
|
||||
}
|
||||
|
||||
func fileNameFromDownloadHeader(header http.Header, fallback string) string {
|
||||
name := fallback
|
||||
if header != nil {
|
||||
if headerName := larkcore.FileNameByHeader(header); strings.TrimSpace(headerName) != "" {
|
||||
name = headerName
|
||||
}
|
||||
}
|
||||
name = strings.ReplaceAll(strings.TrimSpace(name), "\\", "/")
|
||||
name = path.Base(name)
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return fallback
|
||||
}
|
||||
return name
|
||||
}
|
||||
91
shortcuts/markdown/markdown_create.go
Normal file
91
shortcuts/markdown/markdown_create.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var MarkdownCreate = common.Shortcut{
|
||||
Service: "markdown",
|
||||
Command: "+create",
|
||||
Description: "Create a Markdown file in Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:file:upload"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "folder-token", Desc: "target Drive folder token (default: root folder)"},
|
||||
{Name: "name", Desc: "file name with .md suffix; required with --content, optional with --file"},
|
||||
{Name: "content", Desc: "Markdown content", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "file", Desc: "local .md file path"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}, true)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return markdownUploadDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result markdownUploadResult
|
||||
if spec.FileSet {
|
||||
result, err = uploadMarkdownLocalFile(runtime, spec, fileSize)
|
||||
} else {
|
||||
result, err = uploadMarkdownContent(runtime, spec, []byte(spec.Content))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"file_token": result.FileToken,
|
||||
"file_name": finalMarkdownFileName(spec),
|
||||
"size_bytes": fileSize,
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, result.FileToken, "file"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownWrite(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
131
shortcuts/markdown/markdown_fetch.go
Normal file
131
shortcuts/markdown/markdown_fetch.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"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"
|
||||
)
|
||||
|
||||
var MarkdownFetch = common.Shortcut{
|
||||
Service: "markdown",
|
||||
Command: "+fetch",
|
||||
Description: "Fetch a Markdown file from Drive",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "Markdown file token", Required: true},
|
||||
{Name: "output", Desc: "local save path or directory; omit to return content directly"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing local output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
if outputPath == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := validate.SafeOutputPath(outputPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("download markdown file bytes; when --output is omitted the CLI returns content as UTF-8 text").
|
||||
GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Set("file_token", runtime.Str("file-token"))
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
dry.Set("output", outputPath)
|
||||
} else {
|
||||
dry.Set("output", "<stdout>")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
fileName := fileNameFromDownloadHeader(resp.Header, fileToken+".md")
|
||||
if outputPath == "" {
|
||||
payload, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": fileName,
|
||||
"content": string(payload),
|
||||
"size_bytes": len(payload),
|
||||
}
|
||||
runtime.OutFormatRaw(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownContent(w, out)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
if markdownFetchOutputIsDirectory(runtime, outputPath) {
|
||||
outputPath = filepath.Join(outputPath, fileName)
|
||||
}
|
||||
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !runtime.Bool("overwrite") {
|
||||
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
|
||||
savedPath, _ := runtime.ResolveSavePath(outputPath)
|
||||
if savedPath == "" {
|
||||
savedPath = outputPath
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": fileName,
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": result.Size(),
|
||||
}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownSavedFile(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func markdownFetchOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool {
|
||||
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") {
|
||||
return true
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(outputPath)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
113
shortcuts/markdown/markdown_overwrite.go
Normal file
113
shortcuts/markdown/markdown_overwrite.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var MarkdownOverwrite = common.Shortcut{
|
||||
Service: "markdown",
|
||||
Command: "+overwrite",
|
||||
Description: "Overwrite an existing Markdown file in Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "target Markdown file token", Required: true},
|
||||
{Name: "name", Desc: "optional file name with .md suffix; overrides the existing/local file name"},
|
||||
{Name: "content", Desc: "new Markdown content", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "file", Desc: "local .md file path"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileToken: fileToken,
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}, false)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := markdownUploadSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return markdownOverwriteDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
spec := markdownUploadSpec{
|
||||
FileToken: fileToken,
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(spec.FileName)
|
||||
if fileName == "" && spec.FileSet {
|
||||
fileName = filepath.Base(spec.FilePath)
|
||||
}
|
||||
if fileName == "" {
|
||||
remoteName, err := fetchMarkdownFileName(runtime, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileName = strings.TrimSpace(remoteName)
|
||||
}
|
||||
if fileName == "" {
|
||||
fileName = fileToken + ".md"
|
||||
}
|
||||
spec.FileName = fileName
|
||||
|
||||
var result markdownUploadResult
|
||||
if spec.FileSet {
|
||||
result, err = uploadMarkdownLocalFile(runtime, spec, fileSize)
|
||||
} else {
|
||||
result, err = uploadMarkdownContent(runtime, spec, []byte(spec.Content))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"file_token": result.FileToken,
|
||||
"file_name": fileName,
|
||||
"version": result.Version,
|
||||
"size_bytes": fileSize,
|
||||
}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownWrite(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
1344
shortcuts/markdown/markdown_test.go
Normal file
1344
shortcuts/markdown/markdown_test.go
Normal file
File diff suppressed because it is too large
Load Diff
15
shortcuts/markdown/shortcuts.go
Normal file
15
shortcuts/markdown/shortcuts.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all markdown shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MarkdownCreate,
|
||||
MarkdownFetch,
|
||||
MarkdownOverwrite,
|
||||
}
|
||||
}
|
||||
72
shortcuts/minutes/minutes_upload.go
Normal file
72
shortcuts/minutes/minutes_upload.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
minutesUploadSupportedFormatsTip = "Supported audio formats: wav, mp3, m4a, aac, ogg, wma, amr; supported video formats: avi, wmv, mov, mp4, m4v, mpeg, ogg, flv."
|
||||
minutesUploadLimitsTip = "The original uploaded media must be no larger than 6GB and no longer than 6 hours."
|
||||
)
|
||||
|
||||
// MinutesUpload uploads a media file token to generate a minute.
|
||||
var MinutesUpload = common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+upload",
|
||||
Description: "Upload a media file token to generate a minute",
|
||||
Risk: "write",
|
||||
Scopes: []string{"minutes:minutes.upload:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "file_token of a supported audio/video file already uploaded to Drive", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"This shortcut only accepts --file-token. Upload the local media file to Drive first with `lark-cli drive +upload`.",
|
||||
minutesUploadSupportedFormatsTip,
|
||||
minutesUploadLimitsTip,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
if fileToken == "" {
|
||||
return output.ErrValidation("--file-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/minutes/v1/minutes/upload").
|
||||
Body(map[string]interface{}{"file_token": runtime.Str("file-token")})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
|
||||
body := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/minutes/v1/minutes/upload", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
minuteURL := common.GetString(data, "minute_url")
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"minute_url": minuteURL,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
119
shortcuts/minutes/minutes_upload_test.go
Normal file
119
shortcuts/minutes/minutes_upload_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestMinutesUpload_Validate(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing file token",
|
||||
args: []string{"+upload", "--as", "user"},
|
||||
wantErr: "required flag(s) \"file-token\" not set",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesUpload.Mount(parent, f)
|
||||
parent.SetArgs(tt.args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
err := parent.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpload_HelpMetadata(t *testing.T) {
|
||||
if len(MinutesUpload.Flags) == 0 {
|
||||
t.Fatal("expected file-token flag metadata")
|
||||
}
|
||||
if got := MinutesUpload.Flags[0].Desc; !strings.Contains(got, "supported audio/video file") {
|
||||
t.Fatalf("file-token description = %q, want supported media guidance", got)
|
||||
}
|
||||
|
||||
joinedTips := strings.Join(MinutesUpload.Tips, "\n")
|
||||
for _, want := range []string{
|
||||
"drive +upload",
|
||||
"wav, mp3, m4a, aac, ogg, wma, amr",
|
||||
"avi, wmv, mov, mp4, m4v, mpeg, ogg, flv",
|
||||
"6GB",
|
||||
"6 hours",
|
||||
} {
|
||||
if !strings.Contains(joinedTips, want) {
|
||||
t.Fatalf("tips should contain %q, got:\n%s", want, joinedTips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpload_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
err := mountAndRun(t, MinutesUpload, []string{"+upload", "--file-token", "boxcn123456", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "POST") || !strings.Contains(out, "/open-apis/minutes/v1/minutes/upload") {
|
||||
t.Errorf("expected POST /open-apis/minutes/v1/minutes/upload, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "boxcn123456") {
|
||||
t.Errorf("expected file token in body, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpload_Execute(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodPost,
|
||||
URL: "/open-apis/minutes/v1/minutes/upload",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"minute_url": "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesUpload, []string{"+upload", "--file-token", "boxcn123456", "--format", "json", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var res map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
|
||||
dataMap, _ := res["data"].(map[string]interface{})
|
||||
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
|
||||
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,6 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MinutesSearch,
|
||||
MinutesDownload,
|
||||
MinutesUpload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/event"
|
||||
"github.com/larksuite/cli/shortcuts/im"
|
||||
"github.com/larksuite/cli/shortcuts/mail"
|
||||
"github.com/larksuite/cli/shortcuts/markdown"
|
||||
"github.com/larksuite/cli/shortcuts/minutes"
|
||||
"github.com/larksuite/cli/shortcuts/sheets"
|
||||
"github.com/larksuite/cli/shortcuts/slides"
|
||||
@@ -42,6 +43,7 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, base.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, event.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, task.Shortcuts()...)
|
||||
@@ -90,6 +92,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
}
|
||||
program.AddCommand(svc)
|
||||
}
|
||||
if service == "docs" {
|
||||
doc.ConfigureServiceHelp(svc)
|
||||
}
|
||||
|
||||
for _, shortcut := range shortcuts {
|
||||
shortcut.MountWithContext(ctx, svc, f)
|
||||
|
||||
30
shortcuts/register_markdown_test.go
Normal file
30
shortcuts/register_markdown_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, &cmdutil.Factory{})
|
||||
|
||||
for _, path := range [][]string{
|
||||
{"markdown", "+create"},
|
||||
{"markdown", "+fetch"},
|
||||
{"markdown", "+overwrite"},
|
||||
} {
|
||||
cmd, _, err := program.Find(path)
|
||||
if err != nil {
|
||||
t.Fatalf("find markdown shortcut %v: %v", path, err)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != path[1] {
|
||||
t.Fatalf("markdown shortcut not mounted: %#v", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,45 @@
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newRegisterTestFactory(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{})
|
||||
return f
|
||||
}
|
||||
|
||||
func newRegisterTestProgramWithTipsHelp() *cobra.Command {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
defaultHelp := program.HelpFunc()
|
||||
program.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
defaultHelp(cmd, args)
|
||||
tips := cmdutil.GetTips(cmd)
|
||||
if len(tips) == 0 {
|
||||
return
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Tips:")
|
||||
for _, tip := range tips {
|
||||
fmt.Fprintf(out, " • %s\n", tip)
|
||||
}
|
||||
})
|
||||
return program
|
||||
}
|
||||
|
||||
func TestAllShortcutsScopesNotNil(t *testing.T) {
|
||||
for _, s := range allShortcuts {
|
||||
hasScopes := s.Scopes != nil || s.UserScopes != nil || s.BotScopes != nil
|
||||
@@ -48,7 +77,7 @@ func TestAllShortcutsReturnsCopyAndIncludesBase(t *testing.T) {
|
||||
|
||||
func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, &cmdutil.Factory{})
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
baseCmd, _, err := program.Find([]string{"base"})
|
||||
if err != nil {
|
||||
@@ -69,7 +98,7 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
|
||||
|
||||
func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, &cmdutil.Factory{})
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
previewCmd, _, err := program.Find([]string{"docs", "+media-preview"})
|
||||
if err != nil {
|
||||
@@ -80,12 +109,182 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndLegacyTips(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
docsCmd, _, err := program.Find([]string{"docs"})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs command: %v", err)
|
||||
}
|
||||
if docsCmd == nil || docsCmd.Name() != "docs" {
|
||||
t.Fatalf("docs command not mounted: %#v", docsCmd)
|
||||
}
|
||||
if docsCmd.Flags().Lookup("api-version") == nil {
|
||||
t.Fatal("docs command should expose --api-version for versioned help")
|
||||
}
|
||||
|
||||
if !strings.Contains(docsCmd.Long, "Document and content operations.") {
|
||||
t.Fatalf("docs long help missing default description:\n%s", docsCmd.Long)
|
||||
}
|
||||
|
||||
var defaultHelp bytes.Buffer
|
||||
docsCmd.SetOut(&defaultHelp)
|
||||
if err := docsCmd.Help(); err != nil {
|
||||
t.Fatalf("docs help failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Tips:",
|
||||
"Agent version rule",
|
||||
"use --api-version v2 only when the installed lark-doc skill explicitly instructs",
|
||||
"otherwise use the default v1 flags",
|
||||
"if the skill does not mention v2",
|
||||
"legacy v1 examples and flags",
|
||||
} {
|
||||
if !strings.Contains(defaultHelp.String(), want) {
|
||||
t.Fatalf("docs default help missing %q:\n%s", want, defaultHelp.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsV2HelpUsesV2Description(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
docsCmd, _, err := program.Find([]string{"docs"})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs command: %v", err)
|
||||
}
|
||||
if err := docsCmd.Flags().Set("api-version", "v2"); err != nil {
|
||||
t.Fatalf("set docs api-version: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
docsCmd.SetOut(&out)
|
||||
if err := docsCmd.Help(); err != nil {
|
||||
t.Fatalf("docs v2 help failed: %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"Document and content operations (v2).",
|
||||
"Tips:",
|
||||
"Agent version rule",
|
||||
"otherwise use the default v1 flags",
|
||||
"if the skill does not mention v2",
|
||||
"legacy v1 examples and flags",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("docs v2 help missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut string
|
||||
apiVersion string
|
||||
shortcutHelp string
|
||||
versionedFlag string
|
||||
}{
|
||||
{
|
||||
name: "create v1",
|
||||
shortcut: "+create",
|
||||
apiVersion: "v1",
|
||||
shortcutHelp: "Create a Lark document",
|
||||
versionedFlag: "--markdown",
|
||||
},
|
||||
{
|
||||
name: "create v2",
|
||||
shortcut: "+create",
|
||||
apiVersion: "v2",
|
||||
shortcutHelp: "Create a Lark document",
|
||||
versionedFlag: "--content",
|
||||
},
|
||||
{
|
||||
name: "fetch v1",
|
||||
shortcut: "+fetch",
|
||||
apiVersion: "v1",
|
||||
shortcutHelp: "Fetch Lark document content",
|
||||
versionedFlag: "--offset",
|
||||
},
|
||||
{
|
||||
name: "fetch v2",
|
||||
shortcut: "+fetch",
|
||||
apiVersion: "v2",
|
||||
shortcutHelp: "Fetch Lark document content",
|
||||
versionedFlag: "partial read scope",
|
||||
},
|
||||
{
|
||||
name: "update v1",
|
||||
shortcut: "+update",
|
||||
apiVersion: "v1",
|
||||
shortcutHelp: "Update a Lark document",
|
||||
versionedFlag: "--mode",
|
||||
},
|
||||
{
|
||||
name: "update v2",
|
||||
shortcut: "+update",
|
||||
apiVersion: "v2",
|
||||
shortcutHelp: "Update a Lark document",
|
||||
versionedFlag: "--command",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
program := newRegisterTestProgramWithTipsHelp()
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
cmd, _, err := program.Find([]string{"docs", tt.shortcut})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs %s command: %v", tt.shortcut, err)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != tt.shortcut {
|
||||
t.Fatalf("docs %s shortcut not mounted: %#v", tt.shortcut, cmd)
|
||||
}
|
||||
if err := cmd.Flags().Set("api-version", tt.apiVersion); err != nil {
|
||||
t.Fatalf("set docs %s api-version: %v", tt.shortcut, err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
if err := cmd.Help(); err != nil {
|
||||
t.Fatalf("docs %s help failed: %v", tt.shortcut, err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
tt.shortcutHelp,
|
||||
tt.versionedFlag,
|
||||
"Tips:",
|
||||
"Agent version rule",
|
||||
"use --api-version v2 only when the installed lark-doc skill explicitly instructs",
|
||||
"otherwise use the default v1 flags",
|
||||
"if the skill does not mention v2",
|
||||
"legacy v1 examples and flags",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"[NOTE]",
|
||||
"Use --api-version v2 for the latest API",
|
||||
} {
|
||||
if strings.Contains(out.String(), unwanted) {
|
||||
t.Fatalf("docs %s %s help should not include %q:\n%s", tt.shortcut, tt.apiVersion, unwanted, out.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
existingBase := &cobra.Command{Use: "base", Short: "existing base service"}
|
||||
program.AddCommand(existingBase)
|
||||
|
||||
RegisterShortcuts(program, &cmdutil.Factory{})
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
baseCount := 0
|
||||
for _, command := range program.Commands() {
|
||||
|
||||
@@ -104,7 +104,7 @@ metadata:
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或获取单条记录详情 | [`lark-base-record-search.md`](references/lark-base-record-search.md)、[`lark-base-record-list.md`](references/lark-base-record-list.md)、[`lark-base-record-get.md`](references/lark-base-record-get.md) | 默认优先 `+record-list`;仅当用户提供明确搜索关键词时使用 `+record-search`;取数不用来做聚合分析;`--limit` 最大 `200`;仅在用户明确需要时继续翻页;`+record-list` 只能串行执行 |
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或获取单条记录详情 | [`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 记录读取统一先读 read SOP guide:已知 `record_id` 用 `+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query` |
|
||||
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
|
||||
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token` 从 `+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403) |
|
||||
@@ -119,7 +119,7 @@ metadata:
|
||||
|------|------------------|----------------|----------|
|
||||
| `+view-list / +view-get` | 列出视图,或获取视图详情 | [`lark-base-view-list.md`](references/lark-base-view-list.md)、[`lark-base-view-get.md`](references/lark-base-view-get.md) | `+view-list` 只能串行执行;`+view-get` 适合查看已有视图配置 |
|
||||
| `+view-create / +view-delete / +view-rename` | 创建、删除或重命名视图 | [`lark-base-view-create.md`](references/lark-base-view-create.md)、[`lark-base-view-delete.md`](references/lark-base-view-delete.md)、[`lark-base-view-rename.md`](references/lark-base-view-rename.md) | 创建前先确认表和视图类型;删除前先确认目标;用户已明确新名字时可直接重命名 |
|
||||
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-record-list.md`](references/lark-base-record-list.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
|
||||
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
|
||||
| `+view-get-sort / +view-set-sort` | 读取或配置排序 | [`lark-base-view-get-sort.md`](references/lark-base-view-get-sort.md)、[`lark-base-view-set-sort.md`](references/lark-base-view-set-sort.md) | 字段名必须来自真实结构 |
|
||||
| `+view-get-group / +view-set-group` | 读取或配置分组 | [`lark-base-view-get-group.md`](references/lark-base-view-get-group.md)、[`lark-base-view-set-group.md`](references/lark-base-view-set-group.md) | 字段名必须来自真实结构 |
|
||||
| `+view-get-visible-fields / +view-set-visible-fields` | 读取或配置视图可见字段 | [`lark-base-view-get-visible-fields.md`](references/lark-base-view-get-visible-fields.md)、[`lark-base-view-set-visible-fields.md`](references/lark-base-view-set-visible-fields.md) | 用于控制视图中的字段顺序与可见性;字段名必须来自真实结构 |
|
||||
@@ -297,7 +297,7 @@ lark-cli auth login --domain base
|
||||
7. 聚合分析与取数分流;统计走 `+data-query`,关键词检索走 `+record-search`,明细走 `+record-list / +record-get`。
|
||||
8. 筛选查询按视图能力执行;先用 `+view-set-filter` 配置筛选,再结合 `+record-list` 读取。
|
||||
9. Base 场景不要改走裸 API,不要切去 `lark-cli api /open-apis/bitable/v1/...`。
|
||||
10. 统一使用 `--base-token`,不使用旧 `--app-token`。
|
||||
10. 统一使用 `--base-token`。
|
||||
11. workflow 场景先读 schema,不要凭自然语言猜 `type`。
|
||||
12. dashboard 场景先读 guide;提到图表、看板、block 就先进入 dashboard 模块。
|
||||
13. formula / lookup 场景先读 guide;没读 guide 前不要直接创建或更新。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 飞书多维表格使用场景完整示例(base)
|
||||
|
||||
本文档提供基于 `lark-cli base ...` 的完整示例,覆盖 unified Shortcut 与当前 `base/v3` 原生 API 的常见组合方式。
|
||||
本文档提供基于 `lark-cli base +...` shortcut 的完整示例。
|
||||
|
||||
> **返回**: [SKILL.md](../SKILL.md) | **参考**: [shortcut 字段 JSON 规范](lark-base-shortcut-field-properties.md) · [CellValue 规范](lark-base-cell-value.md)
|
||||
|
||||
@@ -24,26 +24,28 @@ lark-cli base +table-create \
|
||||
|
||||
---
|
||||
|
||||
## 场景 2:使用原生 API 创建数据表并查看字段
|
||||
## 场景 2:创建数据表并查看字段
|
||||
|
||||
适合需要精确观察 `base/v3` 请求参数和响应结构的场景。原生 API 直接使用 `base` service。
|
||||
适合需要先建表、再确认字段结构的场景。
|
||||
|
||||
### 步骤 1:在已有 Base 中创建数据表
|
||||
|
||||
```bash
|
||||
lark-cli base tables create \
|
||||
--params '{"base_token":"bascnXXXXXXXX"}' \
|
||||
--data '{"name":"客户管理表"}'
|
||||
lark-cli base +table-create \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--name "客户管理表"
|
||||
```
|
||||
|
||||
### 步骤 2:列出字段
|
||||
|
||||
```bash
|
||||
lark-cli base table.fields list \
|
||||
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","limit":100}'
|
||||
lark-cli base +field-list \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--table-id tblXXXXXXXX \
|
||||
--limit 100
|
||||
```
|
||||
|
||||
> 提示:当前 `base/v3` 不再使用旧的 `app_token` / `app.table.*` 路径,统一改为 `base_token` + `tables` / `table.fields` / `table.records`。
|
||||
> 提示:Base token 统一通过 `--base-token` 传入;表 ID 统一通过 `--table-id` 传入。
|
||||
|
||||
---
|
||||
|
||||
@@ -52,9 +54,10 @@ lark-cli base table.fields list \
|
||||
### 新增记录
|
||||
|
||||
```bash
|
||||
lark-cli base table.records create \
|
||||
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX"}' \
|
||||
--data '{
|
||||
lark-cli base +record-upsert \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--table-id tblXXXXXXXX \
|
||||
--json '{
|
||||
"客户名称":"字节跳动",
|
||||
"负责人":[{"id":"ou_xxx"}],
|
||||
"状态":"进行中"
|
||||
@@ -64,16 +67,20 @@ lark-cli base table.records create \
|
||||
### 列出记录
|
||||
|
||||
```bash
|
||||
lark-cli base table.records list \
|
||||
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","limit":100}'
|
||||
lark-cli base +record-list \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--table-id tblXXXXXXXX \
|
||||
--limit 100
|
||||
```
|
||||
|
||||
### 更新记录
|
||||
|
||||
```bash
|
||||
lark-cli base table.records patch \
|
||||
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","record_id":"recXXXXXXXX"}' \
|
||||
--data '{
|
||||
lark-cli base +record-upsert \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--table-id tblXXXXXXXX \
|
||||
--record-id recXXXXXXXX \
|
||||
--json '{
|
||||
"状态":"已完成"
|
||||
}'
|
||||
```
|
||||
@@ -81,22 +88,27 @@ lark-cli base table.records patch \
|
||||
### 删除记录
|
||||
|
||||
```bash
|
||||
lark-cli base table.records delete \
|
||||
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","record_id":"recXXXXXXXX"}'
|
||||
lark-cli base +record-delete \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--table-id tblXXXXXXXX \
|
||||
--record-id recXXXXXXXX \
|
||||
--yes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 场景 4:配置视图筛选后按视图读取记录
|
||||
|
||||
当前 `base/v3` 原生 spec 没有独立 `search` 方法。需要筛选查询时,推荐先写视图筛选,再通过 `view_id` 读取记录。
|
||||
需要筛选查询时,推荐先写视图筛选,再通过 `view_id` 读取记录。
|
||||
|
||||
### 更新视图筛选条件
|
||||
|
||||
```bash
|
||||
lark-cli base view.filter update \
|
||||
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","view_id":"vewXXXXXXXX"}' \
|
||||
--data '{
|
||||
lark-cli base +view-set-filter \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--table-id tblXXXXXXXX \
|
||||
--view-id vewXXXXXXXX \
|
||||
--json '{
|
||||
"logic":"and",
|
||||
"conditions":[
|
||||
{
|
||||
@@ -111,8 +123,11 @@ lark-cli base view.filter update \
|
||||
### 按视图读取记录
|
||||
|
||||
```bash
|
||||
lark-cli base table.records list \
|
||||
--params '{"base_token":"bascnXXXXXXXX","table_id":"tblXXXXXXXX","view_id":"vewXXXXXXXX","limit":100}'
|
||||
lark-cli base +record-list \
|
||||
--base-token bascnXXXXXXXX \
|
||||
--table-id tblXXXXXXXX \
|
||||
--view-id vewXXXXXXXX \
|
||||
--limit 100
|
||||
```
|
||||
|
||||
---
|
||||
@@ -123,8 +138,3 @@ lark-cli base table.records list \
|
||||
- 需要按业务字段名做 upsert 时,优先 `lark-cli base +record-upsert`
|
||||
- 需要配置筛选视图时,优先 `lark-cli base +view-set-filter`
|
||||
- 需要记录历史时,优先 `lark-cli base +record-history-list`
|
||||
|
||||
原生 API 更适合两类场景:
|
||||
|
||||
- 需要逐步核对 `schema base.<resource>.<method>` 的请求参数
|
||||
- 需要精确控制单次表 / 字段 / 记录 / 视图操作
|
||||
|
||||
@@ -43,7 +43,7 @@ POST /open-apis/base/v3/bases/:base_token/copy
|
||||
- CLI 会额外标记 `copied: true`。
|
||||
- 回复结果时,必须主动返回新 Base 的可访问链接:
|
||||
- 优先使用返回结果中的 `base.url`
|
||||
- 同时返回新 Base 的 token;字段名以实际返回为准,常见为 `base_token` 或 `app_token`
|
||||
- 同时返回新 Base 的 token
|
||||
- 如果本次返回没有 `url`,至少返回新 Base 的名称和 token
|
||||
|
||||
> [!IMPORTANT]
|
||||
|
||||
@@ -38,7 +38,7 @@ POST /open-apis/base/v3/bases
|
||||
- CLI 会额外标记 `created: true`。
|
||||
- 回复结果时,必须主动返回新 Base 的可访问链接:
|
||||
- 优先使用返回结果中的 `base.url`
|
||||
- 同时返回新 Base 的 token;字段名以实际返回为准,常见为 `base_token` 或 `app_token`
|
||||
- 同时返回新 Base 的 token
|
||||
- 如果本次返回没有 `url`,至少返回新 Base 的名称和 token
|
||||
|
||||
> [!IMPORTANT]
|
||||
|
||||
@@ -57,7 +57,7 @@ lark-cli base +data-query \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------------------------|------|------|
|
||||
| `--base-token <token>` | 是 | 多维表格 App Token(base_token) |
|
||||
| `--base-token <token>` | 是 | Base Token(base_token) |
|
||||
| `--dsl <json>` | 是 | LiteQuery Protocol JSON DSL 查询语句 |
|
||||
|
||||
## 如何从链接中提取参数
|
||||
@@ -65,7 +65,7 @@ lark-cli base +data-query \
|
||||
用户通常会提供如下 URL:
|
||||
|
||||
```
|
||||
https://example.feishu.cn/base/<app_token>?table=<table_id>
|
||||
https://example.feishu.cn/base/<base_token>?table=<table_id>
|
||||
```
|
||||
|
||||
- `--base-token`:取 `/base/` 后面的字符串
|
||||
@@ -83,7 +83,7 @@ POST /open-apis/base/v3/bases/:base_token/data/query
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `base_token` | 是 | 多维表格 App Token |
|
||||
| `base_token` | 是 | Base Token |
|
||||
|
||||
**Request Body — DSL 结构:**
|
||||
|
||||
@@ -387,7 +387,7 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
|
||||
## 工作流
|
||||
|
||||
1. 确认 base-token 和 table-id
|
||||
2. **先查表结构**:执行 `lark-cli base app.table.fields list --params '{"app_token":"<token>","table_id":"<id>"}'`
|
||||
2. **先查表结构**:执行 `lark-cli base +field-list --base-token <base_token> --table-id <table_id>`
|
||||
3. 从返回的字段列表中获取 field_name(DSL 中使用的字段名称)
|
||||
4. 根据字段信息构造 DSL JSON
|
||||
5. 执行 +data-query
|
||||
@@ -399,7 +399,7 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
|
||||
|
||||
## 坑点
|
||||
|
||||
- ⚠️ **必须先查表结构**:DSL 的 `field_name` 必须与表中字段名称精确匹配(区分大小写),不能凭猜测构造。先用 `base app.table.fields list` 获取真实字段名
|
||||
- ⚠️ **必须先查表结构**:DSL 的 `field_name` 必须与表中字段名称精确匹配(区分大小写),不能凭猜测构造。先用 `lark-cli base +field-list --base-token <base_token> --table-id <table_id>` 获取真实字段名
|
||||
- ⚠️ **权限要求按文档类型分流**:普通多维表格只需文档**阅读权限**;高级权限多维表格必须是文档管理员(**FA / Full Access**),否则返回权限错误
|
||||
- ⚠️ **alias 不支持中文**:dimensions 和 measures 的 alias 必须使用英文(如 `dim_city`、`total_amount`),中文 alias 会导致错误
|
||||
- ⚠️ **API 路径是 `base/v3`**:本接口路径为 `/open-apis/base/v3/bases/:base_token/data/query`,不是 `bitable/v1`。两者完全不同,用错版本号会返回 `[2200] Internal Error`
|
||||
|
||||
@@ -44,7 +44,7 @@ lark-cli base +form-create \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | 多维表格 App token(base_token) |
|
||||
| `--base-token <token>` | 是 | Base Token(base_token) |
|
||||
| `--table-id <id>` | 是 | 数据表 ID |
|
||||
| `--name <name>` | 是 | 表单名称 |
|
||||
| `--description <string>` | 否 | 表单描述(纯文本或 Markdown 链接,如 `[文本](https://example.com)`) |
|
||||
@@ -76,7 +76,7 @@ lark-cli base +form-create \
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** — 执行前必须向用户确认。
|
||||
|
||||
1. 确认目标 `app_token` 和 `table_id`
|
||||
1. 确认目标 `base_token` 和 `table_id`
|
||||
2. 确认表单名称和描述
|
||||
3. 执行命令
|
||||
4. 报告返回的 `form_id`,后续可用于添加问题(`+form-questions-create`)
|
||||
|
||||
@@ -25,7 +25,7 @@ lark-cli base +form-delete \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | 多维表格 App token(base_token) |
|
||||
| `--base-token <token>` | 是 | Base Token(base_token) |
|
||||
| `--table-id <id>` | 是 | 数据表 ID |
|
||||
| `--form-id <id>` | 是 | 要删除的表单 ID |
|
||||
| `--as` | 否 | 身份:user(默认)\| bot |
|
||||
|
||||
@@ -32,7 +32,7 @@ lark-cli base +form-get \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | 多维表格 App token(base_token) |
|
||||
| `--base-token <token>` | 是 | Base Token(base_token) |
|
||||
| `--table-id <id>` | 是 | 数据表 ID |
|
||||
| `--form-id <id>` | 是 | 表单 ID |
|
||||
| `--format` | 否 | 输出格式:json(默认)\| pretty \| table \| ndjson \| csv |
|
||||
|
||||
@@ -29,7 +29,7 @@ lark-cli base +form-list \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | 多维表格 App token(base_token) |
|
||||
| `--base-token <token>` | 是 | Base Token(base_token) |
|
||||
| `--table-id <id>` | 是 | 数据表 ID |
|
||||
| `--page-size <n>` | 否 | 每次请求的分页大小,默认 100,最大 100 |
|
||||
| `--format` | 否 | 输出格式:json(默认)\| pretty \| table \| ndjson \| csv |
|
||||
@@ -64,7 +64,7 @@ JSON 输出示例(`--format json`,默认):
|
||||
## 提示
|
||||
|
||||
- `base_token` 在多维表格 URL 中可找到(形如 `bascnXXXX`)
|
||||
- `table_id` 可通过 `lark-cli base app.tables list --app-token <token> --params '{"app_token":"<token>"}'` 获取
|
||||
- `table_id` 可通过 `lark-cli base +table-list --base-token <base_token>` 获取
|
||||
- 如无表单,输出 `forms: []`
|
||||
|
||||
## 参考
|
||||
|
||||
@@ -56,7 +56,7 @@ lark-cli base +form-questions-create \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | 多维表格 App token(base_token) |
|
||||
| `--base-token <token>` | 是 | Base Token(base_token) |
|
||||
| `--table-id <id>` | 是 | 数据表 ID |
|
||||
| `--form-id <id>` | 是 | 表单 ID |
|
||||
| `--questions <json>` | 是 | 问题 JSON 数组,最多 10 个(见下方格式) |
|
||||
|
||||
@@ -34,7 +34,7 @@ lark-cli base +form-questions-delete \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | 多维表格 App token(base_token) |
|
||||
| `--base-token <token>` | 是 | Base Token(base_token) |
|
||||
| `--table-id <id>` | 是 | 数据表 ID |
|
||||
| `--form-id <id>` | 是 | 表单 ID |
|
||||
| `--question-ids <json>` | 是 | 要删除的问题 ID JSON 数组,最多 10 个,如 `'["q_001","q_002"]'` |
|
||||
|
||||
@@ -32,7 +32,7 @@ lark-cli base +form-questions-list \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | 多维表格 App token(base_token) |
|
||||
| `--base-token <token>` | 是 | Base Token(base_token) |
|
||||
| `--table-id <id>` | 是 | 数据表 ID |
|
||||
| `--form-id <id>` | 是 | 表单 ID |
|
||||
| `--format` | 否 | 输出格式:json(默认)\| pretty \| table \| ndjson \| csv |
|
||||
|
||||
@@ -42,7 +42,7 @@ lark-cli base +form-questions-update \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | 多维表格 App token(base_token) |
|
||||
| `--base-token <token>` | 是 | Base Token(base_token) |
|
||||
| `--table-id <id>` | 是 | 数据表 ID |
|
||||
| `--form-id <id>` | 是 | 表单 ID |
|
||||
| `--questions <json>` | 是 | 问题更新 JSON 数组,最多 10 个(见下方格式) |
|
||||
|
||||
@@ -41,7 +41,7 @@ lark-cli base +form-update \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | 多维表格 App token(base_token) |
|
||||
| `--base-token <token>` | 是 | Base Token(base_token) |
|
||||
| `--table-id <id>` | 是 | 数据表 ID |
|
||||
| `--form-id <id>` | 是 | 表单 ID |
|
||||
| `--name <name>` | 否 | 新的表单名称 |
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# base +record-get
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
获取单条记录,可选裁剪输出字段。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
lark-cli base +record-get \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--record-id <record_id>
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | Base Token |
|
||||
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
|
||||
| `--record-id <id>` | 是 | 记录 ID |
|
||||
|
||||
## API 入参详情
|
||||
|
||||
**HTTP 方法和路径:**
|
||||
|
||||
```
|
||||
GET /open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id
|
||||
```
|
||||
|
||||
## 返回重点
|
||||
|
||||
- 成功时直接返回接口 `data` 字段内容。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base-record.md](lark-base-record.md) — record 索引页
|
||||
@@ -1,83 +0,0 @@
|
||||
# base +record-list
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
分页列出一张表里的记录;可按视图过滤,也可按字段裁剪返回列。
|
||||
|
||||
> 默认优先使用 `+record-list`;仅当用户提供明确搜索关键词时,才使用 [lark-base-record-search.md](lark-base-record-search.md)。
|
||||
|
||||
## 返回关键字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `has_more` | boolean | 是否还有下一页数据;`true` 表示可继续翻页,`false` 表示已到末页 |
|
||||
| `query_context.record_scope` | string | 记录范围:`all_records`(全表)或 `view_filtered_records`(按视图过滤) |
|
||||
| `query_context.field_scope` | string | 字段范围:`selected_fields`(显式传 `--field-id`)/ `view_visible_fields`(未传 `--field-id` 且按视图可见字段)/ `all_fields`(未传 `--field-id` 且无视图限制) |
|
||||
|
||||
## 字段返回优先级
|
||||
|
||||
- `query_context.field_scope` 的优先级为:`selected_fields`(explicit `--field-id`) > `view_visible_fields`(view visible fields) > `all_fields`(table all fields)。
|
||||
|
||||
## 按需翻页规则
|
||||
|
||||
1. 先执行一次 `+record-list` 获取首批结果。
|
||||
2. 检查返回的 `has_more`。
|
||||
3. 仅当同时满足以下条件时才继续翻页:
|
||||
- `has_more = true`
|
||||
- 用户问题需要更多数据(例如“全部导出”“统计全量明细”“继续加载下一页”)
|
||||
4. 若用户只要部分结果(例如“先看前 20 条”“先给示例数据”),即使 `has_more = true` 也不继续翻页。
|
||||
5. 继续翻页时,`offset` 按已读取数量递增,直到满足用户需求或 `has_more = false`。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
lark-cli base +record-list \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--offset 0 \
|
||||
--limit 100
|
||||
|
||||
lark-cli base +record-list \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--view-id <view_id> \
|
||||
--field-id fldStatus \
|
||||
--field-id 项目名称 \
|
||||
--offset 0 \
|
||||
--limit 50
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | Base Token |
|
||||
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
|
||||
| `--view-id <id>` | 否 | 视图 ID;传入后只读该视图结果 |
|
||||
| `--field-id <id_or_name>` | 否 | 字段 ID 或字段名;可重复传入多个 `--field-id` 裁剪返回列 |
|
||||
| `--offset <n>` | 否 | 分页偏移,默认 `0` |
|
||||
| `--limit <n>` | 否 | 分页大小,默认 `100`,范围 `1-200`(最大 `200`,超过会报错) |
|
||||
|
||||
## API 入参详情
|
||||
|
||||
**HTTP 方法和路径:**
|
||||
|
||||
```
|
||||
GET /open-apis/base/v3/bases/:base_token/tables/:table_id/records
|
||||
```
|
||||
|
||||
- 查询参数会附带 `view_id / field_id(repeatable) / offset / limit`。
|
||||
|
||||
|
||||
## 坑点
|
||||
|
||||
- ⚠️ `+record-list` 禁止并发调用;批量拉多个视图或多张表时必须串行。
|
||||
- ⚠️ `--limit` 最大 `200`,不要传超过 `200` 的值。
|
||||
- ⚠️ 分页时优先根据返回的 `has_more` 判断是否继续请求,不要盲目预拉全量数据。
|
||||
- ⚠️ `--field-id` 接受字段 ID 或字段名。
|
||||
- ⚠️ 复杂筛选优先落到视图里,再用 `--view-id` 读取。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base-record.md](lark-base-record.md) — record 索引页
|
||||
- [lark-base-view-set-filter.md](lark-base-view-set-filter.md) — 配筛选
|
||||
86
skills/lark-base/references/lark-base-record-read-sop.md
Normal file
86
skills/lark-base/references/lark-base-record-read-sop.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# base record read SOP
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、权限处理和全局参数。
|
||||
|
||||
记录读取由 6 个功能组合完成:选路、字段投影、视图预处理、分页与范围、返回结构解释、link 关联读取。
|
||||
|
||||
## 1. 读取选路
|
||||
|
||||
| 场景 | 使用方式 | 规则 |
|
||||
|------|------|------|
|
||||
| 已知 `record_id` | `+record-get` | 只读单条记录,不要用 search/list 反查。 |
|
||||
| 明确关键词检索 | `+record-search` | 只用于文本关键词检索;金额、状态、日期等结构化条件不要用 search。 |
|
||||
| 普通明细读取 / 导出 / 查看前 N 条 | `+record-list` | 优先加 `--view-id` 时只读该视图可见记录与可见字段;或者加 `--field-id` 手动裁剪字段;不传 `--view-id` 时会读取全表。 |
|
||||
| 明确筛选 / 排序 / Top N / Bottom N 且需要原始记录或 `record_id` | 创建带 filter + sort 的临时视图 + `+record-list --view-id` | 让视图完成 filter/sort projection,LLM 不擅长手工筛选排序,建议用视图完成。 |
|
||||
| 统计 / 聚合结果且不需要 `record_id` | 转到 [`lark-base-data-query.md`](lark-base-data-query.md) | `data-query` 是特殊分析 DSL,不是记录读取工具。 |
|
||||
|
||||
## 2. 字段投影
|
||||
|
||||
- `FieldListFirst`: 不清楚字段结构时先 `+field-list`,确认筛选字段、排序字段、展示字段、关联字段、业务唯一键字段。
|
||||
- `UseRealField`: 字段名和字段 ID 必须来自 `+field-list` 返回,不要凭自然语言猜字段名。
|
||||
- `MinimalProjection`: 每次读取只返回本次任务需要的字段;`+record-list` 用重复 `--field-id`,视图读取用 `+view-set-visible-fields`。
|
||||
- `FieldScopePriority`: 返回字段优先级为显式投影字段(`+record-list --field-id` / `record-search select_fields`) > 视图可见字段 > 全表字段;需要稳定列范围时必须显式投影。
|
||||
- `LongFieldAvoidance`: 默认不要读取 `trace`、`raw`、长文本、附件等高噪声字段,除非任务明确需要。
|
||||
- `BusinessKey`: 后续要定位、更新或解释记录时,投影中必须包含可识别业务字段,例如订单号、日报ID、姓名、编号。
|
||||
|
||||
## 3. 视图预处理
|
||||
|
||||
适用于结构化筛选、排序、最高/最低、倒数、Top/Bottom N、按条件找记录等场景。
|
||||
|
||||
1. `+field-list` 获取字段 ID、字段名和字段类型。
|
||||
2. `+view-create` 创建临时 `grid` 视图,名称带任务语义,例如 `tmp_query_销售额升序`。
|
||||
3. `+view-set-filter` 设置筛选条件;空值是否参与必须按用户语义判断。
|
||||
4. `+view-set-sort` 设置排序条件;最高/最新用降序,最低/最早/倒数用升序。
|
||||
5. `+view-set-visible-fields` 设置投影字段,只保留业务键、排序字段、筛选解释字段、需要展示或二跳的字段。
|
||||
6. `+record-list --view-id <view_id> --limit <N>` 读取结果;不要再从未排序全表输出中手动挑选。
|
||||
|
||||
## 4. 分页与范围
|
||||
|
||||
- `ViewScope`: URL 带 `view_id` 时先判断用户是否要求“该视图下”;全表问题不要误用 URL 视图范围,应该根据需求创建合适的临时视图完成查询任务。
|
||||
- `ViewIdScope`: `+record-list --view-id` 是作用域参数;仅用于用户指定的视图,或本次任务主动创建的临时筛选 / 排序 / 投影视图。
|
||||
- `NeedAllPages`: 用户要求全部、导出、统计、最高/最低且未用视图/limit 限定时,必须检查 `has_more` 并串行翻页。
|
||||
- `LimitWhenScoped`: 用户只要示例、前 N 条、Top/Bottom N,使用 `--limit` 控制结果规模。
|
||||
- `NoConcurrentList`: `+record-list` 禁止并发调用;分页和多表读取必须串行。
|
||||
- `DataQueryScope`: `data-query` 的筛选 DSL 与视图筛选不是同一套语法;不要混用。
|
||||
|
||||
## 5. 返回结构解释
|
||||
|
||||
- `ColumnMapping`: `fields` / `field_id_list` 定义 `data` 每列含义;解释记录前先建立列到字段名的映射。
|
||||
- `RowMapping`: `record_id_list[i]` 与 `data[i]` 是同一行;需要后续定位、更新或关联时,按下标整理成 `record_id + 字段名:值` 的小表。
|
||||
- `BusinessMatch`: 后续引用目标记录时按业务字段匹配,不靠肉眼数行号。
|
||||
- `FieldType`: 按字段类型解释值;数字、货币、日期、人员、formula、lookup、attachment、link 不要当普通文本处理。
|
||||
- `EmptyValue`: 空值参与筛选或排序前必须明确语义;不要默认把空值当 `0`、空字符串或有效状态。
|
||||
- `AnswerCheck`: 最终回答前复核答案记录来自读取结果、筛选排序已应用、字段含义和 record_id 映射无误。
|
||||
|
||||
## 6. link 关联字段读取
|
||||
|
||||
link 字段是关联单元格;读取结果通常是关联表的 `record_id` 数组,不是用户可读名称。
|
||||
|
||||
| 步骤 | 做法 |
|
||||
|------|------|
|
||||
| 识别 link 字段 | 用 `+field-list` 查看字段类型为 `link`,并读取 `link_table` 确认关联目标表。 |
|
||||
| 读取当前表 | 在当前表 `+record-list` / `+record-get` 中保留 link 字段和业务键字段。 |
|
||||
| 解析单元格值 | link 单元格通常形如 `[{"id":"rec..."}]`;提取其中每个 `id` 作为关联表 `record_id`。 |
|
||||
| 读取关联表 | 到 `link_table` 使用 `+record-get --record-id <rec...>` 或裁剪后的 `+record-list` 读取显示字段。 |
|
||||
| 建立映射 | 形成 `关联record_id -> 显示字段值` 映射,再回填当前表结果。 |
|
||||
| 多值处理 | 多个关联值保持原顺序;可去重批量读取,但回答时按原单元格顺序输出。 |
|
||||
|
||||
禁止事项:
|
||||
|
||||
- 不要把 link 单元格里的 `record_id` 当作最终答案。
|
||||
- 不要用 `+record-search` 搜索 link `record_id` 来查关联记录。
|
||||
- 不要凭关联 `record_id` 猜名称、负责人、门店等显示值。
|
||||
- 不要只看当前表字段名推断关联表结构;跨表读取前必须拿关联表字段结构。
|
||||
|
||||
## 7. 命令 help
|
||||
|
||||
- `HelpFirst`: 参数、示例、JSON shape 和取值约束以 `lark-cli base +record-get --help`、`+record-search --help`、`+record-list --help` 为准。
|
||||
- `RecordSearchJson`: 构造 `+record-search --json` 前先看 `+record-search --help`,确认 `keyword/search_fields/select_fields/view_id/offset/limit` 的结构和约束。
|
||||
- `RecordListProjection`: 构造 `+record-list` 前先看 `+record-list --help`,确认 `--field-id`、`--view-id`、`--offset`、`--limit` 的语义。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base-view-set-filter.md](lark-base-view-set-filter.md)
|
||||
- [lark-base-view-set-sort.md](lark-base-view-set-sort.md)
|
||||
- [lark-base-view-set-visible-fields.md](lark-base-view-set-visible-fields.md)
|
||||
- [lark-base-data-query.md](lark-base-data-query.md)
|
||||
@@ -1,72 +0,0 @@
|
||||
# base +record-search
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
按关键词检索记录;CLI 侧通过 `--json` 透传请求体。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 需要关键词检索记录。
|
||||
- 用户已提供明确搜索关键词(`keyword`)。
|
||||
- 需要附带 `view_id / select_fields` 控制检索范围与返回字段。
|
||||
- 不用于聚合统计。涉及 SUM/AVG/COUNT/MAX/MIN 时改用 `+data-query`。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
lark-cli base +record-search \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--json '{"keyword":"Created","search_fields":["Title","<field_id>"],"offset":0,"limit":100}'
|
||||
|
||||
lark-cli base +record-search \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--json '{"view_id":"<view_id>","keyword":"Alice","search_fields":["Title","Owner"],"select_fields":["Title","Owner","Status"],"offset":0,"limit":50}'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | Base Token |
|
||||
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
|
||||
| `--json <object>` | 是 | 搜索请求体 JSON(结构要求见下方“JSON 要求”) |
|
||||
|
||||
## 返回关键字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `query_context.record_scope` | string | 记录范围:`all_records`(全表)/ `view_filtered_records`(按 `view_id` 限定到视图记录) |
|
||||
| `query_context.field_scope` | string | 字段范围:`selected_fields`(显式传 `select_fields`)/ `view_visible_fields`(未传 `select_fields` 且按视图可见字段)/ `all_fields`(未传 `select_fields` 且无视图限制) |
|
||||
| `query_context.search_scope` | string | 实际参与搜索的字段集合,格式类似 `fieldTitle(Title), fieldOwner(Owner)` |
|
||||
|
||||
## API 入参详情
|
||||
|
||||
**HTTP 方法和路径:**
|
||||
|
||||
```http
|
||||
POST /open-apis/base/v3/bases/:base_token/tables/:table_id/records/search
|
||||
```
|
||||
|
||||
### JSON 格式要求
|
||||
|
||||
| 字段 | 必填 | 类型 | 约束 |
|
||||
|------|------|------|------|
|
||||
| `view_id` | 否 | string | 传入后仅在该视图范围内搜索,并默认按该视图可见字段返回结果 |
|
||||
| `keyword` | 是 | string | 非空,最小长度 `1` |
|
||||
| `search_fields` | 是 | string[] | 数组长度 `1-20`;每项是字段 `field_id` 或字段名,代表在这些字段中做关键词搜索 |
|
||||
| `select_fields` | 否 | string[] | 数组长度 `<=50`;每项是字段 `field_id` 或字段名 |
|
||||
| `offset` | 否 | int | `>=0`,默认 `0` |
|
||||
| `limit` | 否 | int | `1-200`,默认 `10` |
|
||||
|
||||
## 坑点
|
||||
|
||||
- ⚠️ `+record-search` 用于检索,不用于聚合分析;聚合场景使用 `+data-query`。
|
||||
- ⚠️ 部分字段不支持搜索(例如 `attachment`、`link`);传入后通常不会报错,但可能导致无法命中对应记录。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base-record.md](lark-base-record.md) — record 索引页
|
||||
- [lark-base-record-list.md](lark-base-record-list.md) — 分页列表读取
|
||||
- [lark-base-data-query.md](lark-base-data-query.md) — 聚合分析
|
||||
@@ -8,9 +8,7 @@ record 相关命令索引。
|
||||
|
||||
| 文档 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| [lark-base-record-search.md](lark-base-record-search.md) | `+record-search` | 按关键词和字段范围检索记录 |
|
||||
| [lark-base-record-list.md](lark-base-record-list.md) | `+record-list` | 分页列记录 |
|
||||
| [lark-base-record-get.md](lark-base-record-get.md) | `+record-get` | 获取单条记录 |
|
||||
| [lark-base-record-read-sop.md](lark-base-record-read-sop.md) | `+record-get` / `+record-search` / `+record-list` | 记录读取统一选路、筛选排序投影 SOP |
|
||||
| [lark-base-record-upsert.md](lark-base-record-upsert.md) | `+record-upsert` | 创建或更新记录 |
|
||||
| [lark-base-record-batch-create.md](lark-base-record-batch-create.md) | `+record-batch-create` | 按 `fields/rows` 批量创建记录 |
|
||||
| [lark-base-record-batch-update.md](lark-base-record-batch-update.md) | `+record-batch-update` | 批量更新记录 |
|
||||
@@ -21,7 +19,8 @@ record 相关命令索引。
|
||||
|
||||
## 说明
|
||||
|
||||
- 聚合页只保留目录职责;每个命令的详细说明请进入对应单命令文档。
|
||||
- 读取记录前优先阅读 [lark-base-record-read-sop.md](lark-base-record-read-sop.md),它合并了 `+record-get` / `+record-search` / `+record-list` 的选路和 SOP。
|
||||
- 聚合页只保留目录职责;写入、删除、历史等命令的详细说明请进入对应单命令文档。
|
||||
- 所有 `+xxx-list` 调用都必须串行执行;若要批量跑多个 list 请求,只能串行执行。
|
||||
- `+record-list` 支持重复传参 `--field-id` 做字段筛选。
|
||||
- 写记录 JSON 前优先阅读 [lark-base-cell-value.md](lark-base-cell-value.md)。
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: lark-doc
|
||||
version: 2.0.0
|
||||
description: "飞书云文档:创建和编辑飞书文档。默认使用 DocxXML 格式(也支持 Markdown)。创建文档、获取文档内容(支持 simple/with-ids/full 三种导出详细度,以及 full/outline/range/keyword/section 五种局部读取模式,可按目录、block id 区间、关键词或标题自动成节只拉部分内容以节省上下文)、更新文档(八种指令:str_replace/block_insert_after/block_copy_insert_after/block_replace/block_delete/block_move_after/overwrite/append)、上传和下载文档中的图片和文件、搜索云空间文档。当用户需要创建或编辑飞书文档、读取文档内容、在文档中插入图片、搜索云空间文档时使用;如果用户是想按名称或关键词先定位电子表格、报表等云空间对象,也优先使用本 skill 的 docs +search 做资源发现。"
|
||||
description: "飞书云文档(v2):创建和编辑飞书文档。使用本 skill 时,docs +create、docs +fetch、docs +update 必须携带 --api-version v2;默认使用 DocxXML 格式(也支持 Markdown)。创建文档、获取文档内容(支持 simple/with-ids/full 三种导出详细度,以及 full/outline/range/keyword/section 五种局部读取模式,可按目录、block id 区间、关键词或标题自动成节只拉部分内容以节省上下文)、更新文档(八种指令:str_replace/block_insert_after/block_copy_insert_after/block_replace/block_delete/block_move_after/overwrite/append)、上传和下载文档中的图片和文件、搜索云空间文档。当用户需要创建或编辑飞书文档、读取文档内容、在文档中插入图片、搜索云空间文档时使用;如果用户是想按名称或关键词先定位电子表格、报表等云空间对象,也优先使用本 skill 的 docs +search 做资源发现。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli docs --help"
|
||||
cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help"
|
||||
---
|
||||
|
||||
# docs (v2)
|
||||
|
||||
@@ -74,7 +74,7 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
|
||||
## 最佳实践
|
||||
|
||||
- 文档标题从内容中自动提取(XML `<title>` 或 Markdown `#`),不要在内容开头重复写标题
|
||||
- 创建较长的文档时,先创建基础内容,再用 `docs +update --command block_insert_after` 分段追加
|
||||
- **创建较长的文档时只建骨架**:`--content` 仅传标题 + 各级 heading + 简短占位摘要;正文留给后续 `docs +update --command append` 或 `block_insert_after` 分段追加。一次性塞超长 `--content` 既容易触发参数限制,调试也更难。
|
||||
- **视觉丰富度**:必须遵循 [`lark-doc-style.md`](style/lark-doc-style.md) 中的样式指南,主动使用结构化 block 丰富文档
|
||||
|
||||
## 参考
|
||||
|
||||
@@ -49,21 +49,21 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
| `outline` | 不知道结构,先看目录 | `--max-depth`(标题层级上限) | 扁平列出所有标题,**包括嵌在容器里的内嵌标题**(如 callout 里的 h3);这些 id 可直接作后续 `section` / `range` 端点 |
|
||||
| `section` | 读某个标题对应的整节 | `--start-block-id`(必填) | 顶层标题 → 展开到下一同级/更高级标题前;容器内节点(含内嵌标题) → 按"最小包容单元"返回容器/表格切片,不做 heading 扩展;顶层非标题块 → 仅该块 |
|
||||
| `range` | 已知精确起止 | `--start-block-id` / `--end-block-id` 至少一个;`-1` = 读到末尾 | 两端同顶层 → 顶层序列切片;两端同一容器 → 容器整体;两端同一表格 → 瘦身切片;**跨顶层 → 端点所在顶层块整块输出,不做瘦身** |
|
||||
| `keyword` | 只有模糊关键词 | `--keyword`(不区分大小写、子串,`\|` 分隔多词 OR) | 每处命中按"最小包容单元"输出;**自动去重**(同容器多命中 → 单个容器,同表格多行命中 → 合并切片) |
|
||||
| `keyword` | 只有模糊关键词 | `--keyword`(**多级自动 fallback**:子串 → 归一化 → 分词形变 → RE2 正则;`\|` 分隔多分支 OR) | 每处命中按"最小包容单元"输出;**自动去重**(同容器多命中 → 单个容器,同表格多行命中 → 合并切片) |
|
||||
|
||||
> 💡 **多关键词用 `\|` 拼接(OR 语义,任一命中即返回)**:例 `"部署\|发布\|上线"`,三词任一命中都进结果,适合**同义词/别名/多业务术语**一次召回(如 `bug\|缺陷\|故障`)。
|
||||
|
||||
**设置 `--scope` 时共用** `--context-before` / `--context-after` / `--max-depth`。
|
||||
|
||||
- `--max-depth`:`outline` = 标题层级上限(3 = h1~h3);其它模式 = 被选块的子树遍历深度(`-1` 不限,`0` 仅块自身)。
|
||||
- `--context-before/--context-after`:**只对整块顶层单元生效**;命中落在容器/表格内(返回容器或切片)时 before/after 被忽略,需要更大范围改用 `section` / `range` 显式指定。
|
||||
|
||||
**决策顺序**(核心原则:**局部获取优于全量获取**,能精确到节/区间就绝不全量拉取;**任何文档的第一次读取都应从 `outline` 开始**):
|
||||
1. **第一次接触文档 / 不知道结构** → 先 `outline` 探测目录(**强制首步,无论文档是"目标"还是"引用源"**),再回到 2/3 精读
|
||||
2. 改/读某个**标题对应的整节** → `section`(最省心,**首选精读方式**)
|
||||
3. 精确自定义起止 / 跨节连续区间 → `range`
|
||||
4. 只有模糊关键词 → `keyword`
|
||||
5. **兜底**:确实需要整篇文档时才不传 `--scope`(默认整篇);**不要为了省事就读整篇**,局部模式上下文更省、响应更快
|
||||
|
||||
**推荐双步流程**:`outline --max-depth 3` 拿目录 → `section --start-block-id <标题id> --detail with-ids` 精读该节。
|
||||
**决策顺序**(核心原则:**局部获取优于全量获取**,根据需求形态选起点,必要时多步组合收敛范围):
|
||||
1. 需求**直接给出待查的具体术语/错误码/标识** → 直接走 `keyword` 粗匹配(多级 fallback 自动覆盖形变),需要更大上下文时用返回的 `top-block-id` 走 `section` / `range`
|
||||
2. 需求**指向某个章节/标题**("修改 XX 章"、"总结第 3 节"、"关于 xx 的内容")→ 先 `outline --max-depth 3` 拿目录 → `section --start-block-id <标题id>` 精读
|
||||
3. 已知**精确起止 / 跨节连续区间** → `range`
|
||||
4. **结构未知且无明确关键词/章节线索** → `outline` 探测,再回到 2/3
|
||||
5. **兜底**:仅在确需整篇时才省略 `--scope`;不要为省事直接读整篇
|
||||
|
||||
## 局部读取的输出结构:`<fragment>` 与 `<excerpt>`
|
||||
|
||||
@@ -108,7 +108,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
| `--scope` | 否 | `outline` \| `range` \| `keyword` \| `section`(省略 = 读整篇) |
|
||||
| `--start-block-id` | 否 | `range`/`section` 起始/锚点 id(`section` 必填) |
|
||||
| `--end-block-id` | 否 | `range` 结束 id;`-1` 表示读到末尾 |
|
||||
| `--keyword` | 否 | `keyword` 模式关键词;`\|` 分隔多词 OR |
|
||||
| `--keyword` | 否 | `keyword` 模式关键词,**4 层自动 fallback**(子串 → 归一化 → 分词形变 → RE2 正则);`\|` 分隔多分支 OR |
|
||||
| `--context-before` | 否 | 命中前拉几个兄弟块(仅对顶层单元生效,默认 `0`) |
|
||||
| `--context-after` | 否 | 命中后拉几个兄弟块(仅对顶层单元生效,默认 `0`) |
|
||||
| `--max-depth` | 否 | `outline` = 标题层级上限;其它 = 子树深度(`-1` 不限,默认) |
|
||||
|
||||
@@ -85,7 +85,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
- 合并单元格仅起始格输出 `colspan` / `rowspan`,被合并的格不出现
|
||||
|
||||
# 六、美化系统
|
||||
- 颜色优先使用命名色,也可写 `rgb(r,g,b)` / `rgba(r,g,b,a)`。**基础色(6 色)**:gray, red, orange, yellow, green, blue
|
||||
- 颜色优先使用命名色,也可写 `rgb(r,g,b)` / `rgba(r,g,b,a)`。**基础色(7 色)**:red, orange, yellow, green, blue, purple, gray
|
||||
| 属性 | 支持的命名色 |
|
||||
|-|-|
|
||||
| 文字颜色 `<span text-color>` | 基础色 |
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
|
||||
1. 分析用户需求:受众、目的、范围
|
||||
2. 设计大纲——每个 h1/h2 章节至少规划 1 个非文本 block
|
||||
3. `docs +create --api-version v2` 创建文档:标题 + 开头 `<callout>` + 骨架(各级标题 + 简短占位摘要)
|
||||
3. `docs +create --api-version v2` **只建骨架**:标题 + 开头 `<callout>` + 各级标题 + 每节一句占位摘要
|
||||
- ⚠️ **不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
|
||||
- 完整内容留到第二波,由各 Agent 用 `docs +update --command append` 或 `block_insert_after` 分段写入。
|
||||
|
||||
### 第二波 — 内容撰写(并行 Agent)
|
||||
|
||||
@@ -46,5 +48,3 @@
|
||||
## Agent 子任务要求
|
||||
|
||||
Spawn Agent 时必须提供:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。
|
||||
|
||||
章节较多时,先 `docs +create` 建骨架,再分段 `append` 追加,比一次性超长 `--content` 更可靠。
|
||||
|
||||
@@ -19,6 +19,7 @@ metadata:
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"、"最近一周我打开过的 xxx"、"某人创建的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。老的 `docs +search` 进入维护期、后续会下线,不要新增对它的依赖。
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要在 Drive 里上传、创建、读取、覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
|
||||
@@ -228,13 +229,16 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by SHA-256 content hash; reports `new_local` / `new_remote` / `modified` / `unchanged` (read-only diff primitive for sync workflows). `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
|
||||
| [`+pull`](references/lark-drive-pull.md) | One-way **file-level** mirror of a Drive folder onto a local directory (Drive → local). Supports `--if-exists` (overwrite/skip) and `--delete-local` for orphan cleanup; the destructive `--delete-local` requires `--yes` and only unlinks regular files — empty local directories left behind by remote folder deletes are NOT pruned. Item-level failures exit non-zero (`error.type=partial_failure`) and skip the `--delete-local` pass to avoid half-synced state. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the target is outside cwd. |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides |
|
||||
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling |
|
||||
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming |
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
|
||||
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |
|
||||
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
|
||||
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
|
||||
| [`+push`](references/lark-drive-push.md) | Mirror a local directory onto a Drive folder (local → Drive). Supports `--if-exists` (overwrite/skip) and `--delete-remote` for one-way mirror sync; the destructive `--delete-remote` requires `--yes`. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the source is outside cwd. |
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) |
|
||||
|
||||
|
||||
@@ -39,6 +39,14 @@ lark-cli drive +export \
|
||||
--file-extension xlsx \
|
||||
--output-dir ./exports
|
||||
|
||||
# 指定本地文件名(会按导出格式自动补扩展名)
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf \
|
||||
--file-name "weekly-report.pdf" \
|
||||
--output-dir ./exports
|
||||
|
||||
# 导出电子表格或多维表格为 csv 时,必须传 sub_id
|
||||
lark-cli drive +export \
|
||||
--token "<SHEET_OR_BITABLE_TOKEN>" \
|
||||
@@ -70,6 +78,7 @@ lark-cli drive +export \
|
||||
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` |
|
||||
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` / `base` |
|
||||
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
|
||||
| `--file-name` | 否 | 覆盖默认本地文件名;如未带扩展名,会按 `--file-extension` 自动补齐 |
|
||||
| `--output-dir` | 否 | 本地输出目录,默认当前目录 |
|
||||
| `--overwrite` | 否 | 覆盖已存在文件 |
|
||||
|
||||
@@ -88,7 +97,8 @@ lark-cli drive +export \
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--doc-type docx \
|
||||
--file-extension pdf
|
||||
--file-extension pdf \
|
||||
--file-name "weekly-report.pdf"
|
||||
|
||||
# 如果返回 ready=false / timed_out=true,再继续查
|
||||
lark-cli drive +task_result \
|
||||
@@ -99,6 +109,7 @@ lark-cli drive +task_result \
|
||||
# 查到 file_token 后下载
|
||||
lark-cli drive +export-download \
|
||||
--file-token "<EXPORTED_FILE_TOKEN>" \
|
||||
--file-name "weekly-report.pdf" \
|
||||
--output-dir ./exports
|
||||
```
|
||||
|
||||
|
||||
113
skills/lark-drive/references/lark-drive-pull.md
Normal file
113
skills/lark-drive/references/lark-drive-pull.md
Normal file
@@ -0,0 +1,113 @@
|
||||
|
||||
# drive +pull
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
把飞书云空间的某个文件夹**单向、文件级**镜像到本地目录(Drive → 本地)。命令递归列出 `--folder-token` 下所有 `type=file` 的文件,逐一下载到 `--local-dir` 对应的相对路径,子文件夹自动复刻为本地目录。
|
||||
|
||||
> ⚠️ **不是 directory-level mirror**:`--delete-local` 只删除本地"多余"的常规文件,不删除空目录。如果云端把整个子文件夹删了,对应的本地子目录会留空(里面的文件被清掉,目录本身保留);想精确同步目录结构请自己 `rmdir` 处理空壳。
|
||||
|
||||
输出按"动作"分类:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `summary.downloaded` | 成功下载的文件数 |
|
||||
| `summary.skipped` | 按 `--if-exists=skip` 跳过的文件数 |
|
||||
| `summary.failed` | 下载或写盘失败的文件数 |
|
||||
| `summary.deleted_local` | 启用 `--delete-local --yes` 时删除的本地文件数 |
|
||||
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `action` / 失败时的 `error`) |
|
||||
|
||||
`summary.failed > 0` 时命令以 **非零状态码**(`exit=1`,`error.type=partial_failure`)退出,且同一份 `summary + items` 会在 `error.detail` 里返回;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 基础用法 —— 把云端 fldcXXX 镜像到 ./repo
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx
|
||||
|
||||
# 已存在的本地文件保持不动
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists skip
|
||||
|
||||
# 文件级镜像:下载新文件 + 删除云端没有的本地文件(不删空目录)
|
||||
# (--delete-local 必须搭配 --yes,否则会被 Validate 直接拒绝)
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--delete-local --yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 标志 | 必填 | 类型 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) |
|
||||
| `--folder-token` | 是 | string | 源 Drive 文件夹 token |
|
||||
| `--if-exists` | 否 | enum | 本地文件已存在时的策略:`overwrite`(默认)/ `skip` |
|
||||
| `--delete-local` | 否 | bool | 删除本地"云端没有的常规文件"(**不删空目录**,因此是 file-level mirror);**必须配合 `--yes`** |
|
||||
| `--yes` | 否 | bool | 确认 `--delete-local`;不传时该破坏性操作在 Validate 阶段被拒绝 |
|
||||
|
||||
## 比较与下载范围
|
||||
|
||||
- **只下载 Drive `type=file` 的二进制文件**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)会被跳过 —— 它们没有等价的本地二进制可写盘,否则会变成产生噪声的"假"下载。
|
||||
- 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`,本地缺失的父目录会被自动创建。
|
||||
- 已存在的本地文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的请自己改名再 pull。
|
||||
|
||||
## --delete-local 的安全行为
|
||||
|
||||
`--delete-local` 是命令里**唯一的破坏性 flag**,会按"本地有但云端没有"清理本地常规文件。设计上把它跟 `--yes` 强绑定,且与下载阶段的失败联动:
|
||||
|
||||
- `--delete-local`(无 `--yes`)→ Validate 直接报错:`--delete-local requires --yes`,没有任何下载、列表请求或删除发生。
|
||||
- `--delete-local --yes`,**且下载阶段全部成功** → 扫一遍 `--local-dir` 下所有常规文件,把不在云端清单里的逐个 `os.Remove`。**只删常规文件,不删目录**:远端文件夹被删除后,对应本地目录会保留空壳。
|
||||
- `--delete-local --yes`,**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `partial_failure` 非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
|
||||
- 不传 `--delete-local` → `summary.deleted_local` 永远是 0;命令对本地"多余"文件视而不见。
|
||||
|
||||
第 6 章里把 `+pull --delete-local` 标了 `high-risk-write`,CLI 这边的实现等价于"未传 `--yes` 时拒绝执行",符合该约束的精神。
|
||||
|
||||
## 输出 schema
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"downloaded": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"deleted_local": 0
|
||||
},
|
||||
"items": [
|
||||
{"rel_path": "...", "file_token": "...", "action": "downloaded"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "skipped"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "failed", "error": "..."},
|
||||
{"rel_path": "...", "action": "deleted_local"},
|
||||
{"rel_path": "...", "action": "delete_failed", "error": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。删除条目(`deleted_local` / `delete_failed`)没有 `file_token`,因为该文件本来就只在本地。
|
||||
|
||||
## 性能注意
|
||||
|
||||
- 下载流量 ≈ 云端待下载文件的总字节数。pull 是**全量**写盘 —— 跟 `+status` 不一样,不会跳过"内容相同"的文件(status 是按 hash 比较,pull 是按 `--if-exists`),所以一次跑可能很重。
|
||||
- 想避免重跑全量,可以先 `+status` 找出 `new_remote` 和 `modified`,再只对这些文件单独 `+download`。
|
||||
- 大文件会用 SDK 的流式下载(不会把整个 body 读进内存),但本地磁盘空间需要够。
|
||||
|
||||
## 所需 scope
|
||||
|
||||
| 操作 | scope |
|
||||
|------|-------|
|
||||
| 列出文件夹 / 子目录 | `drive:drive.metadata:readonly` |
|
||||
| 下载文件 | `drive:file:download` |
|
||||
|
||||
如果当前 token 缺这些 scope,命令会直接报 `missing_scope` 并提示重新登录。`drive:drive` 在部分企业被策略禁用,所以 +pull 故意只声明上面这两个细粒度 scope。
|
||||
|
||||
## 范围限制
|
||||
|
||||
`--local-dir` 只接受 cwd 内的相对路径。CLI 会先 `EvalSymlinks` 整条路径,再判断它是否仍落在 cwd 内 —— **指向 cwd 外的符号链接也会被拒**,"在 cwd 内放一条软链指向外面" 这条捷径走不通,会直接撞上 `unsafe file path`。
|
||||
|
||||
如果用户想 pull 到 cwd 之外的目录,**不要 agent 自己 `cd` 绕过**。可以选:让用户在外部把 agent 工作目录切换到目标的祖先后重启会话;或者把目标整体物理移动 / 拷贝到 cwd 内(不是软链);或者直接放弃这次同步,改用别的方式。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) —— 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) —— 认证和全局参数
|
||||
- [lark-drive-status](lark-drive-status.md) —— 下载前先看差异
|
||||
- [lark-drive-download](lark-drive-download.md) —— 单文件按需拉取
|
||||
137
skills/lark-drive/references/lark-drive-push.md
Normal file
137
skills/lark-drive/references/lark-drive-push.md
Normal file
@@ -0,0 +1,137 @@
|
||||
|
||||
# drive +push
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
把本地目录**单向、文件级**镜像到飞书云空间的某个文件夹(本地 → Drive)。命令递归列出 `--folder-token` 下的远端清单,遍历 `--local-dir` 的所有常规文件,按相对路径在 Drive 上新建、覆盖或跳过;可选地(`--delete-remote --yes`)删除云端"本地没有"的 `type=file`。
|
||||
|
||||
> **"文件级镜像"≠"目录镜像"。** 命令只在文件维度收敛差异:本地多了文件就上传,本地少了文件且开了 `--delete-remote --yes` 就删远端文件。**远端只有的空目录、本地已删除的目录**都不会被收敛,云端目录树的多余结构不会被清理。如果需要"目录也要保持完全一致",得自行先 `+status` 找差异、再手动处理多余目录。
|
||||
|
||||
输出按"动作"分类:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `summary.uploaded` | 成功新建或覆盖的文件数 |
|
||||
| `summary.skipped` | 按 `--if-exists=skip` 跳过的文件数 |
|
||||
| `summary.failed` | 上传 / 覆盖 / 建目录 / 删除失败的条目数;**只要不为 0,命令就以非零状态退出**(结构化 `items[]` 仍在 stdout 上) |
|
||||
| `summary.deleted_remote` | 启用 `--delete-remote --yes` 时删除的云端文件数 |
|
||||
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error`) |
|
||||
|
||||
`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `failed` / `delete_failed`。
|
||||
|
||||
> 本地目录(包括空目录)会被镜像到 Drive;新建的子目录会以 `action: "folder_created"` 出现在 `items[]` 里,但**不计入** `summary.uploaded`(该字段只数文件)。已存在的远端目录复用其 token,不会重复 `create_folder`,也不会出现在 `items[]` 里。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 基础用法 —— 把本地 ./repo 增量推送到云端 fldcXXX
|
||||
# 默认 --if-exists=skip:已经存在的远端文件保持不动,只新增、不覆盖。
|
||||
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx
|
||||
|
||||
# 显式覆盖远端同名文件(依赖 upload_all 的灰度协议字段,详见下文"覆盖语义")
|
||||
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists overwrite
|
||||
|
||||
# 文件级镜像同步:上传 / 覆盖 + 删除本地不存在的远端文件
|
||||
# (--delete-remote 必须搭配 --yes,否则会被 Validate 直接拒绝;
|
||||
# 且 Validate 阶段会动态检查 space:document:delete scope,缺权限会立刻失败,
|
||||
# 不会出现"上传成功了但是后面删除阶段挂了"的半同步状态)
|
||||
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists overwrite --delete-remote --yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 标志 | 必填 | 类型 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) |
|
||||
| `--folder-token` | 是 | string | 目标 Drive 文件夹 token |
|
||||
| `--if-exists` | 否 | enum | 远端文件已存在时的策略:`skip`(**默认**,安全)/ `overwrite`(依赖灰度后端协议,详见"覆盖语义") |
|
||||
| `--delete-remote` | 否 | bool | 删除云端本地不存在的文件(文件级镜像;**不会**清理远端只有的目录);**必须配合 `--yes`**,且 Validate 阶段会动态检查 `space:document:delete` scope |
|
||||
| `--yes` | 否 | bool | 确认 `--delete-remote`;不传时该破坏性操作在 Validate 阶段被拒绝 |
|
||||
|
||||
## 上传与目录复刻范围
|
||||
|
||||
- **只上传 / 覆盖 / 删除 Drive `type=file`**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)即使在同一 rel_path 下出现,也不会被覆盖或删除 —— 它们没有等价的本地二进制。
|
||||
- **本地目录结构整体被镜像**:所有子目录(含**空目录**)会按需在 Drive 上 `create_folder`;同名远端目录复用其 token,不重建。空目录不计入 `summary.uploaded`,但会在 `items[]` 里以 `folder_created` 形式留痕。
|
||||
- 已存在的远端文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的请自行改名再 push。
|
||||
|
||||
## 覆盖语义
|
||||
|
||||
`--if-exists=overwrite` 走 `POST /open-apis/drive/v1/files/upload_all`,并在 form 中带上现有文件的 `file_token`,由后端原地更新内容并返回新版本号。`items[].version` 字段会回填该版本号。
|
||||
|
||||
> **为什么默认是 `skip` 而不是 `overwrite`:** `upload_all` 接受 `file_token` 字段、并在响应里返回 `version` 是设计文档(Drive 同步盘)规定的协议;此后端尚在灰度发布。在还未开通该字段的 tenant 上,`--if-exists=overwrite` 会因"无 version 返回"而把对应文件标成 `failed`,整次 `+push` 也会因此非零退出。所以默认值故意定为 `skip`:第一次往一个已经有内容的目录里 push,不会因为协议没到位就把整次运行打挂;要真的覆盖远端,必须显式带 `--if-exists overwrite`。新建上传不依赖该字段,不受影响。
|
||||
|
||||
大文件(>20MB)会自动切到三段式 `upload_prepare` / `upload_part` / `upload_finish`;该路径下 `version` 暂未在响应中返回,覆盖结果中 `items[].version` 会留空,但 `file_token` 与 `action: overwritten` 仍会正确产出。
|
||||
|
||||
## --delete-remote 的安全行为
|
||||
|
||||
`--delete-remote` 是命令里**唯一的破坏性 flag**,会按"远端有但本地没有"逐个 `DELETE /open-apis/drive/v1/files/<token>?type=file` 清理云端副本。设计上把它跟 `--yes` 强绑定:
|
||||
|
||||
- `--delete-remote`(无 `--yes`)→ Validate 直接报错:`--delete-remote requires --yes`,不会发起任何列表 / 上传 / 删除请求。
|
||||
- `--delete-remote --yes` → Validate 阶段还会**动态做一次** `space:document:delete` 的 scope 预检:缺这条 scope 时整次运行立刻失败、不发任何上传请求,避免出现"上传都成功了,但删除阶段才报 missing_scope"的半同步状态。
|
||||
- `--delete-remote --yes`(且 scope 已授权)→ 正常执行:先把本地文件 push 上去,再扫一遍远端 `type=file` 列表,把不在本地清单里的逐个删除。**任何上传 / 覆盖 / 建目录失败时,整段 `--delete-remote` 阶段会被跳过**(stderr 上有提示),命令以非零状态退出,远端不会被破坏。
|
||||
- 不传 `--delete-remote` → `summary.deleted_remote` 永远是 0;命令对远端"多余"文件视而不见。
|
||||
- 在线文档(docx / sheet / bitable / ...)和快捷方式即使本地完全没有同名文件,也**不会**进入删除候选,因为它们从来不进 `summary.uploaded` 的对齐域。
|
||||
- **远端只有的空目录、本地已删除的目录**也不会被清理 —— 这是"文件级镜像"的语义边界,命令不会对目录结构做主动收敛。
|
||||
|
||||
第 6 章里把 `+push --delete-remote` 标了 `high-risk-write`,CLI 这边的实现等价于"未传 `--yes` 时拒绝执行 + 动态 scope 预检",符合该约束的精神。
|
||||
|
||||
## 输出 schema
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"uploaded": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"deleted_remote": 0
|
||||
},
|
||||
"items": [
|
||||
{"rel_path": "...", "file_token": "...", "action": "folder_created"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "uploaded", "size_bytes": 0},
|
||||
{"rel_path": "...", "file_token": "...", "action": "overwritten", "version": "...", "size_bytes": 0},
|
||||
{"rel_path": "...", "file_token": "...", "action": "skipped", "size_bytes": 0},
|
||||
{"rel_path": "...", "action": "failed", "size_bytes": 0, "error": "..."},
|
||||
{"rel_path": "...", "file_token": "...", "action": "deleted_remote"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "delete_failed", "error": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。
|
||||
|
||||
## 性能注意
|
||||
|
||||
- 上传流量 ≈ 本地待上传文件的总字节数。push 是**全量**上传 —— 跟 `+status` 不一样,不会按 hash 跳过"内容相同"的文件(status 是按 hash 比较,push 是按 `--if-exists`),所以一次跑可能很重。
|
||||
- 想避免重跑全量,可以先 `+status` 找出 `new_local` 和 `modified`,再只对这些文件单独上传 / 覆盖。
|
||||
- 大文件会用三段式分片上传(不会把整个 body 读进内存),但本地磁盘和上行带宽需要够。
|
||||
|
||||
## 所需 scope
|
||||
|
||||
| 操作 | scope | 是否在命令上预声明 |
|
||||
|------|-------|-------------------|
|
||||
| 列出文件夹 / 子目录 | `drive:drive.metadata:readonly` | ✅ 预声明 |
|
||||
| 上传 / 覆盖文件 | `drive:file:upload` | ✅ 预声明 |
|
||||
| 新建子目录(`create_folder`) | `space:folder:create` | ✅ 预声明 |
|
||||
| 删除文件(仅 `--delete-remote --yes`) | `space:document:delete` | ⚙️ 不在命令默认 Scopes 里,但在 `--delete-remote --yes` 时由 Validate 动态预检 |
|
||||
|
||||
`drive:drive` 在部分企业被策略禁用,所以 +push 故意只声明上面这几条细粒度 scope。
|
||||
|
||||
> **关于 `space:document:delete`:** 框架的 scope 预检(`runner.go: checkShortcutScopes`)会在 `Validate` 和 `--dry-run` 之前就把命令上声明的 scope 全检查一遍;如果把删除 scope 也预声明,**普通上传或 dry-run** 都会因为没授权删除权限而被拦下来。所以这一项不放在命令的默认 Scopes 里,而是在 Validate 中**条件触发**:只有 `--delete-remote --yes` 同时打开时才会调用 `runtime.EnsureScopes([]string{"space:document:delete"})` 做一次动态前置校验。这样既保留了"普通上传不需要删除权限"的便利,又能在真要做镜像删除前把 scope 缺失暴露出来,避免出现"上传成功 → 删除阶段才挂"的半同步状态。
|
||||
>
|
||||
> 想一次性把权限补齐:`lark-cli auth login --scope "drive:drive.metadata:readonly drive:file:upload space:folder:create space:document:delete"`。
|
||||
|
||||
## 范围限制
|
||||
|
||||
`--local-dir` 只接受 cwd 内的相对路径。CLI 会先 `EvalSymlinks` 整条路径,再判断它是否仍落在 cwd 内 —— **指向 cwd 外的符号链接也会被拒**,"在 cwd 内放一条软链指向外面" 这条捷径走不通,会直接撞上 `unsafe file path`。
|
||||
|
||||
如果用户想 push cwd 之外的目录,**不要 agent 自己 `cd` 绕过**。可以选:让用户在外部把 agent 工作目录切换到目标的祖先后重启会话;或者把目标整体物理移动 / 拷贝到 cwd 内(不是软链);或者直接放弃这次同步,改用别的方式。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) —— 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) —— 认证和全局参数
|
||||
- [lark-drive-status](lark-drive-status.md) —— 上传前先看差异(避免全量回写)
|
||||
- [lark-drive-pull](lark-drive-pull.md) —— Drive → 本地的对称命令
|
||||
- [lark-drive-upload](lark-drive-upload.md) —— 单文件按需上传
|
||||
89
skills/lark-drive/references/lark-drive-status.md
Normal file
89
skills/lark-drive/references/lark-drive-status.md
Normal file
@@ -0,0 +1,89 @@
|
||||
|
||||
# drive +status
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
按 SHA-256 内容哈希比较本地目录与飞书云空间文件夹,输出四类差异:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `new_local` | 仅本地存在 |
|
||||
| `new_remote` | 仅云端存在 |
|
||||
| `modified` | 双端都存在但 hash 不一致 |
|
||||
| `unchanged` | 双端都存在且 hash 一致 |
|
||||
|
||||
只读命令:流式 hash,不下载落盘;但双端都有的文件会从云端拉一份字节流过来在内存里算 hash,大目录 / 大文件会有可观的网络流量。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 基础用法 —— 两个必填参数
|
||||
lark-cli drive +status \
|
||||
--local-dir ./repo \
|
||||
--folder-token fldcnxxxxxxxxx
|
||||
|
||||
# 只看 hash 不一致的项(结合 --jq 过滤)
|
||||
lark-cli drive +status \
|
||||
--local-dir ./repo \
|
||||
--folder-token fldcnxxxxxxxxx \
|
||||
--jq '.modified'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 标志 | 必填 | 类型 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃逸到 cwd 外的相对路径会被 CLI 直接拒绝) |
|
||||
| `--folder-token` | 是 | string | Drive 文件夹 token |
|
||||
|
||||
## 输出 schema
|
||||
|
||||
```json
|
||||
{
|
||||
"new_local": [{"rel_path": "..."}],
|
||||
"new_remote": [{"rel_path": "...", "file_token": "..."}],
|
||||
"modified": [{"rel_path": "...", "file_token": "..."}],
|
||||
"unchanged": [{"rel_path": "...", "file_token": "..."}]
|
||||
}
|
||||
```
|
||||
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致),相对于 `--local-dir` 或 `--folder-token` 的根。仅本地存在时没有 `file_token` 字段。
|
||||
|
||||
## 比较范围
|
||||
|
||||
- **只比对 Drive `type=file` 的二进制文件**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)都被跳过 —— 它们没有等价的本地二进制可对齐,否则会在 `new_remote` 里产生大量误报。
|
||||
- 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`。
|
||||
- 本地侧只比对常规文件(regular file);符号链接、设备文件等被忽略。
|
||||
|
||||
## 范围限制
|
||||
|
||||
`+status` 的本地侧只接受 cwd 下的相对路径。如果用户想比对的目录在 cwd 之外,**不要 agent 自己 `cd` 绕过**;让用户在合适的祖先目录重新启动 agent 后再跑。注意:把目标软链接到 cwd 内**也不行**——路径校验会先 `EvalSymlinks` 再判定是否越界,链接最终指向的真实目录如果在 cwd 之外,仍然会被 `unsafe file path` 拒掉。CLI 会在路径越界时直接报错,无需在 skill 这一层提前手动校验。
|
||||
|
||||
## 典型用法
|
||||
|
||||
把 +status 当作"先看差异、再决定怎么同步"的只读探针。常见接驳场景:
|
||||
|
||||
- 想知道云端有什么本地没有的内容 → 看 `new_remote`,按需选择性拉取(`drive +download --file-token <token>`)。
|
||||
- 想把本地新增的内容推到云端 → 看 `new_local`,再 `drive +upload --file <path> --folder-token <parent>`(注意 +upload 不接受 0 字节文件)。
|
||||
- 想知道哪些文件在云端被同事改过 → 看 `modified`,逐个 `drive +download` 查内容差异。
|
||||
|
||||
## 性能注意
|
||||
|
||||
- `unchanged` + `modified` 的总字节数 = 本次需从云端下载的流量。100GB 的双端共享内容意味着 100GB 网络往返。
|
||||
- 仅一侧存在的文件不会被下载。
|
||||
- Hash 计算在内存里流式做(io.Copy → sha256.New),不会把云端文件落到磁盘。
|
||||
|
||||
## 所需 scope
|
||||
|
||||
| 操作 | scope |
|
||||
|------|-------|
|
||||
| 列出文件夹 / 子目录 | `drive:drive.metadata:readonly` |
|
||||
| 下载并 hash 文件 | `drive:file:download` |
|
||||
|
||||
如果当前 token 缺这些 scope,命令会直接报 `missing_scope` 并提示重新登录。`drive:drive` 在部分企业被策略禁用,所以 +status 故意只声明上面这两个细粒度 scope。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) —— 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) —— 认证和全局参数
|
||||
- [lark-drive-upload](lark-drive-upload.md) / [lark-drive-download](lark-drive-download.md) —— 把 +status 输出接到推/拉动作上
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
上传本地文件到飞书云空间。目标位置可以是 Drive 文件夹,也可以是 wiki 节点。
|
||||
|
||||
## 快速决策
|
||||
- 用户要在 Drive 里上传、创建、读取、覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../../lark-markdown/SKILL.md)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
|
||||
46
skills/lark-markdown/SKILL.md
Normal file
46
skills/lark-markdown/SKILL.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: lark-markdown
|
||||
version: 1.0.0
|
||||
description: "飞书 Markdown:查看、创建、上传和编辑 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取或修改时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli markdown --help"
|
||||
---
|
||||
|
||||
# markdown (v1)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户要**上传、创建一个原生 `.md` 文件**,使用 `lark-cli markdown +create`
|
||||
- 用户要**读取 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +fetch`
|
||||
- 用户要**覆盖更新 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +overwrite`
|
||||
- 用户要把本地 Markdown **导入成在线新版文档(docx)**,不要用本 skill,改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
|
||||
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间操作,不要留在本 skill,切到 [`lark-drive`](../lark-drive/SKILL.md)
|
||||
|
||||
## 核心边界
|
||||
|
||||
- 本 skill 处理的是 **Drive 中作为普通文件存储的 Markdown**,不是 docx 文档
|
||||
- `--name` 和本地 `--file` 文件名都必须显式带 `.md` 后缀;不满足时 shortcut 会直接报错
|
||||
- `--content` 支持:
|
||||
- 直接传字符串
|
||||
- `@file` 从本地文件读取内容
|
||||
- `-` 从 stdin 读取内容
|
||||
- `--file` 只接受本地 `.md` 文件路径
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli markdown +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-markdown-create.md) | Create a Markdown file in Drive |
|
||||
| [`+fetch`](references/lark-markdown-fetch.md) | Fetch a Markdown file from Drive |
|
||||
| [`+overwrite`](references/lark-markdown-overwrite.md) | Overwrite an existing Markdown file in Drive |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-shared](../lark-shared/SKILL.md) — 认证和全局参数
|
||||
- [lark-drive](../lark-drive/SKILL.md) — Drive 文件管理、导入 docx、move/delete/search 等
|
||||
86
skills/lark-markdown/references/lark-markdown-create.md
Normal file
86
skills/lark-markdown/references/lark-markdown-create.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# markdown +create
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
在 Drive 中创建一个原生 Markdown 文件(`.md`)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 直接用行内内容创建
|
||||
lark-cli markdown +create \
|
||||
--name README.md \
|
||||
--content '# Hello'
|
||||
|
||||
# 从本地 .md 文件创建
|
||||
lark-cli markdown +create \
|
||||
--file ./README.md
|
||||
|
||||
# 从本地文件读取内容,但仍走 --content
|
||||
lark-cli markdown +create \
|
||||
--name README.md \
|
||||
--content @./README.md
|
||||
|
||||
# 从 stdin 读取内容
|
||||
printf '# Hello\n\nfrom stdin\n' | \
|
||||
lark-cli markdown +create \
|
||||
--name README.md \
|
||||
--content -
|
||||
|
||||
# 创建到指定文件夹
|
||||
lark-cli markdown +create \
|
||||
--folder-token fldcn_xxx \
|
||||
--file ./README.md
|
||||
|
||||
# 预览底层请求
|
||||
lark-cli markdown +create \
|
||||
--name README.md \
|
||||
--content '# Hello' \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--folder-token` | 否 | 目标 Drive 文件夹 token;省略时创建到根目录 |
|
||||
| `--name` | 条件必填 | 文件名,**必须显式带 `.md` 后缀**;使用 `--content` 时必填;使用 `--file` 时可省略,默认取本地文件名 |
|
||||
| `--content` | 条件必填 | Markdown 内容;与 `--file` 互斥;支持直接传字符串、`@file`、`-`(stdin) |
|
||||
| `--file` | 条件必填 | 本地 `.md` 文件路径;与 `--content` 互斥 |
|
||||
|
||||
## 关键约束
|
||||
|
||||
- `--content` 与 `--file` 必须二选一
|
||||
- `--name` 必须带 `.md` 后缀
|
||||
- `--file` 指向的本地文件名也必须带 `.md` 后缀
|
||||
|
||||
## 返回值
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"file_token": "boxcnxxxx",
|
||||
"file_name": "README.md",
|
||||
"size_bytes": 1234
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 如果 Markdown 文件是**以应用身份(bot)创建**的,如 `lark-cli markdown +create --as bot`,在创建成功后,CLI 会**尝试为当前 CLI 用户自动授予该文件的 `full_access`(可管理权限)**。
|
||||
>
|
||||
> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
|
||||
> - `status = granted`:当前 CLI 用户已获得该文件的可管理权限
|
||||
> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份(bot)授予当前用户权限
|
||||
> - `status = failed`:Markdown 文件已创建成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该文件
|
||||
>
|
||||
> `permission_grant.perm = full_access` 表示该资源已授予“可管理权限”。
|
||||
>
|
||||
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-markdown](../SKILL.md) — Markdown 域总览
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
79
skills/lark-markdown/references/lark-markdown-fetch.md
Normal file
79
skills/lark-markdown/references/lark-markdown-fetch.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# markdown +fetch
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
读取 Drive 中原生 Markdown 文件的内容;也支持把内容保存到本地。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 直接返回 Markdown 文本
|
||||
lark-cli markdown +fetch --file-token boxcnxxxx
|
||||
|
||||
# 保存到本地
|
||||
lark-cli markdown +fetch \
|
||||
--file-token boxcnxxxx \
|
||||
--output ./README.md
|
||||
|
||||
# 传目录时,使用远端文件名保存到该目录下
|
||||
lark-cli markdown +fetch \
|
||||
--file-token boxcnxxxx \
|
||||
--output ./downloads/
|
||||
|
||||
# 覆盖已存在文件
|
||||
lark-cli markdown +fetch \
|
||||
--file-token boxcnxxxx \
|
||||
--output ./README.md \
|
||||
--overwrite
|
||||
|
||||
# 预览底层请求
|
||||
lark-cli markdown +fetch \
|
||||
--file-token boxcnxxxx \
|
||||
--output ./README.md \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file-token` | 是 | 目标 Markdown 文件 token |
|
||||
| `--output` | 否 | 本地保存路径;既可传具体文件名,也可传目录路径。传目录时使用远端文件名保存;省略时直接返回 Markdown 内容 |
|
||||
| `--overwrite` | 否 | 覆盖已存在的本地输出文件;仅在传入 `--output` 时生效 |
|
||||
|
||||
## 返回值
|
||||
|
||||
不传 `--output`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"file_token": "boxcnxxxx",
|
||||
"file_name": "README.md",
|
||||
"content": "# Hello\n",
|
||||
"size_bytes": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
传入 `--output`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"file_token": "boxcnxxxx",
|
||||
"file_name": "README.md",
|
||||
"saved_path": "/abs/path/README.md",
|
||||
"size_bytes": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-markdown](../SKILL.md) — Markdown 域总览
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
85
skills/lark-markdown/references/lark-markdown-overwrite.md
Normal file
85
skills/lark-markdown/references/lark-markdown-overwrite.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# markdown +overwrite
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
覆盖更新 Drive 中已有的原生 Markdown 文件,并返回覆盖后的新版本号。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 用行内内容覆盖
|
||||
lark-cli markdown +overwrite \
|
||||
--file-token boxcnxxxx \
|
||||
--content '# Updated'
|
||||
|
||||
# 用本地 .md 文件覆盖
|
||||
lark-cli markdown +overwrite \
|
||||
--file-token boxcnxxxx \
|
||||
--file ./README.md
|
||||
|
||||
# 覆盖内容时顺便显式指定新文件名
|
||||
lark-cli markdown +overwrite \
|
||||
--file-token boxcnxxxx \
|
||||
--name NEW-README.md \
|
||||
--content '# Updated'
|
||||
|
||||
# 用 --content 从本地文件读取
|
||||
lark-cli markdown +overwrite \
|
||||
--file-token boxcnxxxx \
|
||||
--content @./README.md
|
||||
|
||||
# 用 stdin 覆盖
|
||||
printf '# Updated\n' | \
|
||||
lark-cli markdown +overwrite \
|
||||
--file-token boxcnxxxx \
|
||||
--content -
|
||||
|
||||
# 预览底层请求
|
||||
lark-cli markdown +overwrite \
|
||||
--file-token boxcnxxxx \
|
||||
--content '# Updated' \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file-token` | 是 | 目标 Markdown 文件 token |
|
||||
| `--name` | 否 | 显式指定覆盖后的文件名;必须带 `.md` 后缀。传入时优先使用它 |
|
||||
| `--content` | 条件必填 | 新 Markdown 内容;与 `--file` 互斥;支持直接传字符串、`@file`、`-`(stdin) |
|
||||
| `--file` | 条件必填 | 本地 `.md` 文件路径;与 `--content` 互斥 |
|
||||
|
||||
## 关键约束
|
||||
|
||||
- `--content` 与 `--file` 必须二选一
|
||||
- 如果传了 `--name`,直接使用它作为覆盖后的文件名
|
||||
- 如果没传 `--name` 且使用 `--content`,默认保留远端原文件名
|
||||
- 如果没传 `--name` 且使用 `--file`,默认使用本地文件名
|
||||
- `--file` 指向的本地文件名必须带 `.md` 后缀
|
||||
- 覆盖成功后 **必须** 返回 `version`
|
||||
|
||||
## 返回值
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"file_token": "boxcnxxxx",
|
||||
"file_name": "README.md",
|
||||
"version": "7633658129540910621",
|
||||
"size_bytes": 2048
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `version` 是覆盖写入后的新版本号
|
||||
- `size_bytes` 是本次覆盖后的内容大小
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-markdown](../SKILL.md) — Markdown 域总览
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-minutes
|
||||
version: 1.0.0
|
||||
description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节)。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
|
||||
description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节);5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物。遇到这类请求时,应优先使用本 skill,而不是尝试 `ffmpeg`、`whisper` 等本地转写命令。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -51,6 +51,8 @@ metadata:
|
||||
1. 当用户说"这个妙记的逐字稿""总结""待办""章节"时,**不属于本 skill**。
|
||||
2. 应使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) 获取对应的纪要产物。
|
||||
3. 如果当前上下文中已有 `minute_token`,可直接传给 `vc +notes`;如果只有妙记 URL,先提取 `minute_token`。
|
||||
4. 如果用户给的是**本地音视频文件**,但目标是"转成纪要""转成逐字稿""转成文字稿""转成撰写文字",也支持;此时应先按下文第 5 节上传文件生成妙记,再把返回的 `minute_url` 提取成 `minute_token`,继续调用 `vc +notes --minute-tokens`。
|
||||
5. 用户如果直接给出本地文件名或路径,并要求"转逐字稿""转文字稿""整理成撰写文字",这也是本 skill 的明确触发信号。
|
||||
|
||||
```bash
|
||||
# 通过 minute_token 获取纪要产物(逐字稿、总结、待办、章节)
|
||||
@@ -59,6 +61,19 @@ lark-cli vc +notes --minute-tokens <minute_token>
|
||||
|
||||
> **跨 skill 路由**:逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供
|
||||
|
||||
### 5. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿)
|
||||
|
||||
1. 当用户需要通过上传本地音视频文件来生成妙记时使用。
|
||||
2. 当用户说"把音视频文件转成纪要""把录音转成逐字稿/文字稿/撰写文字""把 mp4/mp3 转成总结/待办/章节"时,也先走这个入口。
|
||||
3. **处理流程**:
|
||||
- **上传音视频获取 `file_token`**:使用 [`lark-cli drive +upload`](../lark-drive/references/lark-drive-upload.md) 上传本地文件到云空间并获取 `file_token`。
|
||||
- **生成妙记**:获取到 `file_token` 后,调用 [`lark-cli minutes +upload`](references/lark-minutes-upload.md) 将文件转换为妙记并获取 `minute_url` 链接。
|
||||
- **继续获取纪要 / 逐字稿(按需)**:如果用户目标不是只要妙记链接,而是要纪要、逐字稿、总结、待办或章节,则从 `minute_url` 中提取 `minute_token`,再调用 [`lark-cli vc +notes --minute-tokens`](../lark-vc/references/lark-vc-notes.md) 获取对应产物。
|
||||
|
||||
> **注意**:必须先获取飞书云空间的 `file_token` 才能进行转换。
|
||||
>
|
||||
> **不要误走本地转写工具**:当用户目标是把本地音视频文件转成纪要、逐字稿、文字稿、撰写文字时,不要改用 `ffmpeg`、`whisper` 或其他本地 ASR/转码命令;标准路径就是 `drive +upload -> minutes +upload -> vc +notes --minute-tokens`。
|
||||
|
||||
## 资源关系
|
||||
|
||||
```text
|
||||
@@ -67,20 +82,22 @@ Minutes (妙记) ← minute_token 标识
|
||||
└── MediaFile (音频/视频文件) → minutes +download
|
||||
```
|
||||
|
||||
> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载音视频文件**。
|
||||
> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载音视频文件、上传音视频生成妙记**。
|
||||
>
|
||||
> **路由规则**:
|
||||
>
|
||||
> - 用户说"妙记列表 / 搜索妙记 / 某个关键词的妙记" → `minutes +search`
|
||||
> - 用户只是想看"我的妙记 / 某段时间内的妙记 / 妙记列表",不要先走 [lark-vc](../lark-vc/SKILL.md),而应直接使用本 skill
|
||||
> - 用户如果同时提到"会议 / 会 / 开会 / 某场会",即使也提到了"妙记",也应优先走 [lark-vc](../lark-vc/SKILL.md) 先定位会议,再通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`
|
||||
> - 用户如果要的是妙记基础信息,拿到 `minute_token` 后用 `minutes minutes get`;用户如果要的是逐字稿、总结、待办、章节,再走 `vc +notes --minute-tokens`
|
||||
> - 用户如果要的是妙记基础信息,拿到 `minute_token` 后用 `minutes minutes get`;用户如果要的是逐字稿、文字稿、撰写文字、总结、待办、章节,再走 `vc +notes --minute-tokens`
|
||||
> - “我的妙记”“参与的妙记”等自然语言映射细则,以 [minutes +search](references/lark-minutes-search.md) 为准
|
||||
> - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果
|
||||
> - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限
|
||||
> - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get`
|
||||
> - 用户说"下载这个妙记的视频 / 音频 / 媒体文件" → `minutes +download`
|
||||
> - 用户说"这个妙记的逐字稿 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)
|
||||
> - 用户说"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)
|
||||
> - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload`
|
||||
> - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens`
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
@@ -90,9 +107,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`
|
||||
| -------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range |
|
||||
| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute |
|
||||
| [`+upload`](references/lark-minutes-upload.md) | Upload a media file token to generate a minute |
|
||||
|
||||
- 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。
|
||||
- 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。
|
||||
- 使用 `+upload` 命令时,必须阅读 [references/lark-minutes-upload.md](references/lark-minutes-upload.md),了解生成参数和返回值结构。
|
||||
|
||||
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
|
||||
|
||||
|
||||
104
skills/lark-minutes/references/lark-minutes-upload.md
Normal file
104
skills/lark-minutes/references/lark-minutes-upload.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# minutes +upload
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
上传音视频文件到飞书妙记并生成妙记(Minute)。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli minutes +upload`。
|
||||
|
||||
## 典型触发表达
|
||||
|
||||
- "把这个音视频文件转成妙记"
|
||||
- "把这个音视频文件转成纪要"
|
||||
- "把这个音视频文件转成逐字稿、文字稿或撰写文字"
|
||||
- "把这个音视频文件转成总结、待办或章节"
|
||||
|
||||
## 完整工作流
|
||||
|
||||
当用户要求将音视频文件转换为妙记,或进一步要纪要/逐字稿/文字稿/撰写文字时,必须按照以下步骤执行:
|
||||
|
||||
1. **上传文件至云空间获取 file_token**
|
||||
- 使用 `lark-cli drive +upload` 命令上传本地文件到云空间(Drive):
|
||||
```bash
|
||||
lark-cli drive +upload --file <path/to/media/file>
|
||||
```
|
||||
- 从命令的返回结果中提取生成的 `file_token`。
|
||||
|
||||
2. **将 file_token 转换为妙记链接(minute_url)**
|
||||
- 调用本 shortcut,将获取到的 `file_token` 转换为妙记:
|
||||
```bash
|
||||
lark-cli minutes +upload --file-token <file_token>
|
||||
```
|
||||
- 命令执行成功后,将返回生成的妙记链接 `minute_url`。
|
||||
|
||||
3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,继续提取 `minute_token` 调用 `vc +notes`**
|
||||
- 从返回的 `minute_url` 中提取路径最后一段,得到 `minute_token`。
|
||||
- 如果用户要的是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,继续调用:
|
||||
```bash
|
||||
lark-cli vc +notes --minute-tokens <minute_token>
|
||||
```
|
||||
- `vc +notes --minute-tokens` 会返回纪要文档、逐字稿文档,以及 AI 内置产物(总结、待办、章节);必要时还会把逐字稿落地到本地文件。
|
||||
|
||||
> **异步生成提示**:API 会立即返回 `minute_url`,但妙记可能仍在异步生成中,您可以直接通过该妙记链接查看当前的处理状态和转写结果。
|
||||
|
||||
## 命令示例
|
||||
|
||||
```bash
|
||||
# 通过已上传到云空间的 file_token 生成妙记
|
||||
lark-cli minutes +upload --file-token boxcnxxxxxxxxxxxxxxxx
|
||||
|
||||
# 通过 minute_token 继续获取纪要 / 逐字稿 / 文字稿 / AI 产物
|
||||
lark-cli vc +notes --minute-tokens obcnxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file-token <token>` | 是 | 已经上传到飞书云空间的音视频文件的 file_token |
|
||||
|
||||
## 支持的格式与限制
|
||||
|
||||
待上传到妙记的原始音视频文件必须满足以下要求:
|
||||
|
||||
- 支持音频格式:`wav`、`mp3`、`m4a`、`aac`、`ogg`、`wma`、`amr`
|
||||
- 支持视频格式:`avi`、`wmv`、`mov`、`mp4`、`m4v`、`mpeg`、`ogg`、`flv`
|
||||
- 音视频时长不能超过 `6` 小时
|
||||
- 文件大小不能超过 `6 GB`
|
||||
|
||||
> 说明:本 shortcut 只接收 `file_token`,不会直接读取本地文件内容,因此这些格式、时长和大小限制对应的是**原始上传文件**本身。若妙记生成失败,请先回查源文件是否满足上述要求。
|
||||
|
||||
## 核心约束
|
||||
|
||||
### 1. 必须提供 file_token
|
||||
|
||||
本接口不直接处理本地文件的上传,必须先使用 `drive +upload` 将文件上传到云空间获取 `file_token`,然后再调用本接口。
|
||||
|
||||
### 2. 先上传,再生成妙记
|
||||
|
||||
推荐流程如下:
|
||||
|
||||
1. 使用 `lark-cli drive +upload --file <path>` 上传本地音视频文件到云空间
|
||||
2. 从返回结果中取出 `file_token`
|
||||
3. 调用 `lark-cli minutes +upload --file-token <file_token>` 生成妙记
|
||||
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,再从 `minute_url` 提取 `minute_token`,继续调用 `lark-cli vc +notes --minute-tokens <minute_token>`
|
||||
|
||||
> **边界说明**:`minutes +upload` 本身只负责把文件转成妙记并返回 `minute_url`。纪要内容、逐字稿、文字稿、撰写文字、总结、待办、章节属于后续产物获取,应由 [vc +notes](../../lark-vc/references/lark-vc-notes.md) 承接。
|
||||
|
||||
## 输出结果示例
|
||||
|
||||
```json
|
||||
{
|
||||
"minute_url": "http(s)://<host>/minutes/<minute-token>"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `minute_url` | 生成的妙记访问链接 |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
|
||||
- [drive +upload](../../lark-drive/references/lark-drive-upload.md) -- 上传文件到云空间
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -49,6 +49,7 @@ lark-cli docs +media-download --type whiteboard --token <whiteboard_token> --out
|
||||
> - `verbatim_doc_token` → **逐字稿**(完整的逐句文字记录,含说话人和时间戳)— 用户说"逐字稿""完整记录""谁说了什么"时用这个
|
||||
> - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_notes`(如有)
|
||||
> - 用户意图不明确时,应展示所有文档链接让用户选择,而不是替用户决定
|
||||
> - 如果用户提供的是**本地音视频文件**并说"转纪要""转逐字稿",不要直接从 `vc +notes` 开始;应先用 [minutes +upload](../lark-minutes/references/lark-minutes-upload.md) 生成 `minute_url`,再提取 `minute_token` 调用 `vc +notes --minute-tokens`
|
||||
|
||||
### 3. 纪要文档与逐字稿链接
|
||||
1. 纪要文档、逐字稿文档与关联的共享文档默认使用文档 Token 返回。
|
||||
@@ -90,6 +91,8 @@ Meeting (视频会议)
|
||||
>
|
||||
> **妙记边界**:`+notes` 负责纪要内容、逐字稿和 AI 产物;妙记基础信息请优先看 [`+recording`](references/lark-vc-recording.md) 与 [lark-minutes](../lark-minutes/SKILL.md)。
|
||||
>
|
||||
> **文件转纪要边界**:如果用户给的是本地音视频文件,并希望得到纪要、逐字稿、总结、待办或章节,入口应先走 [lark-minutes](../lark-minutes/SKILL.md) 的上传流程生成 `minute_url` / `minute_token`,再回到 `vc +notes --minute-tokens` 获取内容产物。
|
||||
>
|
||||
> **特殊情况**: 当用户查询“今天有哪些会议”时,通过 `vc +search` 查询今天开过的会议记录,同时使用 lark-calendar 技能查询今天还未开始的会议,统一整理后展示给用户。
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
# Drive CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 28 leaf commands
|
||||
- Covered: 1
|
||||
- Coverage: 3.6%
|
||||
- Denominator: 29 leaf commands
|
||||
- Covered: 2
|
||||
- Coverage: 6.9%
|
||||
|
||||
## Summary
|
||||
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
|
||||
- TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks.
|
||||
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
|
||||
- TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
- Blocked area: live upload, export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
|
||||
- Blocked area: live upload, live export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
|
||||
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` covers the wiki-target request shape for `drive +upload`, but there is still no live upload workflow coverage.
|
||||
|
||||
## Command Table
|
||||
@@ -20,10 +22,11 @@
|
||||
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
|
||||
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
| ✕ | drive +export | shortcut | | none | no export workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir` | dry-run only; no live export workflow yet |
|
||||
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
|
||||
| ✕ | drive +import | shortcut | | none | no import workflow yet |
|
||||
| ✕ | drive +move | shortcut | | none | no move workflow yet |
|
||||
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflow seeds via `+upload` and asserts all four buckets |
|
||||
| ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet |
|
||||
| ✕ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget (dry-run only) | `--wiki-token`; `parent_type=wiki`; `parent_node` | no live upload workflow yet |
|
||||
| ✕ | drive file.comment.replys create | api | | none | no reply workflow yet |
|
||||
|
||||
62
tests/cli_e2e/drive/drive_export_dryrun_test.go
Normal file
62
tests/cli_e2e/drive/drive_export_dryrun_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDriveExportDryRun_FileNameMetadata(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+export",
|
||||
"--token", "docxDryRunExport",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "custom-report",
|
||||
"--output-dir", "./exports",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Fatalf("url=%q, want export_tasks\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.token").String(); got != "docxDryRunExport" {
|
||||
t.Fatalf("body.token=%q, want docxDryRunExport\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.type").String(); got != "docx" {
|
||||
t.Fatalf("body.type=%q, want docx\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.file_extension").String(); got != "pdf" {
|
||||
t.Fatalf("body.file_extension=%q, want pdf\nstdout:\n%s", got, out)
|
||||
}
|
||||
if gjson.Get(out, "api.0.body.file_name").Exists() {
|
||||
t.Fatalf("file_name should stay local metadata, not export_tasks body\nstdout:\n%s", out)
|
||||
}
|
||||
if got := gjson.Get(out, "file_name").String(); got != "custom-report.pdf" {
|
||||
t.Fatalf("file_name=%q, want custom-report.pdf\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "output_dir").String(); got != "./exports" {
|
||||
t.Fatalf("output_dir=%q, want ./exports\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
173
tests/cli_e2e/drive/drive_pull_dryrun_test.go
Normal file
173
tests/cli_e2e/drive/drive_pull_dryrun_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_PullDryRun locks in the request shape the +pull shortcut emits
|
||||
// under --dry-run: the real CLI binary is invoked end-to-end, so flag
|
||||
// parsing, Validate (still runs in dry-run mode), and the dry-run renderer
|
||||
// all execute. The printed envelope is then inspected for GET method,
|
||||
// list-files URL, the folder_token parameter, and key phrases from Desc.
|
||||
//
|
||||
// Fake credentials are sufficient because --dry-run short-circuits before
|
||||
// any real network call.
|
||||
func TestDrive_PullDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
|
||||
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
desc := gjson.Get(out, "description").String()
|
||||
if !strings.Contains(desc, "list --folder-token") {
|
||||
t.Fatalf("description missing list phrase, got %q\nstdout:\n%s", desc, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_PullDryRunRejectsAbsoluteLocalDir confirms the path validator
|
||||
// runs in the real binary's Validate stage and surfaces a structured error
|
||||
// referencing --local-dir.
|
||||
func TestDrive_PullDryRunRejectsAbsoluteLocalDir(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "/etc",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: t.TempDir(),
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "--local-dir") {
|
||||
t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_PullDryRunRejectsDeleteLocalWithoutYes locks in the safety
|
||||
// guard: --delete-local without --yes must be refused upfront, even under
|
||||
// --dry-run, so an unintended delete flag never silently slides through.
|
||||
func TestDrive_PullDryRunRejectsDeleteLocalWithoutYes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--delete-local",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("--delete-local without --yes must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "--yes") {
|
||||
t.Fatalf("expected --yes hint in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_PullDryRunRejectsMissingFolderToken confirms cobra's
|
||||
// required-flag enforcement runs before our custom Validate.
|
||||
func TestDrive_PullDryRunRejectsMissingFolderToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "folder-token") {
|
||||
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
243
tests/cli_e2e/drive/drive_push_dryrun_test.go
Normal file
243
tests/cli_e2e/drive/drive_push_dryrun_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_PushDryRun locks in the request shape the +push shortcut emits
|
||||
// under --dry-run: the real CLI binary is invoked end-to-end, so flag
|
||||
// parsing, Validate (still runs in dry-run mode), and the dry-run renderer
|
||||
// all execute. The printed envelope is then inspected for GET method,
|
||||
// list-files URL, the folder_token parameter, and key phrases from Desc.
|
||||
//
|
||||
// Fake credentials are sufficient because --dry-run short-circuits before
|
||||
// any real network call.
|
||||
func TestDrive_PushDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
|
||||
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
desc := gjson.Get(out, "description").String()
|
||||
if !strings.Contains(desc, "list --folder-token") {
|
||||
t.Fatalf("description missing list phrase, got %q\nstdout:\n%s", desc, out)
|
||||
}
|
||||
if !strings.Contains(desc, "upload") {
|
||||
t.Fatalf("description missing upload phrase, got %q\nstdout:\n%s", desc, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_PushDryRunRejectsAbsoluteLocalDir confirms the path validator
|
||||
// runs in the real binary's Validate stage and surfaces a structured error
|
||||
// referencing --local-dir.
|
||||
func TestDrive_PushDryRunRejectsAbsoluteLocalDir(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "/etc",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: t.TempDir(),
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Validate-stage rejection emits ExitValidation (2). A regression
|
||||
// that reclassified this as a generic api_error (1) or success (0)
|
||||
// would slip through a loose `!= 0` check, so assert the exact code.
|
||||
if result.ExitCode != 2 {
|
||||
t.Fatalf("absolute --local-dir must be rejected with exit=2 (Validate), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "--local-dir") {
|
||||
t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes locks in the safety
|
||||
// guard: --delete-remote without --yes must be refused upfront, even
|
||||
// under --dry-run, so an unintended delete flag never silently slides
|
||||
// through.
|
||||
func TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--delete-remote",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Same exact-code reasoning as the absolute-path test: this is a
|
||||
// Validate-stage rejection so it must surface as ExitValidation (2).
|
||||
if result.ExitCode != 2 {
|
||||
t.Fatalf("--delete-remote without --yes must be rejected with exit=2 (Validate), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "--yes") {
|
||||
t.Fatalf("expected --yes hint in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_PushDryRunAcceptsDeleteRemoteWithYes is the symmetric guard
|
||||
// to TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes: when --yes is
|
||||
// passed alongside --delete-remote, Validate must accept the run and
|
||||
// hand off to the dry-run renderer.
|
||||
//
|
||||
// Specifically pins the conditional scope pre-check added to Validate:
|
||||
// when the resolver has no token / no scope metadata (the e2e setup
|
||||
// uses fake credentials with no real auth state), runtime.EnsureScopes
|
||||
// is a silent no-op so dry-run still emits its envelope. A regression
|
||||
// where the pre-check incorrectly fired against an empty scope list
|
||||
// would surface here as a non-zero exit and a missing_scope error.
|
||||
func TestDrive_PushDryRunAcceptsDeleteRemoteWithYes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
// No structured error envelope on stdout/stderr — the conditional
|
||||
// EnsureScopes call must not trip a missing_scope here.
|
||||
if strings.Contains(out, `"type": "missing_scope"`) || strings.Contains(result.Stderr, "missing_scope") {
|
||||
t.Fatalf("conditional scope pre-check fired in a no-credential env\nstdout:\n%s\nstderr:\n%s", out, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_PushDryRunRejectsMissingFolderToken confirms cobra's
|
||||
// required-flag enforcement runs before our custom Validate.
|
||||
func TestDrive_PushDryRunRejectsMissingFolderToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// This is a cobra-level required-flag check that fires BEFORE our
|
||||
// Validate callback, so the exit code is cobra's generic flag-error
|
||||
// (1) — distinct from ExitValidation (2). Asserting the exact code
|
||||
// pins which layer rejected the run, which matters because a
|
||||
// regression that pushed required-flag validation into our own
|
||||
// Validate (changing the exit class to 2) would silently slip
|
||||
// through a loose `!= 0` check.
|
||||
if result.ExitCode != 1 {
|
||||
t.Fatalf("missing --folder-token must be rejected with exit=1 (cobra required-flag), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "folder-token") {
|
||||
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
139
tests/cli_e2e/drive/drive_status_dryrun_test.go
Normal file
139
tests/cli_e2e/drive/drive_status_dryrun_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_StatusDryRun locks in the request shape the +status shortcut
|
||||
// emits under --dry-run: the real CLI binary is invoked end-to-end, so the
|
||||
// full flag-parsing, Validate (which still runs in dry-run mode), and the
|
||||
// dry-run renderer all execute. The printed envelope is then inspected to
|
||||
// confirm the GET method, list-files URL, and folder_token parameter, plus
|
||||
// the descriptive text from Desc.
|
||||
//
|
||||
// Fake credentials are sufficient because --dry-run short-circuits before
|
||||
// any network call.
|
||||
func TestDrive_StatusDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
// Validate runs even under --dry-run, so we need a real --local-dir
|
||||
// inside the working directory; create one in a temp tree.
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
|
||||
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
desc := gjson.Get(out, "description").String()
|
||||
if !strings.Contains(desc, "Walk --local-dir") || !strings.Contains(desc, "SHA-256") {
|
||||
t.Fatalf("description missing key phrases, got %q\nstdout:\n%s", desc, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_StatusDryRunRejectsAbsoluteLocalDir confirms that the
|
||||
// --local-dir path validator runs in the real binary's Validate stage and
|
||||
// surfaces a structured error referencing --local-dir (not the framework
|
||||
// default --file).
|
||||
func TestDrive_StatusDryRunRejectsAbsoluteLocalDir(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "/etc",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: t.TempDir(),
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "--local-dir") {
|
||||
t.Fatalf("expected --local-dir in error message, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_StatusDryRunRejectsMissingFolderToken confirms cobra's
|
||||
// required-flag enforcement runs before our custom Validate.
|
||||
func TestDrive_StatusDryRunRejectsMissingFolderToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "folder-token") {
|
||||
t.Fatalf("expected folder-token in error message, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
186
tests/cli_e2e/drive/drive_status_workflow_test.go
Normal file
186
tests/cli_e2e/drive/drive_status_workflow_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_StatusWorkflow exercises +status against a real Drive folder so
|
||||
// the parts that dry-run can't reach — recursive listing pagination, the
|
||||
// download+hash leg, scope handling, and the SHA-256 comparison itself —
|
||||
// are covered against the real backend.
|
||||
//
|
||||
// Layout:
|
||||
//
|
||||
// folder/ (--folder-token target)
|
||||
// ├── unchanged.txt "match" ↔ local: "match" → unchanged
|
||||
// ├── modified.txt "remote" ↔ local: "local" → modified
|
||||
// └── remote-only.txt "remote" ↔ (none) → new_remote
|
||||
// local/ (--local-dir target)
|
||||
// ├── unchanged.txt "match"
|
||||
// ├── modified.txt "local"
|
||||
// └── local-only.txt "anything" → new_local
|
||||
//
|
||||
// Expected output: each of the four buckets contains exactly the file we
|
||||
// expect, with file_token set for the three buckets that have a Drive side.
|
||||
func TestDrive_StatusWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderName := "lark-cli-e2e-drive-status-" + suffix
|
||||
folderToken := createDriveFolder(t, parentT, ctx, folderName, "")
|
||||
|
||||
// Local working directory. +status's --local-dir must be relative to
|
||||
// the binary's cwd, so each upload + the +status invocation share the
|
||||
// same WorkDir.
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local: %v", err)
|
||||
}
|
||||
|
||||
// Helper: write a local file under workDir/<rel>.
|
||||
writeLocal := func(rel, content string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(workDir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir parent of %s: %v", rel, err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: stage <content> into a sibling temp file then upload it as
|
||||
// <name> under folderToken. +upload reads --file relative to its cwd.
|
||||
uploadDriveFile := func(name, content string) string {
|
||||
t.Helper()
|
||||
// Stage outside `local/` so the local-side tree only sees what
|
||||
// the test wants; +upload still reads relative to workDir.
|
||||
stage := "_upload_" + name
|
||||
writeLocal(stage, content)
|
||||
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+upload",
|
||||
"--file", stage,
|
||||
"--folder-token", folderToken,
|
||||
"--name", name,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
|
||||
})
|
||||
return fileToken
|
||||
}
|
||||
|
||||
// Seed both sides. Order doesn't matter functionally, but doing the
|
||||
// uploads first lets the +status listing pick up everything in a
|
||||
// single pass.
|
||||
tokUnchanged := uploadDriveFile("unchanged.txt", "match")
|
||||
tokModified := uploadDriveFile("modified.txt", "remote")
|
||||
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote")
|
||||
|
||||
writeLocal("local/unchanged.txt", "match") // matches remote → unchanged
|
||||
writeLocal("local/modified.txt", "local") // differs → modified
|
||||
writeLocal("local/local-only.txt", "extra") // only here → new_local
|
||||
|
||||
// Run +status against the real folder.
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
// Assert each bucket contains exactly the file we expect, with the
|
||||
// correct file_token for sides that have one.
|
||||
out := result.Stdout
|
||||
|
||||
cases := []struct {
|
||||
bucket string
|
||||
path string
|
||||
token string // empty when the bucket has no Drive side
|
||||
}{
|
||||
{"unchanged", "unchanged.txt", tokUnchanged},
|
||||
{"modified", "modified.txt", tokModified},
|
||||
{"new_local", "local-only.txt", ""},
|
||||
{"new_remote", "remote-only.txt", tokRemoteOnly},
|
||||
}
|
||||
for _, c := range cases {
|
||||
bucket := gjson.Get(out, "data."+c.bucket)
|
||||
if !bucket.IsArray() {
|
||||
t.Fatalf("data.%s must be an array, stdout:\n%s", c.bucket, out)
|
||||
}
|
||||
var found bool
|
||||
bucket.ForEach(func(_, entry gjson.Result) bool {
|
||||
if entry.Get("rel_path").String() != c.path {
|
||||
return true // continue
|
||||
}
|
||||
found = true
|
||||
if c.token != "" {
|
||||
if got := entry.Get("file_token").String(); got != c.token {
|
||||
t.Errorf("%s entry %q: file_token=%q want %q", c.bucket, c.path, got, c.token)
|
||||
}
|
||||
} else if entry.Get("file_token").String() != "" {
|
||||
t.Errorf("%s entry %q must not carry file_token (local-only), stdout:\n%s", c.bucket, c.path, out)
|
||||
}
|
||||
return false // stop
|
||||
})
|
||||
if !found {
|
||||
t.Errorf("%s bucket missing %q\nstdout:\n%s", c.bucket, c.path, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure each bucket is exactly the size we expect (4 files total,
|
||||
// no double-bucketing). +upload may attach extra metadata (e.g. a
|
||||
// folder type entry for `local/` itself) but the lister filters
|
||||
// type=file so the buckets should be clean.
|
||||
for _, b := range []struct {
|
||||
bucket string
|
||||
want int
|
||||
}{
|
||||
{"unchanged", 1},
|
||||
{"modified", 1},
|
||||
{"new_local", 1},
|
||||
{"new_remote", 1},
|
||||
} {
|
||||
got := int(gjson.Get(out, "data."+b.bucket+".#").Int())
|
||||
if got != b.want {
|
||||
t.Errorf("data.%s length=%d want %d\nstdout:\n%s", b.bucket, got, b.want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user