mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ff2dc578e | ||
|
|
69ae326d01 | ||
|
|
e07842d3b5 | ||
|
|
a9c07cebb6 | ||
|
|
f6a31e0853 | ||
|
|
bd5a33c0b7 | ||
|
|
3242ca6f7f | ||
|
|
368ec7e753 | ||
|
|
9f81e7e567 | ||
|
|
a00dfad56a | ||
|
|
8c799d5a9f | ||
|
|
474cb30a48 | ||
|
|
e8e0c6fc5a | ||
|
|
b8f71d50d1 | ||
|
|
46468a900c | ||
|
|
f59f263138 | ||
|
|
51d07be18a | ||
|
|
344ff88701 | ||
|
|
78ff1e7968 | ||
|
|
fa16fe1976 | ||
|
|
d8b0865814 | ||
|
|
d026741532 | ||
|
|
cd7a2363e5 | ||
|
|
353c473e52 | ||
|
|
76fac115ed | ||
|
|
d2a834051d | ||
|
|
d30a9472c3 | ||
|
|
b8fa2b3f80 | ||
|
|
6ec19cbc84 | ||
|
|
d7363b0481 | ||
|
|
5f3915b25c | ||
|
|
4e65ea808e | ||
|
|
d7262b7dc5 | ||
|
|
c16a021ac6 | ||
|
|
fd9ee6afd6 |
49
CHANGELOG.md
49
CHANGELOG.md
@@ -2,6 +2,53 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.9] - 2026-04-11
|
||||
|
||||
### Features
|
||||
|
||||
- Add attendance `user_task.query` (#405)
|
||||
- Support minutes search (#359)
|
||||
- **slides**: Add slides `+create` shortcut with `--slides` one-step creation (#389)
|
||||
- **slides**: Return presentation URL in slides `+create` output (#425)
|
||||
- **sheets**: Add dimension shortcuts for row/column operations (#413)
|
||||
- **sheets**: Add cell operation shortcuts for merge, replace, and style (#412)
|
||||
- **drive**: Add drive folder delete shortcut with async task polling (#415)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Add guide for granting document permission to current bot (#414)
|
||||
|
||||
## [v1.0.8] - 2026-04-10
|
||||
|
||||
### Features
|
||||
|
||||
- Add `update` command with self-update, verification, and rollback (#391)
|
||||
- Add `--file` flag for multipart/form-data file uploads (#395)
|
||||
- Support file comment reply reactions (#380)
|
||||
- **base**: Add `+dashboard-arrange` command for auto-arranging dashboard blocks layout and `text` block type with Markdown support (#388)
|
||||
- **base**: Add record batch `+add` / `+set` shortcuts (#277)
|
||||
- **base**: Add `+record-search` for keyword-based record search (#328)
|
||||
- **base**: Add view visible fields `+get` / `+set` shortcuts (#326)
|
||||
- **base**: Add record field filters (#327)
|
||||
- **base**: Optimize workflow skills (#345)
|
||||
- **calendar**: Add room find workflow (#403)
|
||||
- **mail**: Add `--page-token` and `--page-size` to mail `+triage` (#301)
|
||||
- **whiteboard**: Add `+query` shortcut and enhance `+update` with Mermaid/PlantUML support (#382)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Improve error hints for sandbox and initialization issues (#384)
|
||||
- Fix markdown line breaks support (#338)
|
||||
- Return raw base field and view responses (#378)
|
||||
- **base**: Return raw table list response and clarify sort help (#393)
|
||||
- **calendar**: Add default video meeting to `+create` (#383)
|
||||
- **mail**: Replace `os.Exit` with graceful shutdown in mail watch (#350)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Document Base attachment download via docs `+media-download` (#404)
|
||||
- Reorganize lark-base skill guidance (#374)
|
||||
|
||||
## [v1.0.7] - 2026-04-09
|
||||
|
||||
### Features
|
||||
@@ -256,6 +303,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
|
||||
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8
|
||||
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7
|
||||
[v1.0.6]: https://github.com/larksuite/cli/releases/tag/v1.0.6
|
||||
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5
|
||||
|
||||
@@ -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, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 20 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, and more, with 200+ commands and 21 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** — 20 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 12 business domains, 200+ curated commands, 20 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 21 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 13 business domains, 200+ curated commands, 21 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
|
||||
@@ -30,6 +30,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📊 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 |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
@@ -136,6 +137,7 @@ lark-cli auth status
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `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 |
|
||||
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
|
||||
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 20 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 21 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 12 大业务域、200+ 精选命令、 20 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 21 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 13 大业务域、200+ 精选命令、21 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -30,6 +30,7 @@
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
@@ -137,6 +138,7 @@ lark-cli auth status
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
|
||||
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
|
||||
|
||||
@@ -41,6 +41,7 @@ type APIOptions struct {
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string
|
||||
}
|
||||
|
||||
var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`)
|
||||
@@ -87,6 +88,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
||||
|
||||
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) == 0 {
|
||||
@@ -105,20 +107,24 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
|
||||
}
|
||||
|
||||
// buildAPIRequest validates flags and builds a RawApiRequest.
|
||||
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
|
||||
// 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.
|
||||
// When dryRun is true and a file is provided, file reading is skipped and
|
||||
// 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
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
|
||||
// Validate --file mutual exclusions first.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
|
||||
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)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
}
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if opts.PageSize > 0 {
|
||||
params["page_size"] = opts.PageSize
|
||||
@@ -128,14 +134,53 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
|
||||
Method: opts.Method,
|
||||
URL: normalisePath(opts.Path),
|
||||
Params: params,
|
||||
Data: data,
|
||||
As: opts.As,
|
||||
}
|
||||
// WithFileDownload tells the SDK to skip CodeError parsing on 200 OK.
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
|
||||
if opts.File != "" {
|
||||
// File upload path: build formdata.
|
||||
fieldName, filePath, isStdin := cmdutil.ParseFileFlag(opts.File, "file")
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if _, ok := dataFields.(map[string]any); !ok {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
return request, &cmdutil.FileUploadMeta{
|
||||
FieldName: fieldName, FilePath: filePath, FormFields: dataFields,
|
||||
}, nil
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = fd
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
// Normal path: JSON body.
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = data
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
}
|
||||
}
|
||||
return request, nil
|
||||
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func apiRun(opts *APIOptions) error {
|
||||
@@ -153,7 +198,7 @@ func apiRun(opts *APIOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
request, err := buildAPIRequest(opts)
|
||||
request, fileMeta, err := buildAPIRequest(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,6 +209,9 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
}
|
||||
return apiDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
|
||||
@@ -5,6 +5,7 @@ package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -706,3 +707,98 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
t.Errorf("expected method POST (uppercased), got %s", gotOpts.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--file", "image=photo.jpg", "--data", `{"image_type":"message"}`})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.File != "image=photo.jpg" {
|
||||
t.Errorf("expected File = %q, got %q", "image=photo.jpg", gotOpts.File)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --file with --output")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected mutual exclusion error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileWithGET(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --file with GET")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires POST") {
|
||||
t.Errorf("expected method error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --file stdin with --data stdin")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot both read from stdin") {
|
||||
t.Errorf("expected stdin conflict error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := tmpDir + "/test.jpg"
|
||||
if err := os.WriteFile(tmpFile, []byte("fake-image"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,18 +134,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
// Expand --domain all to all available domains (from_meta projects + shortcut services)
|
||||
for _, d := range selectedDomains {
|
||||
if strings.EqualFold(d, "all") {
|
||||
domainSet := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
domainSet[p] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
domainSet[sc.Service] = true
|
||||
}
|
||||
selectedDomains = make([]string, 0, len(domainSet))
|
||||
for d := range domainSet {
|
||||
selectedDomains = append(selectedDomains, d)
|
||||
}
|
||||
sort.Strings(selectedDomains)
|
||||
selectedDomains = sortedKnownDomains()
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -451,6 +440,8 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
|
||||
|
||||
// collectScopesForDomains collects API scopes (from from_meta projects) and
|
||||
// shortcut scopes for the given domain names.
|
||||
// Domains with auth_domain children are automatically expanded to include
|
||||
// their children's scopes.
|
||||
func collectScopesForDomains(domains []string, identity string) []string {
|
||||
scopeSet := make(map[string]bool)
|
||||
|
||||
@@ -459,11 +450,16 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
|
||||
// 2. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
// 2. Expand domains: include auth_domain children
|
||||
domainSet := make(map[string]bool, len(domains))
|
||||
for _, d := range domains {
|
||||
domainSet[d] = true
|
||||
for _, child := range registry.GetAuthChildren(d) {
|
||||
domainSet[child] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.ScopesForIdentity(identity) {
|
||||
@@ -472,7 +468,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Deduplicate and sort
|
||||
// 4. Deduplicate and sort
|
||||
result := make([]string, 0, len(scopeSet))
|
||||
for s := range scopeSet {
|
||||
result = append(result, s)
|
||||
@@ -481,14 +477,20 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// allKnownDomains returns all valid domain names (from_meta projects + shortcut services).
|
||||
// allKnownDomains returns all valid auth domain names (from_meta projects +
|
||||
// shortcut services), excluding domains that have auth_domain set (they are
|
||||
// folded into their parent domain).
|
||||
func allKnownDomains() map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
domains[p] = true
|
||||
if !registry.HasAuthDomain(p) {
|
||||
domains[p] = true
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
domains[sc.Service] = true
|
||||
if !registry.HasAuthDomain(sc.Service) {
|
||||
domains[sc.Service] = true
|
||||
}
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
@@ -34,8 +34,12 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
seen := make(map[string]bool)
|
||||
var domains []domainMeta
|
||||
|
||||
// 1. Domains from from_meta projects
|
||||
// 1. Domains from from_meta projects (skip domains with auth_domain)
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
if registry.HasAuthDomain(project) {
|
||||
seen[project] = true
|
||||
continue
|
||||
}
|
||||
dm := buildDomainMeta(project, lang)
|
||||
domains = append(domains, dm)
|
||||
seen[project] = true
|
||||
@@ -52,13 +56,14 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
}
|
||||
|
||||
// 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains
|
||||
// (skip domains with auth_domain — they are folded into their parent)
|
||||
shortcutOnlySet := make(map[string]bool)
|
||||
for _, n := range shortcutOnlyNames {
|
||||
shortcutOnlySet[n] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !seen[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
|
||||
dm := buildDomainMeta(sc.Service, lang)
|
||||
domains = append(domains, dm)
|
||||
}
|
||||
|
||||
@@ -903,3 +903,37 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := allKnownDomains()
|
||||
if domains["whiteboard"] {
|
||||
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
|
||||
}
|
||||
if !domains["docs"] {
|
||||
t.Error("docs should still be a known auth domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"docs"}, "user")
|
||||
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
|
||||
found := false
|
||||
for _, s := range scopes {
|
||||
if strings.HasPrefix(s, "board:whiteboard:") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("collectScopesForDomains([docs]) should include whiteboard scopes (board:whiteboard:*)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
if dm.Name == "whiteboard" {
|
||||
t.Error("whiteboard should not appear in interactive domain list (has auth_domain=docs)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
203
cmd/diagnose_scope_test.go
Normal file
203
cmd/diagnose_scope_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
shortcutTypes "github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ── Data types ────────────────────────────────────────────────────────
|
||||
|
||||
type diagMethodEntry struct {
|
||||
Domain string `json:"domain"`
|
||||
Type string `json:"type"` // "api" or "shortcut"
|
||||
Method string `json:"method"` // "calendar.calendars.search" or "+agenda"
|
||||
Scope string `json:"scope"` // minimum-privilege scope
|
||||
Identity []string `json:"identity"` // ["user"], ["bot"], or ["user","bot"]
|
||||
}
|
||||
|
||||
type diagScopeInfo struct {
|
||||
Scope string `json:"scope"`
|
||||
Recommend bool `json:"recommend"`
|
||||
InPriority bool `json:"in_priority"`
|
||||
}
|
||||
|
||||
type diagOutput struct {
|
||||
Methods []diagMethodEntry `json:"methods"`
|
||||
Scopes []diagScopeInfo `json:"scopes"`
|
||||
}
|
||||
|
||||
// ── Core logic ────────────────────────────────────────────────────────
|
||||
|
||||
// diagAllKnownDomains returns sorted, deduplicated domain names from both
|
||||
// from_meta projects and shortcuts.
|
||||
func diagAllKnownDomains() []string {
|
||||
seen := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
seen[p] = true
|
||||
}
|
||||
for _, s := range shortcuts.AllShortcuts() {
|
||||
if s.Service != "" {
|
||||
seen[s.Service] = true
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
for d := range seen {
|
||||
result = append(result, d)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// methodKey uniquely identifies a method+scope pair for merging identities.
|
||||
type methodKey struct {
|
||||
domain string
|
||||
typ string
|
||||
method string
|
||||
scope string
|
||||
}
|
||||
|
||||
// diagBuild builds the full output: flat methods list (merged identities) + scopes.
|
||||
func diagBuild(domains []string) diagOutput {
|
||||
recommend := registry.LoadAutoApproveSet()
|
||||
identities := []string{"user", "bot"}
|
||||
|
||||
merged := make(map[methodKey]*diagMethodEntry)
|
||||
allSC := shortcuts.AllShortcuts()
|
||||
|
||||
for _, domain := range domains {
|
||||
for _, identity := range identities {
|
||||
for _, ce := range registry.CollectCommandScopes([]string{domain}, identity) {
|
||||
for _, scope := range ce.Scopes {
|
||||
method := domain + "." + strings.ReplaceAll(ce.Command, " ", ".")
|
||||
k := methodKey{domain, "api", method, scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
} else {
|
||||
merged[k] = &diagMethodEntry{
|
||||
Domain: domain, Type: "api",
|
||||
Method: method,
|
||||
Scope: scope, Identity: []string{identity},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, sc := range allSC {
|
||||
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
|
||||
continue
|
||||
}
|
||||
for _, scope := range sc.ScopesForIdentity(identity) {
|
||||
k := methodKey{domain, "shortcut", sc.Command, scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
} else {
|
||||
merged[k] = &diagMethodEntry{
|
||||
Domain: domain, Type: "shortcut",
|
||||
Method: sc.Command,
|
||||
Scope: scope, Identity: []string{identity},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
methods := make([]diagMethodEntry, 0, len(merged))
|
||||
scopeSet := make(map[string]bool)
|
||||
for _, e := range merged {
|
||||
methods = append(methods, *e)
|
||||
scopeSet[e.Scope] = true
|
||||
}
|
||||
sort.Slice(methods, func(i, j int) bool {
|
||||
if methods[i].Domain != methods[j].Domain {
|
||||
return methods[i].Domain < methods[j].Domain
|
||||
}
|
||||
if methods[i].Type != methods[j].Type {
|
||||
return methods[i].Type < methods[j].Type
|
||||
}
|
||||
if methods[i].Method != methods[j].Method {
|
||||
return methods[i].Method < methods[j].Method
|
||||
}
|
||||
return methods[i].Scope < methods[j].Scope
|
||||
})
|
||||
|
||||
scopeList := make([]string, 0, len(scopeSet))
|
||||
for s := range scopeSet {
|
||||
scopeList = append(scopeList, s)
|
||||
}
|
||||
sort.Strings(scopeList)
|
||||
|
||||
priorities := registry.LoadScopePriorities()
|
||||
scopes := make([]diagScopeInfo, len(scopeList))
|
||||
for i, s := range scopeList {
|
||||
_, inPri := priorities[s]
|
||||
scopes[i] = diagScopeInfo{Scope: s, Recommend: recommend[s], InPriority: inPri}
|
||||
}
|
||||
|
||||
return diagOutput{Methods: methods, Scopes: scopes}
|
||||
}
|
||||
|
||||
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
|
||||
if len(sc.AuthTypes) == 0 {
|
||||
return identity == "user"
|
||||
}
|
||||
for _, a := range sc.AuthTypes {
|
||||
if a == identity {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func appendUniq(ss []string, s string) []string {
|
||||
for _, existing := range ss {
|
||||
if existing == s {
|
||||
return ss
|
||||
}
|
||||
}
|
||||
return append(ss, s)
|
||||
}
|
||||
|
||||
// ── Snapshot generation ───────────────────────────────────────────────
|
||||
//
|
||||
// Generates a JSON snapshot of all API methods and shortcuts with their
|
||||
// minimum-privilege scopes. Consumed by scripts/scope_audit.py.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// SCOPE_SNAPSHOT_DIR=/tmp/scope-audit go test ./cmd/ -run TestScopeSnapshot -v
|
||||
func TestScopeSnapshot(t *testing.T) {
|
||||
dir := os.Getenv("SCOPE_SNAPSHOT_DIR")
|
||||
if dir == "" {
|
||||
t.Skip("set SCOPE_SNAPSHOT_DIR to enable snapshot generation")
|
||||
}
|
||||
|
||||
registry.Init()
|
||||
result := diagBuild(diagAllKnownDomains())
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "snapshot.json")
|
||||
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Wrote %s (%d methods, %d scopes)", path, len(result.Methods), len(result.Scopes))
|
||||
}
|
||||
@@ -238,7 +238,7 @@ func checkCLIUpdate() []checkResult {
|
||||
if update.IsNewer(latest, current) {
|
||||
return []checkResult{warn("cli_update",
|
||||
fmt.Sprintf("%s → %s available", current, latest),
|
||||
"run: npm update -g @larksuite/cli")}
|
||||
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
|
||||
}
|
||||
return []checkResult{pass("cli_update", latest+" (up to date)")}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -118,6 +119,7 @@ func Execute() int {
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
|
||||
|
||||
@@ -73,6 +73,12 @@ func printResourceList(w io.Writer, spec map[string]interface{}) {
|
||||
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
|
||||
}
|
||||
|
||||
// hasFileFields returns true if any requestBody field has type "file".
|
||||
func hasFileFields(method map[string]interface{}) (bool, []string) {
|
||||
names := cmdutil.DetectFileFields(method)
|
||||
return len(names) > 0, names
|
||||
}
|
||||
|
||||
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
@@ -80,6 +86,7 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
|
||||
fullPath := servicePath + "/" + methodPath
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
isFileUpload, fileFieldNames := hasFileFields(method)
|
||||
|
||||
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
|
||||
|
||||
@@ -138,11 +145,25 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
|
||||
if len(params) == 0 {
|
||||
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %s--data%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fileUploadTag := ""
|
||||
if isFileUpload {
|
||||
fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %s--data%s <json> %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag)
|
||||
requestBody, _ := method["requestBody"].(map[string]interface{})
|
||||
if len(requestBody) > 0 {
|
||||
printNestedFields(w, requestBody, " ", "")
|
||||
}
|
||||
|
||||
if isFileUpload {
|
||||
if len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0])
|
||||
} else {
|
||||
fmt.Fprintf(w, "\n %s--file%s <field=path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", "))
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
@@ -184,7 +205,13 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
|
||||
}
|
||||
|
||||
// CLI example
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
if isFileUpload && len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else if isFileUpload {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <field=path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
}
|
||||
|
||||
// Docs
|
||||
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -61,3 +62,123 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
|
||||
t.Errorf("expected 'Unknown service' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_FileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"description": "Upload an image",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image_type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "images", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "file upload") {
|
||||
t.Errorf("expected 'file upload' marker in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file") {
|
||||
t.Errorf("expected '--file' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"image"`) {
|
||||
t.Errorf("expected default field name 'image' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file <path>") {
|
||||
t.Errorf("expected CLI example with --file <path>, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_NoFileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "calendar",
|
||||
"servicePath": "/open-apis/calendar/v4",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "events",
|
||||
"httpMethod": "POST",
|
||||
"description": "Create an event",
|
||||
"requestBody": map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "events", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if strings.Contains(out, "file upload") {
|
||||
t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "--file") {
|
||||
t.Errorf("did not expect '--file' for non-file method, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFileFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method map[string]interface{}
|
||||
wantBool bool
|
||||
wantFields []string
|
||||
}{
|
||||
{
|
||||
name: "has file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{"type": "file"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: true,
|
||||
wantFields: []string{"image"},
|
||||
},
|
||||
{
|
||||
name: "no file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
{
|
||||
name: "no requestBody",
|
||||
method: map[string]interface{}{},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, names := hasFileFields(tt.method)
|
||||
if got != tt.wantBool {
|
||||
t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool)
|
||||
}
|
||||
if tt.wantFields == nil && names != nil {
|
||||
t.Errorf("expected nil names, got %v", names)
|
||||
}
|
||||
if tt.wantFields != nil && len(names) != len(tt.wantFields) {
|
||||
t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,13 @@ type ServiceMethodOptions struct {
|
||||
Format string
|
||||
JqExpr string
|
||||
DryRun bool
|
||||
File string // --file flag value
|
||||
FileFields []string // auto-detected file field names from metadata
|
||||
}
|
||||
|
||||
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
|
||||
func detectFileFields(method map[string]interface{}) []string {
|
||||
return cmdutil.DetectFileFields(method)
|
||||
}
|
||||
|
||||
func registerMethod(parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
@@ -161,6 +168,16 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
|
||||
// Conditionally register --file for methods with file-type fields.
|
||||
fileFields := detectFileFields(method)
|
||||
opts.FileFields = fileFields
|
||||
if len(fileFields) > 0 {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
|
||||
}
|
||||
}
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
@@ -212,12 +229,15 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
request, err := buildServiceRequest(opts)
|
||||
request, fileMeta, err := buildServiceRequest(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
}
|
||||
return serviceDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
|
||||
@@ -303,7 +323,9 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
}
|
||||
|
||||
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
|
||||
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, error) {
|
||||
// When dryRun is true and a file is provided, file reading is skipped and
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
spec := opts.Spec
|
||||
method := opts.Method
|
||||
schemaPath := opts.SchemaPath
|
||||
@@ -312,12 +334,17 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
// 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
|
||||
|
||||
// Validate --file mutual exclusions.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
|
||||
@@ -330,13 +357,13 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
}
|
||||
val, ok := params[name]
|
||||
if !ok || util.IsEmptyValue(val) {
|
||||
return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("missing required path parameter: %s", name),
|
||||
fmt.Sprintf("lark-cli schema %s", schemaPath))
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if err := validate.ResourceName(valStr, name); err != nil {
|
||||
return client.RawApiRequest{}, output.ErrValidation("%s", err)
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("%s", err)
|
||||
}
|
||||
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
|
||||
delete(params, name)
|
||||
@@ -352,7 +379,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
required, _ := p["required"].(bool)
|
||||
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
|
||||
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
|
||||
return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("missing required query parameter: %s", name),
|
||||
fmt.Sprintf("lark-cli schema %s", schemaPath))
|
||||
}
|
||||
@@ -366,22 +393,60 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
|
||||
}
|
||||
}
|
||||
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, err
|
||||
}
|
||||
|
||||
request := client.RawApiRequest{
|
||||
Method: httpMethod,
|
||||
URL: url,
|
||||
Params: queryParams,
|
||||
Data: data,
|
||||
As: opts.As,
|
||||
}
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
|
||||
if opts.File != "" {
|
||||
// File upload: determine default field name from metadata.
|
||||
defaultField := "file"
|
||||
if len(opts.FileFields) == 1 {
|
||||
defaultField = opts.FileFields[0]
|
||||
}
|
||||
fieldName, filePath, isStdin := cmdutil.ParseFileFlag(opts.File, defaultField)
|
||||
|
||||
// Parse --data as form fields.
|
||||
var dataFields any
|
||||
if opts.Data != "" {
|
||||
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if _, ok := dataFields.(map[string]any); !ok {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
return request, &cmdutil.FileUploadMeta{
|
||||
FieldName: fieldName, FilePath: filePath, FormFields: dataFields,
|
||||
}, nil
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = fd
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
request.Data = data
|
||||
if opts.Output != "" {
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
|
||||
}
|
||||
}
|
||||
return request, nil
|
||||
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -710,6 +711,144 @@ func TestScopeAwareChecker_ScopeError_BotMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── file upload ──
|
||||
|
||||
func imImageMethod() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image_type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
}
|
||||
}
|
||||
|
||||
func imSpec() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagRegistered(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --file flag to be registered for file upload method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagNotRegistered(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag != nil {
|
||||
t.Fatal("expected --file flag NOT to be registered for non-file method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) {
|
||||
getMethod := map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "GET",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
},
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag != nil {
|
||||
t.Fatal("expected --file flag NOT to be registered for GET method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := tmpDir + "/test.jpg"
|
||||
if err := os.WriteFile(tmpFile, []byte("fake-image"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--file", "image=" + tmpFile,
|
||||
"--data", `{"image_type":"message"}`,
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFileFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method map[string]interface{}
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "single file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{"type": "file"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
want: []string{"image"},
|
||||
},
|
||||
{
|
||||
name: "no file fields",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "no requestBody",
|
||||
method: map[string]interface{}{},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectFileFields(tt.method)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("detectFileFields()[%d] = %q, want %q", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
func isExitError(err error, target **output.ExitError) bool {
|
||||
|
||||
314
cmd/update/update.go
Normal file
314
cmd/update/update.go
Normal file
@@ -0,0 +1,314 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
const (
|
||||
repoURL = "https://github.com/larksuite/cli"
|
||||
maxNpmOutput = 2000
|
||||
osWindows = "windows"
|
||||
)
|
||||
|
||||
// Overridable for testing.
|
||||
var (
|
||||
fetchLatest = func() (string, error) { return update.FetchLatest() }
|
||||
currentVersion = func() string { return build.Version }
|
||||
currentOS = runtime.GOOS
|
||||
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
|
||||
)
|
||||
|
||||
func isWindows() bool { return currentOS == osWindows }
|
||||
|
||||
func releaseURL(version string) string {
|
||||
return repoURL + "/releases/tag/v" + strings.TrimPrefix(version, "v")
|
||||
}
|
||||
|
||||
func changelogURL() string { return repoURL + "/blob/main/CHANGELOG.md" }
|
||||
|
||||
// --- Terminal symbols (ASCII fallback on Windows) ---
|
||||
|
||||
func symOK() string {
|
||||
if isWindows() {
|
||||
return "[OK]"
|
||||
}
|
||||
return "✓"
|
||||
}
|
||||
|
||||
func symFail() string {
|
||||
if isWindows() {
|
||||
return "[FAIL]"
|
||||
}
|
||||
return "✗"
|
||||
}
|
||||
|
||||
func symWarn() string {
|
||||
if isWindows() {
|
||||
return "[WARN]"
|
||||
}
|
||||
return "⚠"
|
||||
}
|
||||
|
||||
func symArrow() string {
|
||||
if isWindows() {
|
||||
return "->"
|
||||
}
|
||||
return "→"
|
||||
}
|
||||
|
||||
// --- Command ---
|
||||
|
||||
// UpdateOptions holds inputs for the update command.
|
||||
type UpdateOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
}
|
||||
|
||||
// NewCmdUpdate creates the update command.
|
||||
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &UpdateOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Update lark-cli to the latest version",
|
||||
Long: `Update lark-cli to the latest version.
|
||||
|
||||
Detects the installation method automatically:
|
||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||
- manual/other: shows GitHub Releases download URL
|
||||
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
Use --check to only check for updates without installing.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return updateRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
|
||||
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func updateRun(opts *UpdateOptions) error {
|
||||
io := opts.Factory.IOStreams
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
updater.CleanupStaleFiles()
|
||||
output.PendingNotice = nil
|
||||
|
||||
// 1. Fetch latest version
|
||||
latest, err := fetchLatest()
|
||||
if err != nil {
|
||||
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
|
||||
}
|
||||
|
||||
// 2. Validate version format
|
||||
if update.ParseVersion(latest) == nil {
|
||||
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
|
||||
}
|
||||
|
||||
// 3. Compare versions
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
"latest_version": latest, "action": "already_up_to_date",
|
||||
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "%s lark-cli %s is already up to date\n", symOK(), cur)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. Detect installation method
|
||||
detect := updater.DetectInstallMethod()
|
||||
|
||||
// 5. --check
|
||||
if opts.Check {
|
||||
return reportCheckResult(opts, io, cur, latest, detect.CanAutoUpdate())
|
||||
}
|
||||
|
||||
// 6. Execute update
|
||||
if !detect.CanAutoUpdate() {
|
||||
return doManualUpdate(opts, io, cur, latest, detect)
|
||||
}
|
||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
||||
}
|
||||
|
||||
// --- Output helpers ---
|
||||
|
||||
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
|
||||
})
|
||||
return output.ErrBare(exitCode)
|
||||
}
|
||||
return output.Errorf(exitCode, errType, "%s", msg)
|
||||
}
|
||||
|
||||
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
"latest_version": latest, "action": "update_available",
|
||||
"auto_update": canAutoUpdate,
|
||||
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "Update available: %s %s %s\n", cur, symArrow(), latest)
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
if canAutoUpdate {
|
||||
fmt.Fprintf(io.ErrOut, "\nRun `lark-cli update` to install.\n")
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "\nDownload the release above to update manually.\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult) error {
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "latest_version": latest,
|
||||
"action": "manual_required",
|
||||
"message": fmt.Sprintf("Automatic update unavailable: %s (path: %s)", reason, detect.ResolvedPath),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "Automatic update unavailable: %s (path: %s).\n\n", reason, detect.ResolvedPath)
|
||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
|
||||
fmt.Fprintf(io.ErrOut, "\nAfter updating, also update skills:\n npx -y skills add larksuite/cli -g -y\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
||||
restore, err := updater.PrepareSelfReplace()
|
||||
if err != nil {
|
||||
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
|
||||
}
|
||||
|
||||
if !opts.JSON {
|
||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
||||
}
|
||||
|
||||
npmResult := updater.RunNpmInstall(latest)
|
||||
if npmResult.Err != nil {
|
||||
restore()
|
||||
combined := npmResult.CombinedOutput()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false, "error": map[string]interface{}{
|
||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||
"hint": permissionHint(combined),
|
||||
},
|
||||
})
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
if npmResult.Stdout.Len() > 0 {
|
||||
fmt.Fprint(io.ErrOut, npmResult.Stdout.String())
|
||||
}
|
||||
if npmResult.Stderr.Len() > 0 {
|
||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||
if hint := permissionHint(combined); hint != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||
}
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
// Verify the new binary is functional before proceeding.
|
||||
// If corrupt, restore the previous version from .old.
|
||||
if err := updater.VerifyBinary(latest); err != nil {
|
||||
restore()
|
||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||
hint := verificationFailureHint(updater, latest)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": map[string]interface{}{"type": "update_error", "message": msg, "hint": hint},
|
||||
})
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "\n%s %s\n", symFail(), msg)
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
// Skills update (best-effort).
|
||||
skillsResult := updater.RunSkillsUpdate()
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": latest,
|
||||
"latest_version": latest, "action": "updated",
|
||||
"message": fmt.Sprintf("lark-cli updated from %s to %s", cur, latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
}
|
||||
if skillsResult.Err != nil {
|
||||
result["skills_warning"] = fmt.Sprintf("skills update failed: %s", skillsResult.Err)
|
||||
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
|
||||
result["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
|
||||
}
|
||||
}
|
||||
output.PrintJson(io.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
||||
if skillsResult.Err != nil {
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %s\n", symWarn(), skillsResult.Err)
|
||||
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, 500))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func permissionHint(npmOutput string) string {
|
||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
||||
if updater.CanRestorePreviousVersion() {
|
||||
return "the previous version has been restored"
|
||||
}
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually: npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
851
cmd/update/update_test.go
Normal file
851
cmd/update/update_test.go
Normal file
@@ -0,0 +1,851 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
)
|
||||
|
||||
// newTestFactory creates a test factory with minimal config.
|
||||
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{})
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
|
||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
|
||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
|
||||
npmFn func(string) *selfupdate.NpmResult,
|
||||
skillsFn func() *selfupdate.NpmResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.NpmInstallOverride = npmFn
|
||||
u.SkillsUpdateOverride = skillsFn
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "already_up_to_date"`) {
|
||||
t.Errorf("expected already_up_to_date in JSON output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"ok": true`) {
|
||||
t.Errorf("expected ok:true in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "already up to date") {
|
||||
t.Errorf("expected 'already up to date' in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateManual_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "manual_required"`) {
|
||||
t.Errorf("expected manual_required in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "not installed via npm") {
|
||||
t.Errorf("expected accurate reason in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "releases/tag/v2.0.0") {
|
||||
t.Errorf("expected version-pinned URL in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateManual_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "not installed via npm") {
|
||||
t.Errorf("expected 'not installed via npm' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "releases/tag/v2.0.0") {
|
||||
t.Errorf("expected version-pinned URL in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected success message in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--force", "--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFetchError_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "", errors.New("network timeout") }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
err := cmd.Execute()
|
||||
// cobra silences errors when RunE returns; we just check stdout
|
||||
_ = err
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"ok": false`) {
|
||||
t.Errorf("expected ok:false in JSON output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "network timeout") {
|
||||
t.Errorf("expected 'network timeout' in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFetchError_Human(t *testing.T) {
|
||||
f, _, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "", errors.New("network timeout") }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
// Suppress cobra's default error printing.
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInvalidVersion_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "not-a-version", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
|
||||
_ = cmd.Execute()
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "invalid version") {
|
||||
t.Errorf("expected 'invalid version' in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "1.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "DEV" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmFail_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
fmt.Fprint(&r.Stderr, "EACCES: permission denied")
|
||||
r.Err = errors.New("npm install failed")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
_ = cmd.Execute()
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "permission denied") {
|
||||
t.Errorf("expected 'permission denied' in JSON output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"hint"`) {
|
||||
t.Errorf("expected 'hint' field in JSON output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmFail_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
fmt.Fprint(&r.Stderr, "EACCES: permission denied")
|
||||
r.Err = errors.New("npm install failed")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
_ = cmd.Execute()
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Update failed") {
|
||||
t.Errorf("expected 'Update failed' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Permission denied") {
|
||||
t.Errorf("expected permission hint in stderr, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||
u.RestoreAvailableOverride = func() bool { return false }
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
t.Fatal("skills update should not run when binary verification fails")
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected verification failure")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "automatic rollback is unavailable") {
|
||||
t.Errorf("expected unavailable rollback hint, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "previous version has been restored") {
|
||||
t.Errorf("should not claim restore when no backup is available, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "npm install -g @larksuite/cli@2.0.0") {
|
||||
t.Errorf("expected manual reinstall command in hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheck_JSON_Npm(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json", "--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "update_available"`) {
|
||||
t.Errorf("expected update_available action, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"auto_update": true`) {
|
||||
t.Errorf("expected auto_update:true for npm, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "releases/tag/v2.0.0") {
|
||||
t.Errorf("expected version-pinned release URL, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "CHANGELOG") {
|
||||
t.Errorf("expected changelog URL, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheck_Human_Npm(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Update available") {
|
||||
t.Errorf("expected 'Update available' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "lark-cli update") {
|
||||
t.Errorf("expected 'lark-cli update' instruction for npm, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheck_Human_Manual(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "Update available") {
|
||||
t.Errorf("expected 'Update available' in stderr, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "manually") {
|
||||
t.Errorf("expected manual download instruction for non-npm, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "lark-cli update` to install") {
|
||||
t.Errorf("should NOT suggest 'lark-cli update' for manual install, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpmNotFound_FallsBackToManual(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
// npm detected (node_modules in path) but npm binary not available
|
||||
mockDetect(t, selfupdate.DetectResult{
|
||||
Method: selfupdate.InstallNpm,
|
||||
ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli",
|
||||
NpmAvailable: false,
|
||||
})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "manual_required"`) {
|
||||
t.Errorf("expected manual_required when npm not found, got: %s", out)
|
||||
}
|
||||
// Must say "npm is not available", not generic "not installed via npm"
|
||||
if !strings.Contains(out, "npm is not available") {
|
||||
t.Errorf("expected 'npm is not available' reason when npm detected but missing, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseURL(t *testing.T) {
|
||||
got := releaseURL("2.0.0")
|
||||
if got != "https://github.com/larksuite/cli/releases/tag/v2.0.0" {
|
||||
t.Errorf("expected version-pinned URL, got: %s", got)
|
||||
}
|
||||
got2 := releaseURL("v1.5.0")
|
||||
if got2 != "https://github.com/larksuite/cli/releases/tag/v1.5.0" {
|
||||
t.Errorf("expected no double v prefix, got: %s", got2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionHint(t *testing.T) {
|
||||
origOS := currentOS
|
||||
defer func() { currentOS = origOS }()
|
||||
|
||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
||||
currentOS = "linux"
|
||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
||||
if !strings.Contains(hint, "npm global prefix") {
|
||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||
}
|
||||
if strings.Contains(hint, "sudo npm install -g") {
|
||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||
}
|
||||
|
||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||
currentOS = "windows"
|
||||
hint = permissionHint("EACCES: permission denied")
|
||||
if hint != "" {
|
||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||
}
|
||||
|
||||
// Non-EACCES error: always empty.
|
||||
currentOS = "linux"
|
||||
if got := permissionHint("some other error"); got != "" {
|
||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
// With the rename trick, Windows npm installs can now auto-update.
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
origOS := currentOS
|
||||
currentOS = osWindows
|
||||
defer func() { currentOS = origOS }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected updated on Windows with rename trick, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWindows_Check_JSON(t *testing.T) {
|
||||
// --check on Windows npm should report auto_update: true (rename trick available).
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json", "--check"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
origOS := currentOS
|
||||
currentOS = osWindows
|
||||
defer func() { currentOS = origOS }()
|
||||
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"auto_update": true`) {
|
||||
t.Errorf("expected auto_update:true on Windows (rename trick), got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWindows_Symbols(t *testing.T) {
|
||||
origOS := currentOS
|
||||
defer func() { currentOS = origOS }()
|
||||
|
||||
currentOS = "windows"
|
||||
if symOK() != "[OK]" {
|
||||
t.Errorf("expected [OK] on Windows, got: %s", symOK())
|
||||
}
|
||||
if symFail() != "[FAIL]" {
|
||||
t.Errorf("expected [FAIL] on Windows, got: %s", symFail())
|
||||
}
|
||||
if symWarn() != "[WARN]" {
|
||||
t.Errorf("expected [WARN] on Windows, got: %s", symWarn())
|
||||
}
|
||||
if symArrow() != "->" {
|
||||
t.Errorf("expected -> on Windows, got: %s", symArrow())
|
||||
}
|
||||
|
||||
currentOS = "darwin"
|
||||
if symOK() != "\u2713" {
|
||||
t.Errorf("expected \u2713 on darwin, got: %s", symOK())
|
||||
}
|
||||
if symArrow() != "\u2192" {
|
||||
t.Errorf("expected \u2192 on darwin, got: %s", symArrow())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Should NOT have skills_warning when skills succeed
|
||||
if strings.Contains(out, "skills_warning") {
|
||||
t.Errorf("expected no skills_warning on success, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
// Skills update fails
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// CLI update should still succeed (ok:true)
|
||||
if !strings.Contains(out, `"ok": true`) {
|
||||
t.Errorf("expected ok:true despite skills failure, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"action": "updated"`) {
|
||||
t.Errorf("expected action:updated despite skills failure, got: %s", out)
|
||||
}
|
||||
// Should have skills_warning with detail
|
||||
if !strings.Contains(out, "skills_warning") {
|
||||
t.Errorf("expected skills_warning in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "skills_detail") {
|
||||
t.Errorf("expected skills_detail in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
origFetch := fetchLatest
|
||||
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||
defer func() { fetchLatest = origFetch }()
|
||||
origVersion := currentVersion
|
||||
currentVersion = func() string { return "1.0.0" }
|
||||
defer func() { currentVersion = origVersion }()
|
||||
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
return r
|
||||
}
|
||||
return u
|
||||
}
|
||||
defer func() { newUpdater = origNew }()
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
// CLI update should still show success
|
||||
if !strings.Contains(out, "Successfully updated") {
|
||||
t.Errorf("expected CLI success message, got: %s", out)
|
||||
}
|
||||
// Skills warning should be shown
|
||||
if !strings.Contains(out, "Skills update failed") {
|
||||
t.Errorf("expected skills failure warning, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "npx -y skills add") {
|
||||
t.Errorf("expected manual skills command hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
long := strings.Repeat("x", 3000)
|
||||
got := selfupdate.Truncate(long, 2000)
|
||||
if len(got) != 2000 {
|
||||
t.Errorf("expected truncated length 2000, got %d", len(got))
|
||||
}
|
||||
|
||||
short := "hello"
|
||||
got2 := selfupdate.Truncate(short, 2000)
|
||||
if got2 != "hello" {
|
||||
t.Errorf("expected 'hello', got %q", got2)
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,51 @@ func encodeParams(params map[string]interface{}) string {
|
||||
return vals.Encode()
|
||||
}
|
||||
|
||||
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
|
||||
// Instead of serializing the Formdata body, it shows file metadata.
|
||||
func PrintDryRunWithFile(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format, fileField, filePath string, formFields any) error {
|
||||
dr := NewDryRunAPI()
|
||||
switch request.Method {
|
||||
case "POST":
|
||||
dr.POST(request.URL)
|
||||
case "PUT":
|
||||
dr.PUT(request.URL)
|
||||
case "PATCH":
|
||||
dr.PATCH(request.URL)
|
||||
case "DELETE":
|
||||
dr.DELETE(request.URL)
|
||||
default:
|
||||
dr.GET(request.URL)
|
||||
}
|
||||
if len(request.Params) > 0 {
|
||||
dr.Params(request.Params)
|
||||
}
|
||||
filePathDisplay := filePath
|
||||
if filePathDisplay == "" {
|
||||
filePathDisplay = "<stdin>"
|
||||
}
|
||||
fileInfo := map[string]any{
|
||||
"file": map[string]string{"field": fileField, "path": filePathDisplay},
|
||||
}
|
||||
if formFields != nil {
|
||||
fileInfo["form_fields"] = formFields
|
||||
}
|
||||
fileInfo["options"] = []string{"WithFileUpload"}
|
||||
dr.Body(fileInfo)
|
||||
dr.Set("as", string(request.As))
|
||||
dr.Set("appId", config.AppID)
|
||||
if config.UserOpenId != "" {
|
||||
dr.Set("userOpenId", config.UserOpenId)
|
||||
}
|
||||
fmt.Fprintln(w, "=== Dry Run ===")
|
||||
if format == "pretty" {
|
||||
fmt.Fprint(w, dr.Format())
|
||||
} else {
|
||||
output.PrintJson(w, dr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
|
||||
// When format is "pretty", outputs human-readable text; otherwise JSON.
|
||||
func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
|
||||
130
internal/cmdutil/fileupload.go
Normal file
130
internal/cmdutil/fileupload.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// DetectFileFields returns field names with type "file" in the method's requestBody.
|
||||
func DetectFileFields(method map[string]interface{}) []string {
|
||||
rb, _ := method["requestBody"].(map[string]interface{})
|
||||
var fields []string
|
||||
for name, field := range rb {
|
||||
f, _ := field.(map[string]interface{})
|
||||
if registry.GetStrFromMap(f, "type") == "file" {
|
||||
fields = append(fields, name)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// ParseFileFlag parses a --file flag value into its components.
|
||||
// The format is either "path" or "field=path". When no explicit "field="
|
||||
// prefix is present, defaultField is used as the field name.
|
||||
// A path of "-" indicates stdin; in that case filePath is empty and isStdin is true.
|
||||
func ParseFileFlag(raw, defaultField string) (fieldName, filePath string, isStdin bool) {
|
||||
if idx := strings.IndexByte(raw, '='); idx > 0 {
|
||||
fieldName = raw[:idx]
|
||||
filePath = raw[idx+1:]
|
||||
} else {
|
||||
fieldName = defaultField
|
||||
filePath = raw
|
||||
}
|
||||
if filePath == "-" {
|
||||
return fieldName, "", true
|
||||
}
|
||||
return fieldName, filePath, false
|
||||
}
|
||||
|
||||
// ValidateFileFlag checks mutual exclusion rules for the --file flag.
|
||||
// Returns nil if file is empty (flag not provided).
|
||||
func ValidateFileFlag(file, params, data, outputPath string, pageAll bool, httpMethod string) error {
|
||||
if file == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, filePath, isStdin := ParseFileFlag(file, "file")
|
||||
if !isStdin && filePath == "" {
|
||||
return output.ErrValidation("--file: empty file path")
|
||||
}
|
||||
|
||||
if outputPath != "" {
|
||||
return output.ErrValidation("--file and --output are mutually exclusive")
|
||||
}
|
||||
if pageAll {
|
||||
return output.ErrValidation("--file and --page-all are mutually exclusive")
|
||||
}
|
||||
if isStdin && data == "-" {
|
||||
return output.ErrValidation("--file and --data cannot both read from stdin")
|
||||
}
|
||||
if isStdin && params == "-" {
|
||||
return output.ErrValidation("--file and --params cannot both read from stdin")
|
||||
}
|
||||
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
default:
|
||||
return output.ErrValidation("--file requires POST, PUT, PATCH, or DELETE method")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileUploadMeta holds file upload metadata for dry-run display.
|
||||
// Returned by request builders when dry-run mode skips actual file reading.
|
||||
type FileUploadMeta struct {
|
||||
FieldName string
|
||||
FilePath string
|
||||
FormFields any
|
||||
}
|
||||
|
||||
// BuildFormdata constructs a multipart form data payload for file upload.
|
||||
// If isStdin is true, the file content is read from stdin.
|
||||
// Top-level keys from dataJSON are added as text form fields.
|
||||
func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin bool, stdin io.Reader, dataJSON any) (*larkcore.Formdata, error) {
|
||||
fd := larkcore.NewFormdata()
|
||||
|
||||
if isStdin {
|
||||
if stdin == nil {
|
||||
return nil, output.ErrValidation("--file: stdin is not available")
|
||||
}
|
||||
data, err := io.ReadAll(stdin)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("--file: failed to read stdin: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, output.ErrValidation("--file: stdin is empty")
|
||||
}
|
||||
fd.AddFile(fieldName, bytes.NewReader(data))
|
||||
} else {
|
||||
f, err := fileIO.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("cannot open file: %s", filePath)
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("--file: failed to read %s: %v", filePath, err)
|
||||
}
|
||||
fd.AddFile(fieldName, bytes.NewReader(data))
|
||||
}
|
||||
|
||||
// Add top-level JSON keys as text form fields.
|
||||
if m, ok := dataJSON.(map[string]any); ok {
|
||||
for k, v := range m {
|
||||
fd.AddField(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
return fd, nil
|
||||
}
|
||||
338
internal/cmdutil/fileupload_test.go
Normal file
338
internal/cmdutil/fileupload_test.go
Normal file
@@ -0,0 +1,338 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
func TestParseFileFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
defaultField string
|
||||
wantField string
|
||||
wantPath string
|
||||
wantStdin bool
|
||||
}{
|
||||
{
|
||||
name: "simple filename uses default field",
|
||||
raw: "photo.jpg",
|
||||
defaultField: "file",
|
||||
wantField: "file",
|
||||
wantPath: "photo.jpg",
|
||||
wantStdin: false,
|
||||
},
|
||||
{
|
||||
name: "simple filename with custom default",
|
||||
raw: "photo.jpg",
|
||||
defaultField: "image",
|
||||
wantField: "image",
|
||||
wantPath: "photo.jpg",
|
||||
wantStdin: false,
|
||||
},
|
||||
{
|
||||
name: "explicit field prefix",
|
||||
raw: "image=photo.jpg",
|
||||
defaultField: "file",
|
||||
wantField: "image",
|
||||
wantPath: "photo.jpg",
|
||||
wantStdin: false,
|
||||
},
|
||||
{
|
||||
name: "stdin bare",
|
||||
raw: "-",
|
||||
defaultField: "file",
|
||||
wantField: "file",
|
||||
wantPath: "",
|
||||
wantStdin: true,
|
||||
},
|
||||
{
|
||||
name: "stdin with field prefix",
|
||||
raw: "image=-",
|
||||
defaultField: "file",
|
||||
wantField: "image",
|
||||
wantPath: "",
|
||||
wantStdin: true,
|
||||
},
|
||||
{
|
||||
name: "path with equals sign (only first equals splits)",
|
||||
raw: "field=path/to/file=1.jpg",
|
||||
defaultField: "file",
|
||||
wantField: "field",
|
||||
wantPath: "path/to/file=1.jpg",
|
||||
wantStdin: false,
|
||||
},
|
||||
{
|
||||
name: "absolute path no prefix",
|
||||
raw: "/tmp/photo.jpg",
|
||||
defaultField: "file",
|
||||
wantField: "file",
|
||||
wantPath: "/tmp/photo.jpg",
|
||||
wantStdin: false,
|
||||
},
|
||||
{
|
||||
name: "absolute path with field prefix",
|
||||
raw: "image=/tmp/photo.jpg",
|
||||
defaultField: "file",
|
||||
wantField: "image",
|
||||
wantPath: "/tmp/photo.jpg",
|
||||
wantStdin: false,
|
||||
},
|
||||
{
|
||||
name: "empty field prefix falls through to default",
|
||||
raw: "=photo.jpg",
|
||||
defaultField: "file",
|
||||
wantField: "file",
|
||||
wantPath: "=photo.jpg",
|
||||
wantStdin: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
field, path, isStdin := ParseFileFlag(tt.raw, tt.defaultField)
|
||||
if field != tt.wantField {
|
||||
t.Errorf("field = %q, want %q", field, tt.wantField)
|
||||
}
|
||||
if path != tt.wantPath {
|
||||
t.Errorf("path = %q, want %q", path, tt.wantPath)
|
||||
}
|
||||
if isStdin != tt.wantStdin {
|
||||
t.Errorf("isStdin = %v, want %v", isStdin, tt.wantStdin)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFileFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
params string
|
||||
data string
|
||||
outputPath string
|
||||
pageAll bool
|
||||
httpMethod string
|
||||
wantErr string // empty means no error
|
||||
}{
|
||||
{
|
||||
name: "empty file is valid",
|
||||
file: "",
|
||||
httpMethod: "GET",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "empty file path",
|
||||
file: "field=",
|
||||
httpMethod: "POST",
|
||||
wantErr: "--file: empty file path",
|
||||
},
|
||||
{
|
||||
name: "file with output",
|
||||
file: "photo.jpg",
|
||||
outputPath: "out.json",
|
||||
httpMethod: "POST",
|
||||
wantErr: "--file and --output are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "file with page-all",
|
||||
file: "photo.jpg",
|
||||
pageAll: true,
|
||||
httpMethod: "POST",
|
||||
wantErr: "--file and --page-all are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "stdin file with stdin data",
|
||||
file: "-",
|
||||
data: "-",
|
||||
httpMethod: "POST",
|
||||
wantErr: "--file and --data cannot both read from stdin",
|
||||
},
|
||||
{
|
||||
name: "stdin file with stdin params",
|
||||
file: "-",
|
||||
params: "-",
|
||||
httpMethod: "POST",
|
||||
wantErr: "--file and --params cannot both read from stdin",
|
||||
},
|
||||
{
|
||||
name: "file with GET method",
|
||||
file: "photo.jpg",
|
||||
httpMethod: "GET",
|
||||
wantErr: "--file requires POST, PUT, PATCH, or DELETE method",
|
||||
},
|
||||
{
|
||||
name: "file with POST method",
|
||||
file: "photo.jpg",
|
||||
httpMethod: "POST",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "file with PUT method",
|
||||
file: "photo.jpg",
|
||||
httpMethod: "PUT",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "file with PATCH method",
|
||||
file: "photo.jpg",
|
||||
httpMethod: "PATCH",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "file with DELETE method",
|
||||
file: "photo.jpg",
|
||||
httpMethod: "DELETE",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "stdin with field prefix and data stdin",
|
||||
file: "image=-",
|
||||
data: "-",
|
||||
httpMethod: "POST",
|
||||
wantErr: "--file and --data cannot both read from stdin",
|
||||
},
|
||||
{
|
||||
name: "stdin with field prefix and params stdin",
|
||||
file: "image=-",
|
||||
params: "-",
|
||||
httpMethod: "POST",
|
||||
wantErr: "--file and --params cannot both read from stdin",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateFileFlag(tt.file, tt.params, tt.data, tt.outputPath, tt.pageAll, tt.httpMethod)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFormdata(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
|
||||
t.Run("stdin success", func(t *testing.T) {
|
||||
stdin := bytes.NewReader([]byte("file-content-here"))
|
||||
fd, err := BuildFormdata(fio, "file", "", true, stdin, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fd == nil {
|
||||
t.Fatal("expected non-nil Formdata")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stdin nil reader", func(t *testing.T) {
|
||||
_, err := BuildFormdata(fio, "file", "", true, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil stdin")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stdin is not available") {
|
||||
t.Errorf("error = %q, want containing %q", err.Error(), "stdin is not available")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stdin empty", func(t *testing.T) {
|
||||
stdin := bytes.NewReader([]byte{})
|
||||
_, err := BuildFormdata(fio, "file", "", true, stdin, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty stdin")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stdin is empty") {
|
||||
t.Errorf("error = %q, want containing %q", err.Error(), "stdin is empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file open success", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "test.txt"), []byte("hello"), 0600); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
fd, err := BuildFormdata(fio, "photo", "test.txt", false, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fd == nil {
|
||||
t.Fatal("expected non-nil Formdata")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file not found", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
|
||||
_, err := BuildFormdata(fio, "file", "nonexistent.txt", false, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot open file:") {
|
||||
t.Errorf("error = %q, want containing %q", err.Error(), "cannot open file:")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dataJSON fields added", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "upload.bin"), []byte("data"), 0600); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
dataJSON := map[string]any{
|
||||
"file_name": "report.pdf",
|
||||
"parent_type": "doc_image",
|
||||
"size": 1024,
|
||||
}
|
||||
|
||||
fd, err := BuildFormdata(fio, "file", "upload.bin", false, nil, dataJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fd == nil {
|
||||
t.Fatal("expected non-nil Formdata")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dataJSON nil is fine", func(t *testing.T) {
|
||||
stdin := bytes.NewReader([]byte("content"))
|
||||
fd, err := BuildFormdata(fio, "file", "", true, stdin, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fd == nil {
|
||||
t.Fatal("expected non-nil Formdata")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dataJSON non-map is ignored", func(t *testing.T) {
|
||||
stdin := bytes.NewReader([]byte("content"))
|
||||
fd, err := BuildFormdata(fio, "file", "", true, stdin, "not-a-map")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fd == nil {
|
||||
t.Fatal("expected non-nil Formdata")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -163,6 +163,16 @@ type CliConfig struct {
|
||||
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
|
||||
}
|
||||
|
||||
// identityBotBit is the bit flag for bot identity in SupportedIdentities.
|
||||
// Must match extension/credential.SupportsBot.
|
||||
const identityBotBit uint8 = 1 << 1
|
||||
|
||||
// CanBot reports whether the current credential context supports bot identity.
|
||||
// Returns true when SupportedIdentities is unset (0, unknown) or includes the bot bit.
|
||||
func (c *CliConfig) CanBot() bool {
|
||||
return c.SupportedIdentities == 0 || c.SupportedIdentities&identityBotBit != 0
|
||||
}
|
||||
|
||||
// GetConfigDir returns the config directory path.
|
||||
// If the home directory cannot be determined, it falls back to a relative path
|
||||
// and prints a warning to stderr.
|
||||
|
||||
@@ -187,3 +187,24 @@ func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) {
|
||||
t.Fatalf("ResolveConfigFromMulti() profile = %q, want %q", cfg.ProfileName, "active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCliConfig_CanBot(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
supportedIdentities uint8
|
||||
want bool
|
||||
}{
|
||||
{"unset (0) defaults to true", 0, true},
|
||||
{"user only", 1, false},
|
||||
{"bot only", 2, true},
|
||||
{"both", 3, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &CliConfig{SupportedIdentities: tt.supportedIdentities}
|
||||
if got := cfg.CanBot(); got != tt.want {
|
||||
t.Errorf("CanBot() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +36,10 @@ func wrapError(op string, err error) error {
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("keychain %s failed: %v", op, err)
|
||||
hint := "Check if the OS keychain/credential manager is locked or accessible. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain."
|
||||
hint := "Check if the OS keychain/credential manager is locked or accessible. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain, you can try running this outside the sandbox."
|
||||
|
||||
if errors.Is(err, errNotInitialized) {
|
||||
hint = "The keychain master key may have been cleaned up or deleted. Please reconfigure the CLI by running `lark-cli config init`."
|
||||
hint = "The keychain master key may have been cleaned up or deleted. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain, you can try running this outside the sandbox. Otherwise, please reconfigure the CLI by running lark-cli config init."
|
||||
}
|
||||
|
||||
func() {
|
||||
|
||||
@@ -564,3 +564,54 @@ func TestCollectScopesForProjects_NonexistentProject(t *testing.T) {
|
||||
t.Errorf("expected empty scopes for nonexistent project, got %d", len(scopes))
|
||||
}
|
||||
}
|
||||
|
||||
// --- auth_domain functions ---
|
||||
|
||||
func TestGetAuthDomain_Configured(t *testing.T) {
|
||||
// whiteboard has auth_domain: "docs" in service_descriptions.json
|
||||
if got := GetAuthDomain("whiteboard"); got != "docs" {
|
||||
t.Errorf("GetAuthDomain(whiteboard) = %q, want %q", got, "docs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthDomain_NotConfigured(t *testing.T) {
|
||||
if got := GetAuthDomain("calendar"); got != "" {
|
||||
t.Errorf("GetAuthDomain(calendar) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthDomain_Unknown(t *testing.T) {
|
||||
if got := GetAuthDomain("nonexistent_xyz"); got != "" {
|
||||
t.Errorf("GetAuthDomain(nonexistent_xyz) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAuthDomain(t *testing.T) {
|
||||
if !HasAuthDomain("whiteboard") {
|
||||
t.Error("HasAuthDomain(whiteboard) = false, want true")
|
||||
}
|
||||
if HasAuthDomain("calendar") {
|
||||
t.Error("HasAuthDomain(calendar) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthChildren(t *testing.T) {
|
||||
children := GetAuthChildren("docs")
|
||||
found := false
|
||||
for _, c := range children {
|
||||
if c == "whiteboard" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GetAuthChildren(docs) = %v, want to contain 'whiteboard'", children)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthChildren_NoChildren(t *testing.T) {
|
||||
children := GetAuthChildren("calendar")
|
||||
if len(children) != 0 {
|
||||
t.Errorf("GetAuthChildren(calendar) = %v, want empty", children)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
"im:message:send_as_bot": 1,
|
||||
"calendar:calendar:read": 70,
|
||||
"calendar:calendar:readonly": 1,
|
||||
"sheets:spreadsheet:write_only": 45,
|
||||
"docs:document.comment:delete": 60,
|
||||
"sheets:spreadsheet:write_only": 60,
|
||||
"drive:drive:readonly": 1,
|
||||
"docs:doc:readonly": 1,
|
||||
"sheets:spreadsheet:readonly": 1,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,9 @@ type serviceDescLocale struct {
|
||||
|
||||
// serviceDescEntry holds bilingual descriptions for a service domain.
|
||||
type serviceDescEntry struct {
|
||||
En serviceDescLocale `json:"en"`
|
||||
Zh serviceDescLocale `json:"zh"`
|
||||
En serviceDescLocale `json:"en"`
|
||||
Zh serviceDescLocale `json:"zh"`
|
||||
AuthDomain string `json:"auth_domain,omitempty"`
|
||||
}
|
||||
|
||||
var serviceDescMap map[string]serviceDescEntry
|
||||
@@ -76,3 +77,31 @@ func GetServiceDetailDescription(name, lang string) string {
|
||||
}
|
||||
return loc.Description
|
||||
}
|
||||
|
||||
// GetAuthDomain returns the auth_domain for a service, or "" if not set.
|
||||
// When auth_domain is set, the service's scopes are collected under the
|
||||
// parent domain during auth login.
|
||||
func GetAuthDomain(service string) string {
|
||||
m := loadServiceDescriptions()
|
||||
if entry, ok := m[service]; ok {
|
||||
return entry.AuthDomain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HasAuthDomain reports whether the service has an auth_domain configured.
|
||||
func HasAuthDomain(service string) bool {
|
||||
return GetAuthDomain(service) != ""
|
||||
}
|
||||
|
||||
// GetAuthChildren returns all service names whose auth_domain equals parent.
|
||||
func GetAuthChildren(parent string) []string {
|
||||
m := loadServiceDescriptions()
|
||||
var children []string
|
||||
for name, entry := range m {
|
||||
if entry.AuthDomain == parent {
|
||||
children = append(children, name)
|
||||
}
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
"en": { "title": "Sheets", "description": "Spreadsheet operations" },
|
||||
"zh": { "title": "电子表格", "description": "电子表格操作" }
|
||||
},
|
||||
"slides": {
|
||||
"en": { "title": "Slides", "description": "Create and manage presentations, read content, and add or remove slides" },
|
||||
"zh": { "title": "幻灯片", "description": "创建和管理演示文稿、读取内容,以及新增或删除幻灯片页面" }
|
||||
},
|
||||
"task": {
|
||||
"en": { "title": "Task", "description": "Task, task list, and subtask management" },
|
||||
"zh": { "title": "任务", "description": "任务、清单、子任务管理" }
|
||||
@@ -53,7 +57,8 @@
|
||||
},
|
||||
"whiteboard": {
|
||||
"en": { "title": "Whiteboard", "description": "Create and edit boards" },
|
||||
"zh": { "title": "画板", "description": "画板创建、编辑" }
|
||||
"zh": { "title": "画板", "description": "画板创建、编辑" },
|
||||
"auth_domain": "docs"
|
||||
},
|
||||
"wiki": {
|
||||
"en": { "title": "Wiki", "description": "Wiki space and node management" },
|
||||
|
||||
231
internal/selfupdate/updater.go
Normal file
231
internal/selfupdate/updater.go
Normal file
@@ -0,0 +1,231 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package selfupdate handles installation detection, npm-based updates,
|
||||
// skills updates, and platform-specific binary replacement for the CLI
|
||||
// self-update flow.
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// InstallMethod describes how the CLI was installed.
|
||||
type InstallMethod int
|
||||
|
||||
const (
|
||||
InstallNpm InstallMethod = iota
|
||||
InstallManual
|
||||
)
|
||||
|
||||
const (
|
||||
NpmPackage = "@larksuite/cli"
|
||||
)
|
||||
|
||||
const (
|
||||
npmInstallTimeout = 10 * time.Minute
|
||||
skillsUpdateTimeout = 2 * time.Minute
|
||||
verifyTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
type DetectResult struct {
|
||||
Method InstallMethod
|
||||
ResolvedPath string
|
||||
NpmAvailable bool
|
||||
}
|
||||
|
||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||
func (d DetectResult) CanAutoUpdate() bool {
|
||||
return d.Method == InstallNpm && d.NpmAvailable
|
||||
}
|
||||
|
||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||
func (d DetectResult) ManualReason() string {
|
||||
if d.Method == InstallNpm && !d.NpmAvailable {
|
||||
return "installed via npm, but npm is not available in PATH"
|
||||
}
|
||||
return "not installed via npm"
|
||||
}
|
||||
|
||||
// NpmResult holds the result of an npm install or skills update execution.
|
||||
type NpmResult struct {
|
||||
Stdout bytes.Buffer
|
||||
Stderr bytes.Buffer
|
||||
Err error
|
||||
}
|
||||
|
||||
// CombinedOutput returns stdout + stderr concatenated.
|
||||
func (r *NpmResult) CombinedOutput() string {
|
||||
return r.Stdout.String() + r.Stderr.String()
|
||||
}
|
||||
|
||||
// Updater manages self-update operations.
|
||||
// Platform-specific methods (PrepareSelfReplace, CleanupStaleFiles)
|
||||
// are in updater_unix.go and updater_windows.go.
|
||||
//
|
||||
// Override DetectOverride / NpmInstallOverride / SkillsUpdateOverride / VerifyOverride
|
||||
// / RestoreAvailableOverride for testing.
|
||||
type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
SkillsUpdateOverride func() *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
RestoreAvailableOverride func() bool
|
||||
|
||||
// backupCreated is set to true by PrepareSelfReplace (Windows) when the
|
||||
// running binary is successfully renamed to .old. Used by
|
||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||
backupCreated bool
|
||||
}
|
||||
|
||||
// New creates an Updater with default (real) behavior.
|
||||
func New() *Updater { return &Updater{} }
|
||||
|
||||
// DetectInstallMethod determines how the CLI was installed and whether
|
||||
// npm is available for auto-update.
|
||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
if u.DetectOverride != nil {
|
||||
return u.DetectOverride()
|
||||
}
|
||||
exe, err := vfs.Executable()
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual}
|
||||
}
|
||||
resolved, err := vfs.EvalSymlinks(exe)
|
||||
if err != nil {
|
||||
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
||||
}
|
||||
|
||||
method := InstallManual
|
||||
if strings.Contains(resolved, "node_modules") {
|
||||
method = InstallNpm
|
||||
}
|
||||
|
||||
npmAvailable := false
|
||||
if method == InstallNpm {
|
||||
if _, err := exec.LookPath("npm"); err == nil {
|
||||
npmAvailable = true
|
||||
}
|
||||
}
|
||||
|
||||
return DetectResult{
|
||||
Method: method,
|
||||
ResolvedPath: resolved,
|
||||
NpmAvailable: npmAvailable,
|
||||
}
|
||||
}
|
||||
|
||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||
func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
if u.NpmInstallOverride != nil {
|
||||
return u.NpmInstallOverride(version)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
npmPath, err := exec.LookPath("npm")
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("npm not found in PATH: %w", err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npmPath, "install", "-g", NpmPackage+"@"+version)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
r.Err = fmt.Errorf("npm install timed out after %s", npmInstallTimeout)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// RunSkillsUpdate executes npx -y skills add larksuite/cli -g -y.
|
||||
func (u *Updater) RunSkillsUpdate() *NpmResult {
|
||||
if u.SkillsUpdateOverride != nil {
|
||||
return u.SkillsUpdateOverride()
|
||||
}
|
||||
r := &NpmResult{}
|
||||
npxPath, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
||||
return r
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", "larksuite/cli", "-g", "-y")
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
r.Err = fmt.Errorf("skills update timed out after %s", skillsUpdateTimeout)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// VerifyBinary checks that the installed binary reports the expected version
|
||||
// by running "lark-cli --version" and comparing the version token exactly.
|
||||
// Output format is "lark-cli version X.Y.Z"; the last field is extracted and
|
||||
// compared against expectedVersion (both stripped of any "v" prefix).
|
||||
func (u *Updater) VerifyBinary(expectedVersion string) error {
|
||||
if u.VerifyOverride != nil {
|
||||
return u.VerifyOverride(expectedVersion)
|
||||
}
|
||||
// Prefer the current executable path (what the user actually launched).
|
||||
// Use Executable() directly without EvalSymlinks — after npm install the
|
||||
// symlink target may have changed, but the path itself is still valid for
|
||||
// execution. Fall back to LookPath only if Executable() fails entirely.
|
||||
exe, err := vfs.Executable()
|
||||
if err != nil {
|
||||
exe, err = exec.LookPath("lark-cli")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot locate binary: %w", err)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), verifyTimeout)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, exe, "--version").Output()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("binary verification timed out after %s", verifyTimeout)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("binary not executable: %w", err)
|
||||
}
|
||||
fields := strings.Fields(strings.TrimSpace(string(out)))
|
||||
if len(fields) == 0 {
|
||||
return fmt.Errorf("empty version output")
|
||||
}
|
||||
actual := strings.TrimPrefix(fields[len(fields)-1], "v")
|
||||
expected := strings.TrimPrefix(expectedVersion, "v")
|
||||
if actual != expected {
|
||||
return fmt.Errorf("expected version %s, got %q", expectedVersion, actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Truncate returns the last maxLen runes of s.
|
||||
func Truncate(s string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return string(r[len(r)-maxLen:])
|
||||
}
|
||||
|
||||
// resolveExe returns the resolved path of the current running binary.
|
||||
func (u *Updater) resolveExe() (string, error) {
|
||||
exe, err := vfs.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return vfs.EvalSymlinks(exe)
|
||||
}
|
||||
89
internal/selfupdate/updater_test.go
Normal file
89
internal/selfupdate/updater_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type executableTestFS struct {
|
||||
vfs.OsFs
|
||||
exe string
|
||||
}
|
||||
|
||||
func (f executableTestFS) Executable() (string, error) { return f.exe, nil }
|
||||
|
||||
func TestResolveExe(t *testing.T) {
|
||||
u := New()
|
||||
p, err := u.resolveExe()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveExe() error: %v", err)
|
||||
}
|
||||
if !filepath.IsAbs(p) {
|
||||
t.Errorf("expected absolute path, got: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSelfReplace_ReturnsNoError(t *testing.T) {
|
||||
u := New()
|
||||
restore, err := u.PrepareSelfReplace()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
restore()
|
||||
}
|
||||
|
||||
func TestCleanupStaleFiles_NoPanic(t *testing.T) {
|
||||
u := New()
|
||||
u.CleanupStaleFiles()
|
||||
}
|
||||
|
||||
func TestVerifyBinaryChecksVersion(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell script")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "lark-cli")
|
||||
// Script prints version string matching real CLI format when --version is passed.
|
||||
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.0.0\"; exit 0; fi\nexit 12\n"
|
||||
if err := os.WriteFile(exe, []byte(script), 0755); err != nil {
|
||||
t.Fatalf("write test binary: %v", err)
|
||||
}
|
||||
|
||||
// Mock vfs.Executable to return our test script, matching VerifyBinary's
|
||||
// primary lookup path. Also prepend to PATH for the LookPath fallback.
|
||||
origFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = executableTestFS{OsFs: vfs.OsFs{}, exe: exe}
|
||||
t.Cleanup(func() { vfs.DefaultFS = origFS })
|
||||
|
||||
origPath := os.Getenv("PATH")
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath)
|
||||
|
||||
// Matching version → success.
|
||||
if err := New().VerifyBinary("2.0.0"); err != nil {
|
||||
t.Fatalf("VerifyBinary(matching) error = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Mismatched version → error.
|
||||
if err := New().VerifyBinary("3.0.0"); err == nil {
|
||||
t.Fatal("VerifyBinary(mismatched) expected error, got nil")
|
||||
}
|
||||
|
||||
// Substring of actual version must not match (e.g. "0.0" is in "2.0.0").
|
||||
if err := New().VerifyBinary("0.0"); err == nil {
|
||||
t.Fatal("VerifyBinary(substring) expected error, got nil")
|
||||
}
|
||||
|
||||
// Version that is a prefix of actual must not match (e.g. "2.0.0" in "12.0.0").
|
||||
// Binary reports "2.0.0", asking for "12.0.0" must fail.
|
||||
if err := New().VerifyBinary("12.0.0"); err == nil {
|
||||
t.Fatal("VerifyBinary(prefix-mismatch) expected error, got nil")
|
||||
}
|
||||
}
|
||||
24
internal/selfupdate/updater_unix.go
Normal file
24
internal/selfupdate/updater_unix.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package selfupdate
|
||||
|
||||
// PrepareSelfReplace is a no-op on Unix.
|
||||
// Unix allows overwriting a running executable via inode semantics.
|
||||
func (u *Updater) PrepareSelfReplace() (restore func(), err error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
// CleanupStaleFiles is a no-op on Unix (no .old files are created).
|
||||
func (u *Updater) CleanupStaleFiles() {}
|
||||
|
||||
// CanRestorePreviousVersion reports whether PrepareSelfReplace created a
|
||||
// restorable backup for the current update attempt.
|
||||
func (u *Updater) CanRestorePreviousVersion() bool {
|
||||
if u.RestoreAvailableOverride != nil {
|
||||
return u.RestoreAvailableOverride()
|
||||
}
|
||||
return u.backupCreated
|
||||
}
|
||||
87
internal/selfupdate/updater_windows.go
Normal file
87
internal/selfupdate/updater_windows.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// PrepareSelfReplace renames the running .exe to .old so that npm's
|
||||
// postinstall script can write the new binary without hitting EBUSY.
|
||||
// Returns a restore function that undoes the rename on failure.
|
||||
func (u *Updater) PrepareSelfReplace() (restore func(), err error) {
|
||||
noop := func() {}
|
||||
|
||||
exe, err := u.resolveExe()
|
||||
if err != nil {
|
||||
return noop, nil // best-effort; don't block update
|
||||
}
|
||||
|
||||
oldPath := exe + ".old"
|
||||
|
||||
// Clean up stale .old from a previous upgrade.
|
||||
vfs.Remove(oldPath)
|
||||
|
||||
// Rename running.exe → running.exe.old (Windows allows rename of locked files).
|
||||
if err := vfs.Rename(exe, oldPath); err != nil {
|
||||
return noop, fmt.Errorf("cannot rename binary for update: %w", err)
|
||||
}
|
||||
u.backupCreated = true
|
||||
|
||||
// Restore: move .old back to the original path.
|
||||
// Guard with Stat: run.js may have already recovered .old on its own
|
||||
// during VerifyBinary; if .old is gone, skip to avoid deleting the
|
||||
// only working binary.
|
||||
// On any failure, clear backupCreated so CanRestorePreviousVersion
|
||||
// reports the real outcome instead of claiming success.
|
||||
restore = func() {
|
||||
if _, err := vfs.Stat(oldPath); err != nil {
|
||||
u.backupCreated = false
|
||||
return
|
||||
}
|
||||
vfs.Remove(exe)
|
||||
if err := vfs.Rename(oldPath, exe); err != nil {
|
||||
u.backupCreated = false
|
||||
}
|
||||
}
|
||||
|
||||
return restore, nil
|
||||
}
|
||||
|
||||
// CleanupStaleFiles removes leftover .old files from previous upgrades.
|
||||
// If the original binary is missing but .old exists (crash mid-update),
|
||||
// it restores the .old to recover the installation.
|
||||
func (u *Updater) CleanupStaleFiles() {
|
||||
exe, err := u.resolveExe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
oldPath := exe + ".old"
|
||||
|
||||
if _, err := vfs.Stat(oldPath); err != nil {
|
||||
return // no .old file
|
||||
}
|
||||
|
||||
if _, err := vfs.Stat(exe); err != nil {
|
||||
// Original missing, .old exists — restore to recover.
|
||||
vfs.Rename(oldPath, exe)
|
||||
return
|
||||
}
|
||||
|
||||
// Both exist — .old is stale, clean up.
|
||||
vfs.Remove(oldPath)
|
||||
}
|
||||
|
||||
// CanRestorePreviousVersion reports whether PrepareSelfReplace created a
|
||||
// restorable backup for the current update attempt.
|
||||
func (u *Updater) CanRestorePreviousVersion() bool {
|
||||
if u.RestoreAvailableOverride != nil {
|
||||
return u.RestoreAvailableOverride()
|
||||
}
|
||||
return u.backupCreated
|
||||
}
|
||||
@@ -218,8 +218,8 @@ func fetchLatestVersion() (string, error) {
|
||||
// is considered newer — an unparseable local version is assumed outdated.
|
||||
// When a cannot be parsed, returns false (can't confirm it's newer).
|
||||
func IsNewer(a, b string) bool {
|
||||
ap := ParseVersion(a)
|
||||
bp := ParseVersion(b)
|
||||
ap := parseVersionDetail(a)
|
||||
bp := parseVersionDetail(b)
|
||||
if ap == nil {
|
||||
return false // can't confirm remote is newer
|
||||
}
|
||||
@@ -227,28 +227,59 @@ func IsNewer(a, b string) bool {
|
||||
return true // local version unparseable → assume outdated
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if ap[i] > bp[i] {
|
||||
if ap.core[i] > bp.core[i] {
|
||||
return true
|
||||
}
|
||||
if ap[i] < bp[i] {
|
||||
if ap.core[i] < bp.core[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
return comparePrerelease(ap.prerelease, bp.prerelease) > 0
|
||||
}
|
||||
|
||||
// ParseVersion parses "X.Y.Z" (with optional "v" prefix and pre-release suffix)
|
||||
// into [major, minor, patch]. Returns nil on invalid input.
|
||||
func ParseVersion(v string) []int {
|
||||
parsed := parseVersionDetail(v)
|
||||
if parsed == nil {
|
||||
return nil
|
||||
}
|
||||
return []int{parsed.core[0], parsed.core[1], parsed.core[2]}
|
||||
}
|
||||
|
||||
type parsedVersion struct {
|
||||
core [3]int
|
||||
prerelease string
|
||||
}
|
||||
|
||||
// validPrerelease matches semver pre-release identifiers (dot-separated).
|
||||
// Each identifier is either: "0", a non-zero-leading numeric, or alphanumeric with at least one letter/hyphen.
|
||||
// Rejects empty identifiers ("1.0.0-"), leading-zero numerics ("1.0.0-01"), etc.
|
||||
var validPrerelease = regexp.MustCompile(
|
||||
`^(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)` +
|
||||
`(?:\.(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*$`)
|
||||
|
||||
func parseVersionDetail(v string) *parsedVersion {
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
if idx := strings.Index(v, "+"); idx >= 0 {
|
||||
v = v[:idx]
|
||||
}
|
||||
prerelease := ""
|
||||
if idx := strings.Index(v, "-"); idx >= 0 {
|
||||
prerelease = v[idx+1:]
|
||||
v = v[:idx]
|
||||
if prerelease == "" || !validPrerelease.MatchString(prerelease) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
parts := strings.SplitN(v, ".", 3)
|
||||
if len(parts) != 3 {
|
||||
return nil
|
||||
}
|
||||
nums := make([]int, 3)
|
||||
var nums [3]int
|
||||
for i, p := range parts {
|
||||
if idx := strings.IndexAny(p, "-+"); idx >= 0 {
|
||||
p = p[:idx]
|
||||
if len(p) > 1 && p[0] == '0' {
|
||||
return nil // leading zero in core part (e.g. "01.0.0")
|
||||
}
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
@@ -256,5 +287,56 @@ func ParseVersion(v string) []int {
|
||||
}
|
||||
nums[i] = n
|
||||
}
|
||||
return nums
|
||||
return &parsedVersion{core: nums, prerelease: prerelease}
|
||||
}
|
||||
|
||||
func comparePrerelease(a, b string) int {
|
||||
if a == "" && b == "" {
|
||||
return 0
|
||||
}
|
||||
if a == "" {
|
||||
return 1
|
||||
}
|
||||
if b == "" {
|
||||
return -1
|
||||
}
|
||||
ap := strings.Split(a, ".")
|
||||
bp := strings.Split(b, ".")
|
||||
for i := 0; i < len(ap) && i < len(bp); i++ {
|
||||
cmp := comparePrereleaseIdentifier(ap[i], bp[i])
|
||||
if cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(ap) > len(bp):
|
||||
return 1
|
||||
case len(ap) < len(bp):
|
||||
return -1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func comparePrereleaseIdentifier(a, b string) int {
|
||||
an, aErr := strconv.Atoi(a)
|
||||
bn, bErr := strconv.Atoi(b)
|
||||
aNumeric := aErr == nil
|
||||
bNumeric := bErr == nil
|
||||
switch {
|
||||
case aNumeric && bNumeric:
|
||||
if an > bn {
|
||||
return 1
|
||||
}
|
||||
if an < bn {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
case aNumeric:
|
||||
return -1
|
||||
case bNumeric:
|
||||
return 1
|
||||
default:
|
||||
return strings.Compare(a, b)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ func TestIsNewer(t *testing.T) {
|
||||
{"1.0.0", "9b933f1", true}, // bare commit hash → assume outdated
|
||||
{"", "1.0.0", false}, // empty remote → false
|
||||
{"1.1.0", "v1.0.0-12-g9b933f1-dirty", true}, // git describe: 1.1.0 > 1.0.0
|
||||
{"1.0.0", "1.0.0-rc.1", true}, // stable release > prerelease
|
||||
{"1.0.0-rc.2", "1.0.0-rc.1", true}, // prerelease identifiers are ordered
|
||||
{"1.0.0-rc.1", "1.0.0", false}, // prerelease < stable release
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := IsNewer(tt.a, tt.b)
|
||||
@@ -74,6 +77,16 @@ func TestParseVersion(t *testing.T) {
|
||||
{"v1.2.3", []int{1, 2, 3}},
|
||||
{"0.0.1", []int{0, 0, 1}},
|
||||
{"1.0.0-beta.1", []int{1, 0, 0}},
|
||||
{"1.0.0-rc.1", []int{1, 0, 0}},
|
||||
{"1.0.0-0", []int{1, 0, 0}},
|
||||
{"1.0.0+build.123", []int{1, 0, 0}},
|
||||
{"1.0.0-beta.1+build", []int{1, 0, 0}},
|
||||
{"1.0.0-", nil}, // empty pre-release
|
||||
{"1.0.0-01", nil}, // leading zero in numeric pre-release
|
||||
{"1.0.0-beta..1", nil}, // empty identifier between dots
|
||||
{"01.0.0", nil}, // leading zero in major
|
||||
{"1.00.0", nil}, // leading zero in minor
|
||||
{"1.0.00", nil}, // leading zero in patch
|
||||
{"DEV", nil},
|
||||
{"", nil},
|
||||
{"1.2", nil},
|
||||
|
||||
@@ -31,3 +31,5 @@ func MkdirAll(path string, perm fs.FileMode) error { return DefaultFS.MkdirA
|
||||
func ReadDir(name string) ([]os.DirEntry, error) { return DefaultFS.ReadDir(name) }
|
||||
func Remove(name string) error { return DefaultFS.Remove(name) }
|
||||
func Rename(oldpath, newpath string) error { return DefaultFS.Rename(oldpath, newpath) }
|
||||
func EvalSymlinks(path string) (string, error) { return DefaultFS.EvalSymlinks(path) }
|
||||
func Executable() (string, error) { return DefaultFS.Executable() }
|
||||
|
||||
@@ -29,4 +29,8 @@ type FS interface {
|
||||
ReadDir(name string) ([]os.DirEntry, error)
|
||||
Remove(name string) error
|
||||
Rename(oldpath, newpath string) error
|
||||
|
||||
// Path resolution
|
||||
EvalSymlinks(path string) (string, error)
|
||||
Executable() (string, error)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package vfs
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// OsFs delegates every method to the os standard library.
|
||||
@@ -33,3 +34,7 @@ func (OsFs) MkdirAll(path string, perm fs.FileMode) error { return os.MkdirAll(p
|
||||
func (OsFs) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
|
||||
func (OsFs) Remove(name string) error { return os.Remove(name) }
|
||||
func (OsFs) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) }
|
||||
|
||||
// Path resolution
|
||||
func (OsFs) EvalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) }
|
||||
func (OsFs) Executable() (string, error) { return os.Executable() }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.9",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -9,6 +9,38 @@ const path = require("path");
|
||||
const ext = process.platform === "win32" ? ".exe" : "";
|
||||
const bin = path.join(__dirname, "..", "bin", "lark-cli" + ext);
|
||||
|
||||
// On Windows, a crashed self-update may have left the binary renamed to .old.
|
||||
// Recover it before proceeding so the CLI remains functional.
|
||||
const oldBin = bin + ".old";
|
||||
function restoreOldBinary() {
|
||||
try {
|
||||
if (fs.existsSync(bin)) {
|
||||
fs.rmSync(bin, { force: true });
|
||||
}
|
||||
fs.renameSync(oldBin, bin);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === "win32" && fs.existsSync(oldBin)) {
|
||||
if (!fs.existsSync(bin)) {
|
||||
restoreOldBinary();
|
||||
} else {
|
||||
try {
|
||||
execFileSync(bin, ["--version"], { stdio: "ignore", timeout: 10000 });
|
||||
try {
|
||||
fs.rmSync(oldBin, { force: true });
|
||||
} catch (_) {
|
||||
// Best-effort cleanup; keep running the healthy binary.
|
||||
}
|
||||
} catch (_) {
|
||||
restoreOldBinary();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(bin)) {
|
||||
console.error(
|
||||
`Error: lark-cli binary not found at ${bin}\n\n` +
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
// ── Dashboard CRUD ──────────────────────────────────────────────────
|
||||
|
||||
// TestBaseDashboardExecuteList tests the +dashboard-list command.
|
||||
func TestBaseDashboardExecuteList(t *testing.T) {
|
||||
t.Run("single page", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -41,6 +42,7 @@ func TestBaseDashboardExecuteList(t *testing.T) {
|
||||
|
||||
}
|
||||
|
||||
// TestBaseDashboardExecuteGet tests the +dashboard-get command.
|
||||
func TestBaseDashboardExecuteGet(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -67,6 +69,7 @@ func TestBaseDashboardExecuteGet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardExecuteCreate tests the +dashboard-create command.
|
||||
func TestBaseDashboardExecuteCreate(t *testing.T) {
|
||||
t.Run("name only", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -114,6 +117,7 @@ func TestBaseDashboardExecuteCreate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseDashboardExecuteUpdate tests the +dashboard-update command.
|
||||
func TestBaseDashboardExecuteUpdate(t *testing.T) {
|
||||
t.Run("update name", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -161,6 +165,7 @@ func TestBaseDashboardExecuteUpdate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseDashboardExecuteDelete tests the +dashboard-delete command.
|
||||
func TestBaseDashboardExecuteDelete(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -179,6 +184,7 @@ func TestBaseDashboardExecuteDelete(t *testing.T) {
|
||||
|
||||
// ── Dashboard Block CRUD ────────────────────────────────────────────
|
||||
|
||||
// TestBaseDashboardBlockExecuteList tests the +dashboard-block-list command.
|
||||
func TestBaseDashboardBlockExecuteList(t *testing.T) {
|
||||
t.Run("single page", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -208,6 +214,7 @@ func TestBaseDashboardBlockExecuteList(t *testing.T) {
|
||||
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockExecuteGet tests the +dashboard-block-get command.
|
||||
func TestBaseDashboardBlockExecuteGet(t *testing.T) {
|
||||
t.Run("basic", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -261,6 +268,7 @@ func TestBaseDashboardBlockExecuteGet(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockExecuteCreate tests the +dashboard-block-create command.
|
||||
func TestBaseDashboardBlockExecuteCreate(t *testing.T) {
|
||||
t.Run("with data-config", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -354,6 +362,7 @@ func TestBaseDashboardBlockExecuteCreate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockExecuteUpdate tests the +dashboard-block-update command.
|
||||
func TestBaseDashboardBlockExecuteUpdate(t *testing.T) {
|
||||
t.Run("update name and data-config", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -420,6 +429,7 @@ func TestBaseDashboardBlockExecuteUpdate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockExecuteDelete tests the +dashboard-block-delete command.
|
||||
func TestBaseDashboardBlockExecuteDelete(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -438,6 +448,7 @@ func TestBaseDashboardBlockExecuteDelete(t *testing.T) {
|
||||
|
||||
// ── Dry Run: Dashboard & Blocks ──────────────────────────────────────
|
||||
|
||||
// TestBaseDashboardDryRun_List tests the +dashboard-list --dry-run flag.
|
||||
func TestBaseDashboardDryRun_List(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseDashboardList, []string{"+dashboard-list", "--base-token", "app_x", "--page-size", "50", "--dry-run", "--format", "pretty"}, factory, stdout); err != nil {
|
||||
@@ -449,6 +460,7 @@ func TestBaseDashboardDryRun_List(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardDryRun_Get tests the +dashboard-get --dry-run flag.
|
||||
func TestBaseDashboardDryRun_Get(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseDashboardGet, []string{"+dashboard-get", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--dry-run", "--format", "pretty"}, factory, stdout); err != nil {
|
||||
@@ -460,6 +472,7 @@ func TestBaseDashboardDryRun_Get(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardDryRun_Create tests the +dashboard-create --dry-run flag.
|
||||
func TestBaseDashboardDryRun_Create(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-create", "--base-token", "app_x", "--name", "新报表", "--theme-style", "default", "--dry-run", "--format", "pretty"}
|
||||
@@ -472,6 +485,7 @@ func TestBaseDashboardDryRun_Create(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardDryRun_Update tests the +dashboard-update --dry-run flag.
|
||||
func TestBaseDashboardDryRun_Update(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--name", "更新名", "--dry-run", "--format", "pretty"}
|
||||
@@ -484,6 +498,7 @@ func TestBaseDashboardDryRun_Update(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardDryRun_Delete tests the +dashboard-delete --dry-run flag.
|
||||
func TestBaseDashboardDryRun_Delete(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-delete", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--dry-run", "--format", "pretty"}
|
||||
@@ -496,6 +511,7 @@ func TestBaseDashboardDryRun_Delete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockDryRun_List tests the +dashboard-block-list --dry-run flag.
|
||||
func TestBaseDashboardBlockDryRun_List(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-list", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--page-size", "10", "--dry-run", "--format", "pretty"}
|
||||
@@ -508,6 +524,7 @@ func TestBaseDashboardBlockDryRun_List(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockDryRun_Get tests the +dashboard-block-get --dry-run flag.
|
||||
func TestBaseDashboardBlockDryRun_Get(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--user-id-type", "union_id", "--dry-run", "--format", "pretty"}
|
||||
@@ -520,6 +537,7 @@ func TestBaseDashboardBlockDryRun_Get(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockDryRun_Create tests the +dashboard-block-create --dry-run flag.
|
||||
func TestBaseDashboardBlockDryRun_Create(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--name", "订单趋势", "--type", "column", "--data-config", `{"table_name":"订单表","count_all":true}`, "--user-id-type", "open_id", "--dry-run", "--format", "pretty"}
|
||||
@@ -532,6 +550,7 @@ func TestBaseDashboardBlockDryRun_Create(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockDryRun_Update tests the +dashboard-block-update --dry-run flag.
|
||||
func TestBaseDashboardBlockDryRun_Update(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--name", "订单趋势v2", "--data-config", `{"table_name":"订单表2","count_all":true}`, "--dry-run", "--format", "pretty"}
|
||||
@@ -544,6 +563,7 @@ func TestBaseDashboardBlockDryRun_Update(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockDryRun_Delete tests the +dashboard-block-delete --dry-run flag.
|
||||
func TestBaseDashboardBlockDryRun_Delete(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-delete", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--dry-run", "--format", "pretty"}
|
||||
@@ -558,6 +578,7 @@ func TestBaseDashboardBlockDryRun_Delete(t *testing.T) {
|
||||
|
||||
// ── Validator: data_config ───────────────────────────────────────────
|
||||
|
||||
// TestBaseDashboardBlockCreate_ValidateFails tests that data_config validation catches missing table_name.
|
||||
func TestBaseDashboardBlockCreate_ValidateFails(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
// 缺 table_name 且 series 与 count_all 同时存在
|
||||
@@ -574,6 +595,7 @@ func TestBaseDashboardBlockCreate_ValidateFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockCreate_NoValidateFlagAllocs tests that --no-validate flag skips client-side validation.
|
||||
func TestBaseDashboardBlockCreate_NoValidateFlagAllocs(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks",
|
||||
@@ -591,6 +613,7 @@ func TestBaseDashboardBlockCreate_NoValidateFlagAllocs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockCreate_InvalidRollup tests that invalid rollup values are rejected during validation.
|
||||
func TestBaseDashboardBlockCreate_InvalidRollup(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
// 合法 JSON,但 rollup=COUNTA(不支持)
|
||||
@@ -606,3 +629,186 @@ func TestBaseDashboardBlockCreate_InvalidRollup(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text Block Tests ────────────────────────────────────────────────
|
||||
|
||||
// TestBaseDashboardBlockExecuteCreate_TextType tests creating text blocks with markdown content.
|
||||
func TestBaseDashboardBlockExecuteCreate_TextType(t *testing.T) {
|
||||
t.Run("valid text block", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"block_id": "blk_text",
|
||||
"name": "说明文字",
|
||||
"type": "text",
|
||||
"data_config": map[string]interface{}{
|
||||
"text": "# 标题\n**加粗**",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001",
|
||||
"--name", "说明文字", "--type", "text",
|
||||
"--data-config", `{"text":"# 标题\n**加粗**"}`,
|
||||
}
|
||||
if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"blk_text"`) || !strings.Contains(got, `"created": true`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("text block missing text field", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001",
|
||||
"--name", "Bad", "--type", "text",
|
||||
"--data-config", `{}`,
|
||||
}
|
||||
err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for missing text field")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "text") || !strings.Contains(got, "data_config 校验失败") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseDashboardBlockExecuteUpdate_TextType tests updating text block content and name.
|
||||
func TestBaseDashboardBlockExecuteUpdate_TextType(t *testing.T) {
|
||||
t.Run("update text content", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_text",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"block_id": "blk_text",
|
||||
"name": "更新后的标题",
|
||||
"type": "text",
|
||||
"data_config": map[string]interface{}{
|
||||
"text": "# 新内容",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_text",
|
||||
"--name", "更新后的标题",
|
||||
"--data-config", `{"text":"# 新内容"}`,
|
||||
}
|
||||
if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"updated": true`) || !strings.Contains(got, "新内容") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("update without type skips strict validation", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
// update 不传 type,不做强类型校验,直接透传给后端
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_text",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"block_id": "blk_text",
|
||||
"type": "text",
|
||||
},
|
||||
},
|
||||
})
|
||||
args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_text",
|
||||
"--data-config", `{"content":"xxx"}`,
|
||||
}
|
||||
// 不传 type,本地不做强校验,让后端处理
|
||||
err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Dashboard Arrange ────────────────────────────────────────────────
|
||||
|
||||
// TestBaseDashboardExecuteArrange tests the +dashboard-arrange command for auto-arranging dashboard blocks.
|
||||
func TestBaseDashboardExecuteArrange(t *testing.T) {
|
||||
t.Run("arrange dashboard blocks", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/arrange",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"dashboard_id": "dsh_001",
|
||||
"name": "测试仪表盘",
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_id": "cht_xxx",
|
||||
"block_name": "组件1",
|
||||
"block_type": "column",
|
||||
"layout": map[string]interface{}{
|
||||
"x": 0, "y": 0, "w": 500, "h": 400,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
args := []string{"+dashboard-arrange", "--base-token", "app_x", "--dashboard-id", "dsh_001"}
|
||||
if err := runShortcut(t, BaseDashboardArrange, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"arranged": true`) || !strings.Contains(got, `"dashboard_id"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("arrange with user-id-type", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "user_id_type=union_id",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"dashboard_id": "dsh_001",
|
||||
"blocks": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
args := []string{"+dashboard-arrange", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--user-id-type", "union_id"}
|
||||
if err := runShortcut(t, BaseDashboardArrange, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"arranged": true`) || !strings.Contains(got, `"dashboard_id"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseDashboardDryRun_Arrange tests the +dashboard-arrange --dry-run flag includes empty body.
|
||||
func TestBaseDashboardDryRun_Arrange(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{"+dashboard-arrange", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--user-id-type", "union_id", "--dry-run", "--format", "pretty"}
|
||||
if err := runShortcut(t, BaseDashboardArrange, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "POST /open-apis/base/v3/bases/app_x/dashboards/dsh_001/arrange") || !strings.Contains(got, "union_id") || !strings.Contains(got, "{}") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,18 +63,49 @@ func TestDryRunFieldOps(t *testing.T) {
|
||||
func TestDryRunRecordOps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
listRT := newBaseTestRuntime(
|
||||
listRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "view-id": "viw_1"},
|
||||
map[string][]string{"field-id": {"Name", "Age"}},
|
||||
nil,
|
||||
map[string]int{"offset": -3, "limit": 500},
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1")
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
|
||||
|
||||
commaFieldRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"field-id": {"A,B", "C"}},
|
||||
nil,
|
||||
map[string]int{"limit": 1},
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, commaFieldRT), "limit=1", "offset=0", "field_id=A%2CB", "field_id=C")
|
||||
|
||||
searchRT := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"json": `{"view_id":"viw_1","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":-1,"limit":500}`,
|
||||
},
|
||||
nil, nil,
|
||||
)
|
||||
assertDryRunContains(
|
||||
t,
|
||||
dryRunRecordSearch(ctx, searchRT),
|
||||
"POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/search",
|
||||
`"view_id":"viw_1"`,
|
||||
`"keyword":"Created"`,
|
||||
`"search_fields":["Title","fld_owner"]`,
|
||||
`"select_fields":["Title","fld_owner"]`,
|
||||
`"offset":-1`,
|
||||
`"limit":500`,
|
||||
)
|
||||
|
||||
upsertCreateRT := newBaseTestRuntime(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`},
|
||||
nil, nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordUpsert(ctx, upsertCreateRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records")
|
||||
assertDryRunContains(t, dryRunRecordBatchCreate(ctx, upsertCreateRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_create")
|
||||
assertDryRunContains(t, dryRunRecordBatchUpdate(ctx, upsertCreateRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_update")
|
||||
|
||||
rt := newBaseTestRuntime(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "record-id": "rec_1", "json": `{"Name":"B"}`},
|
||||
@@ -211,6 +242,7 @@ func TestDryRunViewOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunViewSetWrapped(setWrappedInvalidRT, "group", "group_config"), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/group")
|
||||
|
||||
assertDryRunContains(t, dryRunViewGetFilter(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/filter")
|
||||
assertDryRunContains(t, dryRunViewGetVisibleFields(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/visible_fields")
|
||||
assertDryRunContains(t, dryRunViewGetGroup(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/group")
|
||||
assertDryRunContains(t, dryRunViewGetSort(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/sort")
|
||||
assertDryRunContains(t, dryRunViewGetTimebar(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/timebar")
|
||||
|
||||
@@ -303,7 +303,7 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFieldList, []string{"+field-list", "--base-token", "app_x", "--table-id", "tbl_x", "--offset", "0", "--limit", "1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 2`) || !strings.Contains(got, `"field_name": "Amount"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 2`) || !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"name": "Amount"`) || strings.Contains(got, `"items"`) || strings.Contains(got, `"offset"`) || strings.Contains(got, `"limit"`) || strings.Contains(got, `"count"`) || strings.Contains(got, `"field_name": "Amount"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
@@ -376,7 +376,7 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
|
||||
if err := runShortcut(t, BaseTableList, []string{"+table-list", "--base-token", "app_x", "--limit", "1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 2`) || !strings.Contains(got, `"table_name": "Alpha"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 2`) || !strings.Contains(got, `"tables"`) || !strings.Contains(got, `"name": "Alpha"`) || strings.Contains(got, `"items"`) || strings.Contains(got, `"offset"`) || strings.Contains(got, `"limit"`) || strings.Contains(got, `"count"`) || strings.Contains(got, `"table_name": "Alpha"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
@@ -427,7 +427,7 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
|
||||
if err := runShortcut(t, BaseTableGet, []string{"+table-get", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"name": "Orders"`) || !strings.Contains(got, `"primary_field": "fld_x"`) || !strings.Contains(got, `"vew_x"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"name": "Orders"`) || !strings.Contains(got, `"primary_field": "fld_x"`) || !strings.Contains(got, `"id": "fld_x"`) || !strings.Contains(got, `"name": "OrderNo"`) || !strings.Contains(got, `"id": "vew_x"`) || !strings.Contains(got, `"name": "Main"`) || strings.Contains(got, `"field_name": "OrderNo"`) || strings.Contains(got, `"view_name": "Main"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
@@ -471,6 +471,52 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list with fields and view", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=Name&field_id=Age&limit=1&offset=0&view_id=vew_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_fields"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
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 {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list with comma field", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=A%2CB&field_id=C&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"A,B", "C"},
|
||||
"record_id_list": []interface{}{"rec_json_fields"},
|
||||
"data": []interface{}{[]interface{}{"value-1", "value-2"}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
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 {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"A,B"`) || !strings.Contains(got, `"rec_json_fields"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list new shape", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -494,6 +540,72 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
searchStub := &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": "filtered_records",
|
||||
"field_scope": "selected_fields",
|
||||
"search_scope": "fld_title(Title)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
if err := runShortcut(
|
||||
t,
|
||||
BaseRecordSearch,
|
||||
[]string{
|
||||
"+record-search",
|
||||
"--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}`,
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"query_context"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(searchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"view_id":"vew_x"`) ||
|
||||
!strings.Contains(body, `"keyword":"Created"`) ||
|
||||
!strings.Contains(body, `"search_fields":["Title","fld_owner"]`) ||
|
||||
!strings.Contains(body, `"select_fields":["Title","fld_owner"]`) ||
|
||||
!strings.Contains(body, `"offset":0`) ||
|
||||
!strings.Contains(body, `"limit":2`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list legacy fields flag rejected in dry-run", 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", "--dry-run"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -552,6 +664,75 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch create", 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/batch_create",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1", "rec_2"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{"Bob"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":["Name"],"rows":[["Alice"],["Bob"]]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch update", 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/batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"update": map[string]interface{}{"Status": "Done"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Status":"Done"}}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"update"`) || !strings.Contains(got, `"Done"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch update passthrough", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Name":"Alice","Status":"Done"}}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"patch":{"Name":"Alice","Status":"Done"}`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -739,7 +920,7 @@ func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
if err := runShortcut(t, BaseViewList, []string{"+view-list", "--base-token", "app_x", "--table-id", "tbl_x", "--offset", "0", "--limit", "1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 3`) || !strings.Contains(got, `"view_name": "Main"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 3`) || !strings.Contains(got, `"views"`) || !strings.Contains(got, `"name": "Main"`) || strings.Contains(got, `"items"`) || strings.Contains(got, `"offset"`) || strings.Contains(got, `"limit"`) || strings.Contains(got, `"count"`) || strings.Contains(got, `"view_name": "Main"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
@@ -812,6 +993,61 @@ func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get-visible-fields", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1/visible_fields",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": []interface{}{"fld_primary", "fld_status"},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseViewGetVisibleFields, []string{"+view-get-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"visible_fields"`) || !strings.Contains(got, `"fld_primary"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("set-visible-fields-array-invalid", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(
|
||||
t,
|
||||
BaseViewSetVisibleFields,
|
||||
[]string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1", "--json", `["fld_status"]`},
|
||||
factory,
|
||||
stdout,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("set-visible-fields-object", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1/visible_fields",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": []interface{}{"fld_primary", "fld_status"},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
if err := runShortcut(t, BaseViewSetVisibleFields, []string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1", "--json", `{"visible_fields":["fld_status"]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
body := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(body, `"visible_fields":["fld_status"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
if strings.Contains(body, `{"visible_fields":{"visible_fields":`) {
|
||||
t.Fatalf("request body double wrapped: %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseTableExecuteListFallbackShapes(t *testing.T) {
|
||||
|
||||
@@ -18,10 +18,17 @@ import (
|
||||
)
|
||||
|
||||
func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
return newBaseTestRuntimeWithArrays(stringFlags, nil, boolFlags, intFlags)
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for name := range stringFlags {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range stringArrayFlags {
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
@@ -32,6 +39,11 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
|
||||
for name, value := range stringFlags {
|
||||
_ = cmd.Flags().Set(name, value)
|
||||
}
|
||||
for name, values := range stringArrayFlags {
|
||||
for _, value := range values {
|
||||
_ = cmd.Flags().Set(name, value)
|
||||
}
|
||||
}
|
||||
for name, value := range boolFlags {
|
||||
if value {
|
||||
_ = cmd.Flags().Set(name, "true")
|
||||
@@ -108,13 +120,19 @@ func TestWrapViewPropertyBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewSetVisibleFieldsNoValidateHook(t *testing.T) {
|
||||
if BaseViewSetVisibleFields.Validate != nil {
|
||||
t.Fatalf("expected no validate hook, got non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutsCatalog(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
want := []string{
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
|
||||
"+record-list", "+record-get", "+record-upsert", "+record-upload-attachment", "+record-delete",
|
||||
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
|
||||
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-upload-attachment", "+record-delete",
|
||||
"+record-history-list",
|
||||
"+base-get", "+base-copy", "+base-create",
|
||||
"+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable",
|
||||
@@ -122,7 +140,7 @@ func TestShortcutsCatalog(t *testing.T) {
|
||||
"+data-query",
|
||||
"+form-create", "+form-delete", "+form-list", "+form-update", "+form-get",
|
||||
"+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list",
|
||||
"+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete",
|
||||
"+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange",
|
||||
"+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete",
|
||||
}
|
||||
if len(shortcuts) != len(want) {
|
||||
@@ -234,21 +252,19 @@ func TestBaseTableValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseRecordValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if BaseRecordList.Validate != nil {
|
||||
t.Fatalf("record list validate should be nil after removing --fields")
|
||||
t.Fatalf("record list validate should be nil for repeatable --field-id")
|
||||
}
|
||||
if BaseRecordSearch.Validate != nil {
|
||||
t.Fatalf("record search validate should be nil for API passthrough")
|
||||
}
|
||||
if BaseRecordGet.Validate != nil {
|
||||
t.Fatalf("record get validate should be nil after removing --fields")
|
||||
t.Fatalf("record get validate should be nil")
|
||||
}
|
||||
if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"A"}`}, nil, nil)); err != nil {
|
||||
t.Fatalf("upsert validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": "{"}, nil, nil)); err != nil {
|
||||
t.Fatalf("invalid record json should bypass CLI validate, err=%v", err)
|
||||
if BaseRecordUpsert.Validate != nil {
|
||||
t.Fatalf("record upsert validate should be nil for API passthrough")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseViewValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil {
|
||||
|
||||
29
shortcuts/base/dashboard_arrange.go
Normal file
29
shortcuts/base/dashboard_arrange.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseDashboardArrange = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+dashboard-arrange",
|
||||
Description: "Auto-arrange dashboard blocks layout (server-side smart layout)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:dashboard:update"},
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
dashboardIDFlag(true),
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
},
|
||||
DryRun: dryRunDashboardArrange,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeDashboardArrange(runtime)
|
||||
},
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package base
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -23,7 +24,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
dashboardIDFlag(true),
|
||||
{Name: "name", Desc: "block name", Required: true},
|
||||
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡). Read dashboard-block-data-config.md before creating.", Required: true},
|
||||
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true},
|
||||
{Name: "data-config", Desc: "data config JSON object (table_name, series, count_all, group_by, filter, etc.)"},
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
|
||||
@@ -35,7 +36,11 @@ var BaseDashboardBlockCreate = common.Shortcut{
|
||||
}
|
||||
raw := runtime.Str("data-config")
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil // 允许无 data_config 的创建(某些类型可先创建后配置)
|
||||
// text 类型必须提供 data-config(含 text 内容)
|
||||
if strings.ToLower(runtime.Str("type")) == "text" {
|
||||
return fmt.Errorf("text 类型组件必须提供 data-config,包含必填字段 text")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cfg, err := parseJSONObject(pc, raw, "data-config")
|
||||
if err != nil {
|
||||
|
||||
@@ -24,7 +24,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{
|
||||
dashboardIDFlag(true),
|
||||
blockIDFlag(true),
|
||||
{Name: "name", Desc: "new block name"},
|
||||
{Name: "data-config", Desc: "data config JSON: table_name, series|count_all (mutually exclusive), group_by, filter. See dashboard-block-data-config.md for details."},
|
||||
{Name: "data-config", Desc: "data config JSON. For chart types: table_name, series|count_all, group_by, filter. For text type: text (markdown supported). See dashboard-block-data-config.md for details."},
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
|
||||
},
|
||||
@@ -42,9 +42,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
norm := normalizeDataConfig(cfg)
|
||||
if errs := validateBlockDataConfig("", norm); len(errs) > 0 { // update 时不强校验类型特性
|
||||
return formatDataConfigErrors(errs)
|
||||
}
|
||||
// update 时不做强类型校验(不传 type),让后端验证具体字段
|
||||
b, _ := json.Marshal(norm)
|
||||
_ = runtime.Cmd.Flags().Set("data-config", string(b))
|
||||
return nil
|
||||
|
||||
@@ -10,14 +10,17 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// dashboardIDFlag returns a Flag for dashboard ID.
|
||||
func dashboardIDFlag(required bool) common.Flag {
|
||||
return common.Flag{Name: "dashboard-id", Desc: "dashboard ID", Required: required}
|
||||
}
|
||||
|
||||
// blockIDFlag returns a Flag for dashboard block ID.
|
||||
func blockIDFlag(required bool) common.Flag {
|
||||
return common.Flag{Name: "block-id", Desc: "dashboard block ID", Required: required}
|
||||
}
|
||||
|
||||
// dryRunDashboardBase returns a base DryRunAPI with common dashboard parameters set.
|
||||
func dryRunDashboardBase(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
@@ -25,6 +28,7 @@ func dryRunDashboardBase(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
// dryRunDashboardList returns a DryRunAPI for listing dashboards.
|
||||
func dryRunDashboardList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{}
|
||||
if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" {
|
||||
@@ -38,11 +42,13 @@ func dryRunDashboardList(_ context.Context, runtime *common.RuntimeContext) *com
|
||||
Params(params)
|
||||
}
|
||||
|
||||
// dryRunDashboardGet returns a DryRunAPI for getting a dashboard.
|
||||
func dryRunDashboardGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return dryRunDashboardBase(runtime).
|
||||
GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id")
|
||||
}
|
||||
|
||||
// dryRunDashboardCreate returns a DryRunAPI for creating a dashboard.
|
||||
func dryRunDashboardCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{"name": runtime.Str("name")}
|
||||
if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" {
|
||||
@@ -53,6 +59,7 @@ func dryRunDashboardCreate(_ context.Context, runtime *common.RuntimeContext) *c
|
||||
Body(body)
|
||||
}
|
||||
|
||||
// dryRunDashboardUpdate returns a DryRunAPI for updating a dashboard.
|
||||
func dryRunDashboardUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
@@ -66,11 +73,13 @@ func dryRunDashboardUpdate(_ context.Context, runtime *common.RuntimeContext) *c
|
||||
Body(body)
|
||||
}
|
||||
|
||||
// dryRunDashboardDelete returns a DryRunAPI for deleting a dashboard.
|
||||
func dryRunDashboardDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return dryRunDashboardBase(runtime).
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id")
|
||||
}
|
||||
|
||||
// dryRunDashboardBlockList returns a DryRunAPI for listing dashboard blocks.
|
||||
func dryRunDashboardBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{}
|
||||
if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" {
|
||||
@@ -84,6 +93,7 @@ func dryRunDashboardBlockList(_ context.Context, runtime *common.RuntimeContext)
|
||||
Params(params)
|
||||
}
|
||||
|
||||
// dryRunDashboardBlockGet returns a DryRunAPI for getting a dashboard block.
|
||||
func dryRunDashboardBlockGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{}
|
||||
if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" {
|
||||
@@ -94,6 +104,7 @@ func dryRunDashboardBlockGet(_ context.Context, runtime *common.RuntimeContext)
|
||||
Params(params)
|
||||
}
|
||||
|
||||
// dryRunDashboardBlockCreate returns a DryRunAPI for creating a dashboard block.
|
||||
func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body := map[string]interface{}{}
|
||||
@@ -119,6 +130,7 @@ func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContex
|
||||
Body(body)
|
||||
}
|
||||
|
||||
// dryRunDashboardBlockUpdate returns a DryRunAPI for updating a dashboard block.
|
||||
func dryRunDashboardBlockUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body := map[string]interface{}{}
|
||||
@@ -140,6 +152,7 @@ func dryRunDashboardBlockUpdate(_ context.Context, runtime *common.RuntimeContex
|
||||
Body(body)
|
||||
}
|
||||
|
||||
// dryRunDashboardBlockDelete returns a DryRunAPI for deleting a dashboard block.
|
||||
func dryRunDashboardBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return dryRunDashboardBase(runtime).
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id")
|
||||
@@ -147,6 +160,7 @@ func dryRunDashboardBlockDelete(_ context.Context, runtime *common.RuntimeContex
|
||||
|
||||
// ── Dashboard CRUD ──────────────────────────────────────────────────
|
||||
|
||||
// executeDashboardList lists all dashboards in a base.
|
||||
func executeDashboardList(runtime *common.RuntimeContext) error {
|
||||
params := map[string]interface{}{}
|
||||
if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" {
|
||||
@@ -163,6 +177,7 @@ func executeDashboardList(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardGet retrieves a dashboard by ID.
|
||||
func executeDashboardGet(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id")), nil, nil)
|
||||
if err != nil {
|
||||
@@ -172,6 +187,7 @@ func executeDashboardGet(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardCreate creates a new dashboard.
|
||||
func executeDashboardCreate(runtime *common.RuntimeContext) error {
|
||||
body := map[string]interface{}{"name": runtime.Str("name")}
|
||||
if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" {
|
||||
@@ -185,6 +201,7 @@ func executeDashboardCreate(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardUpdate updates an existing dashboard.
|
||||
func executeDashboardUpdate(runtime *common.RuntimeContext) error {
|
||||
body := map[string]interface{}{}
|
||||
if name := strings.TrimSpace(runtime.Str("name")); name != "" {
|
||||
@@ -201,6 +218,7 @@ func executeDashboardUpdate(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardDelete deletes a dashboard by ID.
|
||||
func executeDashboardDelete(runtime *common.RuntimeContext) error {
|
||||
_, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id")), nil, nil)
|
||||
if err != nil {
|
||||
@@ -212,6 +230,7 @@ func executeDashboardDelete(runtime *common.RuntimeContext) error {
|
||||
|
||||
// ── Dashboard Block CRUD ────────────────────────────────────────────
|
||||
|
||||
// executeDashboardBlockList lists all blocks in a dashboard.
|
||||
func executeDashboardBlockList(runtime *common.RuntimeContext) error {
|
||||
params := map[string]interface{}{}
|
||||
if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" {
|
||||
@@ -228,6 +247,7 @@ func executeDashboardBlockList(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardBlockGet retrieves a dashboard block by ID.
|
||||
func executeDashboardBlockGet(runtime *common.RuntimeContext) error {
|
||||
params := map[string]interface{}{}
|
||||
if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" {
|
||||
@@ -241,6 +261,7 @@ func executeDashboardBlockGet(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardBlockCreate creates a new dashboard block.
|
||||
func executeDashboardBlockCreate(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body := map[string]interface{}{}
|
||||
@@ -271,6 +292,7 @@ func executeDashboardBlockCreate(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardBlockUpdate updates an existing dashboard block.
|
||||
func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body := map[string]interface{}{}
|
||||
@@ -297,6 +319,7 @@ func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDashboardBlockDelete deletes a dashboard block by ID.
|
||||
func executeDashboardBlockDelete(runtime *common.RuntimeContext) error {
|
||||
_, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), nil, nil)
|
||||
if err != nil {
|
||||
@@ -305,3 +328,36 @@ func executeDashboardBlockDelete(runtime *common.RuntimeContext) error {
|
||||
runtime.Out(map[string]interface{}{"deleted": true, "block_id": runtime.Str("block-id")}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Dashboard Arrange ────────────────────────────────────────────────
|
||||
|
||||
// dryRunDashboardArrange returns a DryRunAPI for the dashboard arrange endpoint.
|
||||
func dryRunDashboardArrange(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{}
|
||||
if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" {
|
||||
params["user_id_type"] = userIDType
|
||||
}
|
||||
return dryRunDashboardBase(runtime).
|
||||
POST("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/arrange").
|
||||
Params(params).
|
||||
Body(map[string]interface{}{})
|
||||
}
|
||||
|
||||
// executeDashboardArrange sends a POST request to auto-arrange dashboard blocks layout.
|
||||
func executeDashboardArrange(runtime *common.RuntimeContext) error {
|
||||
params := map[string]interface{}{}
|
||||
if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" {
|
||||
params["user_id_type"] = userIDType
|
||||
}
|
||||
// 请求体为空对象,由服务端智能重排
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "arrange"), params, map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
data["arranged"] = true
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ func executeFieldList(runtime *common.RuntimeContext) error {
|
||||
if total == 0 {
|
||||
total = len(fields)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"items": simplifyFields(fields), "offset": offset, "limit": limit, "count": len(fields), "total": total}, nil)
|
||||
runtime.Out(map[string]interface{}{"fields": fields, "total": total}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -379,7 +379,18 @@ func baseV3Path(parts ...string) string {
|
||||
func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
for k, v := range params {
|
||||
queryParams.Set(k, fmt.Sprintf("%v", v))
|
||||
switch val := v.(type) {
|
||||
case []string:
|
||||
for _, item := range val {
|
||||
queryParams.Add(k, item)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range val {
|
||||
queryParams.Add(k, fmt.Sprintf("%v", item))
|
||||
}
|
||||
default:
|
||||
queryParams.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: strings.ToUpper(method),
|
||||
@@ -662,45 +673,6 @@ func viewName(view map[string]interface{}) string {
|
||||
return v
|
||||
}
|
||||
|
||||
func viewType(view map[string]interface{}) string {
|
||||
if v, _ := view["type"].(string); v != "" {
|
||||
return v
|
||||
}
|
||||
v, _ := view["view_type"].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func simplifyFields(fields []map[string]interface{}) []interface{} {
|
||||
items := make([]interface{}, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
entry := map[string]interface{}{
|
||||
"field_id": fieldID(field),
|
||||
"field_name": fieldName(field),
|
||||
"type": fieldTypeName(field),
|
||||
}
|
||||
if style, ok := field["style"].(map[string]interface{}); ok && len(style) > 0 {
|
||||
entry["style"] = style
|
||||
}
|
||||
if multiple, ok := field["multiple"].(bool); ok {
|
||||
entry["multiple"] = multiple
|
||||
}
|
||||
items = append(items, entry)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func simplifyViews(views []map[string]interface{}) []interface{} {
|
||||
items := make([]interface{}, 0, len(views))
|
||||
for _, view := range views {
|
||||
items = append(items, map[string]interface{}{
|
||||
"view_id": viewID(view),
|
||||
"view_name": viewName(view),
|
||||
"view_type": viewType(view),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func canonicalValue(v interface{}) string {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
@@ -984,6 +956,8 @@ func sleepBetweenBatches(index int, total int) {
|
||||
|
||||
// ── Dashboard Block data_config normalization & validation ───────────
|
||||
|
||||
// normalizeDataConfig normalizes data_config fields for dashboard blocks.
|
||||
// It converts series[].rollup to uppercase and group_by[].sort fields to lowercase.
|
||||
func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
@@ -1025,8 +999,21 @@ func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} {
|
||||
return out
|
||||
}
|
||||
|
||||
// validateBlockDataConfig validates data_config based on block type.
|
||||
// For text type, it checks for the presence of text field.
|
||||
// For chart types, it validates table_name, series/count_all, group_by, and filter fields.
|
||||
func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []string {
|
||||
var errs []string
|
||||
|
||||
// text 类型特殊校验:只需要有 text 字段即可
|
||||
if strings.ToLower(blockType) == "text" {
|
||||
if txt, _ := cfg["text"].(string); strings.TrimSpace(txt) == "" {
|
||||
errs = append(errs, "text 类型组件缺少必填字段 text")
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// 图表类型通用校验
|
||||
// table_name 必填
|
||||
if tn, _ := cfg["table_name"].(string); strings.TrimSpace(tn) == "" {
|
||||
errs = append(errs, "缺少必填字段 table_name")
|
||||
|
||||
@@ -198,7 +198,7 @@ func TestRecordAndChunkHelpers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndSimplifyHelpers(t *testing.T) {
|
||||
func TestResolveHelpers(t *testing.T) {
|
||||
fields := []map[string]interface{}{{"id": "fld_1", "name": "Name", "type": "text"}, {"field_id": "fld_2", "field_name": "Age", "type": "number", "multiple": true}}
|
||||
tables := []map[string]interface{}{{"id": "tbl_1", "name": "Orders"}}
|
||||
views := []map[string]interface{}{{"id": "vew_1", "name": "Main", "type": "grid"}}
|
||||
@@ -214,14 +214,6 @@ func TestResolveAndSimplifyHelpers(t *testing.T) {
|
||||
if _, err := resolveViewRef(views, "Missing"); err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
simplifiedFields := simplifyFields(fields)
|
||||
if len(simplifiedFields) != 2 {
|
||||
t.Fatalf("simplifiedFields=%v", simplifiedFields)
|
||||
}
|
||||
simplifiedViews := simplifyViews(views)
|
||||
if len(simplifiedViews) != 1 {
|
||||
t.Fatalf("simplifiedViews=%v", simplifiedViews)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterAndSortHelpers(t *testing.T) {
|
||||
@@ -314,9 +306,6 @@ func TestIdentifierAndValueHelpers(t *testing.T) {
|
||||
if viewName(map[string]interface{}{"view_name": "Main"}) != "Main" {
|
||||
t.Fatalf("viewName alt key failed")
|
||||
}
|
||||
if viewType(map[string]interface{}{"view_type": "grid"}) != "grid" {
|
||||
t.Fatalf("viewType alt key failed")
|
||||
}
|
||||
if !valueEmpty(nil) || !valueEmpty(" ") || !valueEmpty([]interface{}{}) || !valueEmpty(map[string]interface{}{}) {
|
||||
t.Fatalf("valueEmpty empty cases failed")
|
||||
}
|
||||
|
||||
32
shortcuts/base/record_batch_create.go
Normal file
32
shortcuts/base/record_batch_create.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseRecordBatchCreate = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-batch-create",
|
||||
Description: "Batch create records",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:record:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "batch create JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
|
||||
},
|
||||
DryRun: dryRunRecordBatchCreate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordBatchCreate(runtime)
|
||||
},
|
||||
}
|
||||
32
shortcuts/base/record_batch_update.go
Normal file
32
shortcuts/base/record_batch_update.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseRecordBatchUpdate = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-batch-update",
|
||||
Description: "Batch update records",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:record:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "batch update JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
|
||||
},
|
||||
DryRun: dryRunRecordBatchUpdate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordBatchUpdate(runtime)
|
||||
},
|
||||
}
|
||||
@@ -19,6 +19,7 @@ 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"},
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
|
||||
|
||||
@@ -5,6 +5,8 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -15,13 +17,18 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
|
||||
offset = 0
|
||||
}
|
||||
limit := common.ParseIntBounded(runtime, "limit", 1, 200)
|
||||
params := map[string]interface{}{"offset": offset, "limit": limit}
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
params["view_id"] = viewID
|
||||
params := url.Values{}
|
||||
params.Set("offset", strconv.Itoa(offset))
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
for _, field := range recordListFields(runtime) {
|
||||
params.Add("field_id", field)
|
||||
}
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
params.Set("view_id", viewID)
|
||||
}
|
||||
path := "/open-apis/base/v3/bases/:base_token/tables/:table_id/records?" + params.Encode()
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records").
|
||||
Params(params).
|
||||
GET(path).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
@@ -34,6 +41,16 @@ func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
}
|
||||
|
||||
func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/search").
|
||||
Body(body).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func dryRunRecordUpsert(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
@@ -52,6 +69,26 @@ func dryRunRecordUpsert(_ context.Context, runtime *common.RuntimeContext) *comm
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func dryRunRecordBatchCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_create").
|
||||
Body(body).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func dryRunRecordBatchUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_update").
|
||||
Body(body).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func dryRunRecordDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
@@ -79,6 +116,10 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
return runtime.StrArray("field-id")
|
||||
}
|
||||
|
||||
func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
offset := runtime.Int("offset")
|
||||
if offset < 0 {
|
||||
@@ -86,6 +127,10 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
limit := common.ParseIntBounded(runtime, "limit", 1, 200)
|
||||
params := map[string]interface{}{"offset": offset, "limit": limit}
|
||||
fields := recordListFields(runtime)
|
||||
if len(fields) > 0 {
|
||||
params["field_id"] = fields
|
||||
}
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
params["view_id"] = viewID
|
||||
}
|
||||
@@ -106,6 +151,20 @@ func executeRecordGet(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordSearch(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "search"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordUpsert(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
@@ -130,6 +189,36 @@ func executeRecordUpsert(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordBatchCreate(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_create"), nil, body)
|
||||
data, err := handleBaseAPIResult(result, err, "batch create records")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordBatchUpdate(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_update"), nil, body)
|
||||
data, err := handleBaseAPIResult(result, err, "batch update records")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordDelete(runtime *common.RuntimeContext) error {
|
||||
_, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil)
|
||||
if err != nil {
|
||||
|
||||
32
shortcuts/base/record_search.go
Normal file
32
shortcuts/base/record_search.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseRecordSearch = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-search",
|
||||
Description: "Search records in a table",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:record:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "record search JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
|
||||
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
|
||||
},
|
||||
DryRun: dryRunRecordSearch,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordSearch(runtime)
|
||||
},
|
||||
}
|
||||
@@ -26,9 +26,6 @@ var BaseRecordUpsert = common.Shortcut{
|
||||
`Example: --json '{"Name":"Alice"}'`,
|
||||
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordUpsert,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordUpsert(runtime)
|
||||
|
||||
@@ -25,6 +25,8 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseViewDelete,
|
||||
BaseViewGetFilter,
|
||||
BaseViewSetFilter,
|
||||
BaseViewGetVisibleFields,
|
||||
BaseViewSetVisibleFields,
|
||||
BaseViewGetGroup,
|
||||
BaseViewSetGroup,
|
||||
BaseViewGetSort,
|
||||
@@ -35,8 +37,11 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseViewSetCard,
|
||||
BaseViewRename,
|
||||
BaseRecordList,
|
||||
BaseRecordSearch,
|
||||
BaseRecordGet,
|
||||
BaseRecordUpsert,
|
||||
BaseRecordBatchCreate,
|
||||
BaseRecordBatchUpdate,
|
||||
BaseRecordUploadAttachment,
|
||||
BaseRecordDelete,
|
||||
BaseRecordHistoryList,
|
||||
@@ -71,6 +76,7 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseDashboardCreate,
|
||||
BaseDashboardUpdate,
|
||||
BaseDashboardDelete,
|
||||
BaseDashboardArrange,
|
||||
BaseDashboardBlockList,
|
||||
BaseDashboardBlockGet,
|
||||
BaseDashboardBlockCreate,
|
||||
|
||||
@@ -68,11 +68,7 @@ func executeTableList(runtime *common.RuntimeContext) error {
|
||||
if total == 0 {
|
||||
total = len(tables)
|
||||
}
|
||||
items := make([]interface{}, 0, len(tables))
|
||||
for _, table := range tables {
|
||||
items = append(items, map[string]interface{}{"table_id": tableID(table), "table_name": tableNameFromMap(table)})
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"items": items, "offset": offset, "limit": limit, "count": len(items), "total": total}, nil)
|
||||
runtime.Out(map[string]interface{}{"tables": tables, "total": total}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -93,8 +89,8 @@ func executeTableGet(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"table": table,
|
||||
"fields": simplifyFields(fields),
|
||||
"views": simplifyViews(views),
|
||||
"fields": fields,
|
||||
"views": views,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
24
shortcuts/base/view_get_visible_fields.go
Normal file
24
shortcuts/base/view_get_visible_fields.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseViewGetVisibleFields = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+view-get-visible-fields",
|
||||
Description: "Get view visible fields configuration",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:view:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)},
|
||||
DryRun: dryRunViewGetVisibleFields,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeViewGetProperty(runtime, "visible_fields", "visible_fields")
|
||||
},
|
||||
}
|
||||
@@ -80,10 +80,18 @@ func dryRunViewGetFilter(_ context.Context, runtime *common.RuntimeContext) *com
|
||||
return dryRunViewGetProperty(runtime, "filter")
|
||||
}
|
||||
|
||||
func dryRunViewGetVisibleFields(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return dryRunViewGetProperty(runtime, "visible_fields")
|
||||
}
|
||||
|
||||
func dryRunViewSetFilter(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return dryRunViewSetJSONObject(runtime, "filter")
|
||||
}
|
||||
|
||||
func dryRunViewSetVisibleFields(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return dryRunViewSetJSONObject(runtime, "visible_fields")
|
||||
}
|
||||
|
||||
func dryRunViewGetGroup(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return dryRunViewGetProperty(runtime, "group")
|
||||
}
|
||||
@@ -154,7 +162,7 @@ func executeViewList(runtime *common.RuntimeContext) error {
|
||||
if total == 0 {
|
||||
total = len(views)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"items": simplifyViews(views), "offset": offset, "limit": limit, "count": len(views), "total": total}, nil)
|
||||
runtime.Out(map[string]interface{}{"views": views, "total": total}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -249,6 +257,23 @@ func executeViewSetWrapped(runtime *common.RuntimeContext, segment string, wrapp
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeViewSetVisibleFields(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
baseToken := runtime.Str("base-token")
|
||||
tableIDValue := baseTableID(runtime)
|
||||
viewRef := runtime.Str("view-id")
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := baseV3CallAny(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef, "visible_fields"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"visible_fields": data}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeViewRename(runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
tableIDValue := baseTableID(runtime)
|
||||
|
||||
@@ -20,10 +20,10 @@ var BaseViewSetSort = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "sort JSON object/array", Required: true},
|
||||
{Name: "json", Desc: "sort_config JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '[{"field":"fldPriority","desc":true}]'`,
|
||||
`Example: --json '{"sort_config":[{"field":"fldPriority","desc":true}]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
33
shortcuts/base/view_set_visible_fields.go
Normal file
33
shortcuts/base/view_set_visible_fields.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseViewSetVisibleFields = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+view-set-visible-fields",
|
||||
Description: "Set view visible fields",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:view:write_only"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: `visible fields JSON object with "visible_fields"`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"visible_fields":["fldXXX"]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
|
||||
},
|
||||
DryRun: dryRunViewSetVisibleFields,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeViewSetVisibleFields(runtime)
|
||||
},
|
||||
}
|
||||
@@ -19,7 +19,7 @@ var BaseWorkflowCreate = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"My Workflow","steps":[...]}; or @path/to/file.json for large definitions`, Required: true},
|
||||
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"My Workflow","steps":[...]}`, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -20,7 +20,7 @@ var BaseWorkflowUpdate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
||||
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"New Title","steps":[...]}; or @path/to/file.json for large definitions`, Required: true},
|
||||
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"New Title","steps":[...]}`, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -23,6 +23,7 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
|
||||
"end_time": map[string]string{"timestamp": endTs},
|
||||
"attendee_ability": "can_modify_event",
|
||||
"free_busy_status": "busy",
|
||||
"vchat": map[string]string{"vc_type": "vc"},
|
||||
"reminders": []map[string]int{
|
||||
{"minutes": 5},
|
||||
},
|
||||
|
||||
372
shortcuts/calendar/calendar_room_find.go
Normal file
372
shortcuts/calendar/calendar_room_find.go
Normal file
@@ -0,0 +1,372 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
roomFindPath = "/open-apis/calendar/v4/freebusy/room_find"
|
||||
roomFindWorkers = 10
|
||||
flagSlot = "slot"
|
||||
flagCity = "city"
|
||||
flagBuilding = "building"
|
||||
flagFloor = "floor"
|
||||
flagRoomName = "room-name"
|
||||
flagMinCapacity = "min-capacity"
|
||||
flagMaxCapacity = "max-capacity"
|
||||
)
|
||||
|
||||
type roomFindRequest struct {
|
||||
City string `json:"city,omitempty"`
|
||||
Building string `json:"building,omitempty"`
|
||||
Floor string `json:"floor,omitempty"`
|
||||
RoomName string `json:"room_name,omitempty"`
|
||||
MinCapacity int `json:"min_capacity,omitempty"`
|
||||
MaxCapacity int `json:"max_capacity,omitempty"`
|
||||
EventStartTime string `json:"event_start_time,omitempty"`
|
||||
EventEndTime string `json:"event_end_time,omitempty"`
|
||||
AttendeeUserIDs []string `json:"attendee_user_ids,omitempty"`
|
||||
AttendeeChatIDs []string `json:"attendee_chat_ids,omitempty"`
|
||||
EventRrule string `json:"event_rrule,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
type roomFindSuggestion struct {
|
||||
RoomID string `json:"room_id,omitempty"`
|
||||
RoomName string `json:"room_name,omitempty"`
|
||||
Capacity int `json:"capacity,omitempty"`
|
||||
ReserveUntilTime string `json:"reserve_until_time,omitempty"`
|
||||
}
|
||||
|
||||
type roomFindData struct {
|
||||
AvailableRooms []*roomFindSuggestion `json:"available_rooms,omitempty"`
|
||||
}
|
||||
|
||||
type roomFindSlot struct {
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
}
|
||||
|
||||
type roomFindTimeSlot struct {
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
MeetingRooms []*roomFindSuggestion `json:"meeting_rooms,omitempty"`
|
||||
}
|
||||
|
||||
type roomFindOutput struct {
|
||||
TimeSlots []*roomFindTimeSlot `json:"time_slots,omitempty"`
|
||||
}
|
||||
|
||||
func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFindSlot) ([]*roomFindSuggestion, error)) (*roomFindOutput, error) {
|
||||
if limit <= 0 {
|
||||
limit = 1
|
||||
}
|
||||
|
||||
out := &roomFindOutput{
|
||||
TimeSlots: make([]*roomFindTimeSlot, 0, len(slots)),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var firstErr error
|
||||
sem := make(chan struct{}, limit)
|
||||
|
||||
for _, slot := range slots {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(slot roomFindSlot) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
suggestions, err := fetch(slot)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
return
|
||||
}
|
||||
out.TimeSlots = append(out.TimeSlots, &roomFindTimeSlot{
|
||||
Start: slot.Start,
|
||||
End: slot.End,
|
||||
MeetingRooms: suggestions,
|
||||
})
|
||||
}(slot)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
|
||||
sort.Slice(out.TimeSlots, func(i, j int) bool {
|
||||
return out.TimeSlots[i].Start < out.TimeSlots[j].Start
|
||||
})
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseRoomFindSlots(runtime *common.RuntimeContext) ([]roomFindSlot, error) {
|
||||
rawSlots := runtime.StrArray(flagSlot)
|
||||
if len(rawSlots) == 0 {
|
||||
return nil, output.ErrValidation("specify at least one --slot")
|
||||
}
|
||||
slots := make([]roomFindSlot, 0, len(rawSlots))
|
||||
for _, raw := range rawSlots {
|
||||
parts := strings.Split(strings.TrimSpace(raw), "~")
|
||||
if len(parts) != 2 {
|
||||
return nil, output.ErrValidation("invalid --slot format %q, expected start~end", raw)
|
||||
}
|
||||
startTs, err := common.ParseTime(parts[0])
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot start time %q: %v", parts[0], err)
|
||||
}
|
||||
endTs, err := common.ParseTime(parts[1])
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot end time %q: %v", parts[1], err)
|
||||
}
|
||||
startSec, err := strconv.ParseInt(startTs, 10, 64)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err)
|
||||
}
|
||||
endSec, err := strconv.ParseInt(endTs, 10, 64)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err)
|
||||
}
|
||||
if endSec <= startSec {
|
||||
return nil, output.ErrValidation("--slot end time must be after start time: %q", raw)
|
||||
}
|
||||
startRFC3339, err := unixStringToRFC3339(startTs)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err)
|
||||
}
|
||||
endRFC3339, err := unixStringToRFC3339(endTs)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err)
|
||||
}
|
||||
slots = append(slots, roomFindSlot{Start: startRFC3339, End: endRFC3339})
|
||||
}
|
||||
return slots, nil
|
||||
}
|
||||
|
||||
func unixStringToRFC3339(ts string) (string, error) {
|
||||
sec, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return time.Unix(sec, 0).Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
func parseRoomFindAttendees(attendeesStr string, currentUserID string) ([]string, []string, error) {
|
||||
var userIDs []string
|
||||
var chatIDs []string
|
||||
seenUsers := map[string]bool{}
|
||||
seenChats := map[string]bool{}
|
||||
for _, id := range strings.Split(attendeesStr, ",") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(id, "ou_"):
|
||||
if !seenUsers[id] {
|
||||
userIDs = append(userIDs, id)
|
||||
seenUsers[id] = true
|
||||
}
|
||||
case strings.HasPrefix(id, "oc_"):
|
||||
if !seenChats[id] {
|
||||
chatIDs = append(chatIDs, id)
|
||||
seenChats[id] = true
|
||||
}
|
||||
default:
|
||||
return nil, nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id)
|
||||
}
|
||||
}
|
||||
if currentUserID != "" && !seenUsers[currentUserID] {
|
||||
userIDs = append(userIDs, currentUserID)
|
||||
}
|
||||
return userIDs, chatIDs, nil
|
||||
}
|
||||
|
||||
func buildRoomFindBaseRequest(runtime *common.RuntimeContext) (*roomFindRequest, error) {
|
||||
req := &roomFindRequest{
|
||||
City: strings.TrimSpace(runtime.Str(flagCity)),
|
||||
Building: strings.TrimSpace(runtime.Str(flagBuilding)),
|
||||
Floor: strings.TrimSpace(runtime.Str(flagFloor)),
|
||||
RoomName: strings.TrimSpace(runtime.Str(flagRoomName)),
|
||||
MinCapacity: runtime.Int(flagMinCapacity),
|
||||
MaxCapacity: runtime.Int(flagMaxCapacity),
|
||||
Timezone: strings.TrimSpace(runtime.Str(flagTimezone)),
|
||||
EventRrule: strings.TrimSpace(runtime.Str(flagEventRrule)),
|
||||
}
|
||||
|
||||
currentUserID := ""
|
||||
if !runtime.IsBot() {
|
||||
currentUserID = runtime.UserOpenId()
|
||||
}
|
||||
attendeeUserIDs, attendeeChatIDs, err := parseRoomFindAttendees(runtime.Str(flagAttendees), currentUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.AttendeeUserIDs = attendeeUserIDs
|
||||
req.AttendeeChatIDs = attendeeChatIDs
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func callRoomFind(runtime *common.RuntimeContext, req *roomFindRequest) ([]*roomFindSuggestion, error) {
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: "POST",
|
||||
ApiPath: roomFindPath,
|
||||
Body: req,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody))
|
||||
}
|
||||
|
||||
var resp = &OpenAPIResponse[*roomFindData]{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error())
|
||||
}
|
||||
|
||||
if resp.Code != 0 {
|
||||
return nil, output.ErrAPI(resp.Code, resp.Msg, resp.Data)
|
||||
}
|
||||
|
||||
if resp.Data != nil {
|
||||
return resp.Data.AvailableRooms, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var CalendarRoomFind = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+room-find",
|
||||
Description: "Find available meeting room candidates for one or more event time slots",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.free_busy:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: flagSlot, Type: "string_array", Desc: "event time slot in start~end format; repeatable"},
|
||||
{Name: flagCity, Type: "string", Desc: "meeting room city constraint"},
|
||||
{Name: flagBuilding, Type: "string", Desc: "meeting room building constraint"},
|
||||
{Name: flagFloor, Type: "string", Desc: "meeting room floor constraint (e.g., F2)"},
|
||||
{Name: flagRoomName, Type: "string", Desc: "meeting room name constraint (e.g., 木星, 02)"},
|
||||
{Name: flagMinCapacity, Type: "int", Desc: "minimum meeting room capacity"},
|
||||
{Name: flagMaxCapacity, Type: "int", Desc: "maximum meeting room capacity"},
|
||||
{Name: flagAttendees, Type: "string", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_)"},
|
||||
{Name: flagEventRrule, Type: "string", Desc: "event recurrence rule"},
|
||||
{Name: flagTimezone, Type: "string", Desc: "current time zone"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
baseReq, err := buildRoomFindBaseRequest(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
slots, err := parseRoomFindSlots(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
d := common.NewDryRunAPI()
|
||||
for _, slot := range slots {
|
||||
req := *baseReq
|
||||
req.EventStartTime = slot.Start
|
||||
req.EventEndTime = slot.End
|
||||
d.POST(roomFindPath).
|
||||
Desc(fmt.Sprintf("Lookup meeting room suggestions for %s - %s", slot.Start, slot.End)).
|
||||
Body(req)
|
||||
}
|
||||
return d
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, flag := range []string{flagCity, flagBuilding, flagFloor, flagRoomName, flagEventRrule, flagTimezone} {
|
||||
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
||||
return output.ErrValidation(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := parseRoomFindSlots(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := parseRoomFindAttendees(runtime.Str(flagAttendees), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if minCapacity := runtime.Int(flagMinCapacity); minCapacity < 0 {
|
||||
return output.ErrValidation("--min-capacity must be >= 0")
|
||||
}
|
||||
if maxCapacity := runtime.Int(flagMaxCapacity); maxCapacity < 0 {
|
||||
return output.ErrValidation("--max-capacity must be >= 0")
|
||||
}
|
||||
if minCapacity, maxCapacity := runtime.Int(flagMinCapacity), runtime.Int(flagMaxCapacity); minCapacity > 0 && maxCapacity > 0 && minCapacity > maxCapacity {
|
||||
return output.ErrValidation("--min-capacity must be <= --max-capacity")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseReq, err := buildRoomFindBaseRequest(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slots, err := parseRoomFindSlots(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := collectRoomFindResults(slots, roomFindWorkers, func(slot roomFindSlot) ([]*roomFindSuggestion, error) {
|
||||
req := *baseReq
|
||||
req.EventStartTime = slot.Start
|
||||
req.EventEndTime = slot.End
|
||||
return callRoomFind(runtime, &req)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(out, &output.Meta{Count: len(out.TimeSlots)}, func(w io.Writer) {
|
||||
if len(out.TimeSlots) == 0 {
|
||||
fmt.Fprintln(w, "No meeting room suggestions available.")
|
||||
return
|
||||
}
|
||||
for _, slot := range out.TimeSlots {
|
||||
fmt.Fprintf(w, "%s - %s\n", slot.Start, slot.End)
|
||||
var rows []map[string]interface{}
|
||||
for _, room := range slot.MeetingRooms {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"room_id": room.RoomID,
|
||||
"room_name": room.RoomName,
|
||||
"capacity": room.Capacity,
|
||||
"reserve_until_time": room.ReserveUntilTime,
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
62
shortcuts/calendar/calendar_room_find_test.go
Normal file
62
shortcuts/calendar/calendar_room_find_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCollectRoomFindResults_LimitsConcurrency(t *testing.T) {
|
||||
slots := []roomFindSlot{
|
||||
{Start: "2026-03-27T14:00:00+08:00", End: "2026-03-27T15:00:00+08:00"},
|
||||
{Start: "2026-03-27T15:00:00+08:00", End: "2026-03-27T16:00:00+08:00"},
|
||||
{Start: "2026-03-27T16:00:00+08:00", End: "2026-03-27T17:00:00+08:00"},
|
||||
}
|
||||
|
||||
entered := make(chan struct{}, len(slots))
|
||||
release := make(chan struct{})
|
||||
done := make(chan *roomFindOutput, 1)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
out, err := collectRoomFindResults(slots, 2, func(slot roomFindSlot) ([]*roomFindSuggestion, error) {
|
||||
entered <- struct{}{}
|
||||
<-release
|
||||
return []*roomFindSuggestion{{RoomName: slot.Start}}, nil
|
||||
})
|
||||
errCh <- err
|
||||
done <- out
|
||||
}()
|
||||
|
||||
for range 2 {
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for room-find workers to start")
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-entered:
|
||||
t.Fatal("room-find exceeded the configured concurrency limit")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(release)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("collectRoomFindResults returned error: %v", err)
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for room-find results")
|
||||
}
|
||||
|
||||
out := <-done
|
||||
if len(out.TimeSlots) != len(slots) {
|
||||
t.Fatalf("expected %d time slots, got %d", len(slots), len(out.TimeSlots))
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,7 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
|
||||
var CalendarSuggestion = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+suggestion",
|
||||
Description: "Intelligently suggest available meeting times to simplify scheduling",
|
||||
Description: "Intelligently suggest available time blocks based on unclear time ranges",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.free_busy:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -292,7 +292,7 @@ var CalendarSuggestion = common.Shortcut{
|
||||
Body: req,
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "request_fail", "api request fail", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices {
|
||||
|
||||
@@ -7,16 +7,18 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -88,6 +90,20 @@ func noLoginBotDefaultConfig() *core.CliConfig {
|
||||
}
|
||||
}
|
||||
|
||||
type missingTokenResolver struct{}
|
||||
|
||||
func (r *missingTokenResolver) ResolveToken(context.Context, credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, &credential.TokenUnavailableError{Source: "test", Type: credential.TokenTypeUAT}
|
||||
}
|
||||
|
||||
type staticAccountResolver struct {
|
||||
config *core.CliConfig
|
||||
}
|
||||
|
||||
func (r *staticAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return credential.AccountFromCliConfig(r.config), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarCreate tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -132,6 +148,26 @@ func TestCreate_CreateEventOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEventData_DefaultVChat(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("summary", "", "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
cmd.Flags().String("rrule", "", "")
|
||||
cmd.Flags().Set("summary", "Team Sync")
|
||||
cmd.Flags().Set("description", "Weekly meeting")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
eventData := buildEventData(runtime, "1742515200", "1742518800")
|
||||
|
||||
vchat, ok := eventData["vchat"].(map[string]string)
|
||||
if !ok {
|
||||
t.Fatalf("vchat = %T, want map[string]string", eventData["vchat"])
|
||||
}
|
||||
if got := vchat["vc_type"]; got != "vc" {
|
||||
t.Fatalf("vchat.vc_type = %q, want %q", got, "vc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_WithAttendees_Success(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
@@ -364,6 +400,11 @@ func TestCalendarShortcuts_RequireLoginUnlessExplicitBot(t *testing.T) {
|
||||
shortcut: CalendarFreebusy,
|
||||
args: []string{"+freebusy", "--start", "2025-03-21", "--end", "2025-03-21"},
|
||||
},
|
||||
{
|
||||
name: "room-find",
|
||||
shortcut: CalendarRoomFind,
|
||||
args: []string{"+room-find", "--slot", "2025-03-21T00:00:00+08:00~2025-03-21T01:00:00+08:00"},
|
||||
},
|
||||
{
|
||||
name: "rsvp",
|
||||
shortcut: CalendarRsvp,
|
||||
@@ -1023,6 +1064,255 @@ func TestSuggestion_APIError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarRoomFind tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRoomFind_MultiSlot_NewEventContext(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
for range 2 {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/freebusy/room_find",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"available_rooms": []interface{}{
|
||||
map[string]interface{}{
|
||||
"room_id": "omm_room1",
|
||||
"room_name": "F2-02",
|
||||
"capacity": 7,
|
||||
"reserve_until_time": "2026-04-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
"--slot", "2026-03-27T16:00:00+08:00~2026-03-27T17:00:00+08:00",
|
||||
"--attendee-ids", "ou_user1,ou_user2",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"time_slots\"") {
|
||||
t.Fatalf("expected aggregated time_slots output, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomFind_RejectsDangerousChars(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
"--room-name", "F2-02\x7f",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for dangerous characters")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--room-name") {
|
||||
t.Fatalf("expected dangerous char error for --room-name, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomFind_DryRun_SplitsUserAndChatAttendees(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
"--attendee-ids", "ou_user1,oc_group1",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"attendee_user_ids"`) || !strings.Contains(out, `"ou_user1"`) || !strings.Contains(out, `"attendee_chat_ids"`) || !strings.Contains(out, `"oc_group1"`) {
|
||||
t.Fatalf("dry-run should split attendee IDs by prefix, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomFind_DryRun_IncludesStructuredLocationFields(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
"--city", "北京",
|
||||
"--building", "学清嘉创大厦B座",
|
||||
"--floor", "F2",
|
||||
"--room-name", "木星",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{`"city": "北京"`, `"building": "学清嘉创大厦B座"`, `"floor": "F2"`, `"room_name": "木星"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run should include %s, got: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomFind_RequestIncludesStructuredLocationFields(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/freebusy/room_find",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"available_rooms": []interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
"--city", "北京",
|
||||
"--building", "学清嘉创大厦B座",
|
||||
"--floor", "F2",
|
||||
"--room-name", "木星",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &got); err != nil {
|
||||
t.Fatalf("unmarshal captured request: %v", err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"city": "北京",
|
||||
"building": "学清嘉创大厦B座",
|
||||
"floor": "F2",
|
||||
"room_name": "木星",
|
||||
} {
|
||||
if got[key] != want {
|
||||
t.Fatalf("expected %s=%q, got %#v", key, want, got[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomFind_RejectsInvertedOrZeroLengthSlots(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
slot string
|
||||
}{
|
||||
{
|
||||
name: "inverted",
|
||||
slot: "2026-03-27T15:00:00+08:00~2026-03-27T14:00:00+08:00",
|
||||
},
|
||||
{
|
||||
name: "zero-length",
|
||||
slot: "2026-03-27T15:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", tc.slot,
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected slot validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--slot end time must be after start time") {
|
||||
t.Fatalf("expected invalid slot range error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomFind_PreservesAuthErrorFromDoAPI(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, noLoginConfig())
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
nil,
|
||||
&staticAccountResolver{config: noLoginConfig()},
|
||||
&missingTokenResolver{},
|
||||
nil,
|
||||
)
|
||||
|
||||
err := mountAndRun(t, CalendarRoomFind, []string{
|
||||
"+room-find",
|
||||
"--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected auth error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected structured exit error, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("expected exit code %d, got %d (%v)", output.ExitAuth, exitErr.Code, err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("expected auth error detail, got %#v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestion_PreservesAuthErrorFromDoAPI(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, noLoginConfig())
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
nil,
|
||||
&staticAccountResolver{config: noLoginConfig()},
|
||||
&missingTokenResolver{},
|
||||
nil,
|
||||
)
|
||||
|
||||
err := mountAndRun(t, CalendarSuggestion, []string{
|
||||
"+suggestion",
|
||||
"--start", "2026-03-27T14:00:00+08:00",
|
||||
"--end", "2026-03-27T15:00:00+08:00",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected auth error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected structured exit error, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("expected exit code %d, got %d (%v)", output.ExitAuth, exitErr.Code, err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("expected auth error detail, got %#v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1087,17 +1377,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
||||
// Shortcuts() registration test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShortcuts_Returns5(t *testing.T) {
|
||||
func TestShortcuts_Returns6(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 5 {
|
||||
t.Fatalf("expected 5 shortcuts, got %d", len(shortcuts))
|
||||
if len(shortcuts) != 6 {
|
||||
t.Fatalf("expected 6 shortcuts, got %d", len(shortcuts))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, s := range shortcuts {
|
||||
names[s.Command] = true
|
||||
}
|
||||
for _, want := range []string{"+agenda", "+create", "+freebusy", "+rsvp", "+suggestion"} {
|
||||
for _, want := range []string{"+agenda", "+create", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing shortcut %s", want)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut {
|
||||
CalendarAgenda,
|
||||
CalendarCreate,
|
||||
CalendarFreebusy,
|
||||
CalendarRoomFind,
|
||||
CalendarRsvp,
|
||||
CalendarSuggestion,
|
||||
}
|
||||
|
||||
@@ -120,6 +120,8 @@ func permissionTargetLabel(resourceType string) string {
|
||||
return "spreadsheet"
|
||||
case "bitable", "base":
|
||||
return "base"
|
||||
case "slides":
|
||||
return "presentation"
|
||||
case "file":
|
||||
return "file"
|
||||
case "folder":
|
||||
|
||||
@@ -42,6 +42,7 @@ type RuntimeContext struct {
|
||||
resolvedAs core.Identity // effective identity resolved by framework
|
||||
Factory *cmdutil.Factory // injected by framework
|
||||
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
}
|
||||
|
||||
@@ -71,6 +72,57 @@ func (ctx *RuntimeContext) IsBot() bool {
|
||||
// UserOpenId returns the current user's open_id from config.
|
||||
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
|
||||
|
||||
// BotInfo holds bot identity metadata fetched lazily from /bot/v3/info.
|
||||
type BotInfo struct {
|
||||
OpenID string
|
||||
AppName string
|
||||
}
|
||||
|
||||
// BotInfo returns the bot's open_id and display name, fetched lazily from /bot/v3/info.
|
||||
// Unlike UserOpenId() (which reads from config), this requires a network call and may fail.
|
||||
// Thread-safe via sync.OnceValues; the API is called at most once per RuntimeContext.
|
||||
func (ctx *RuntimeContext) BotInfo() (*BotInfo, error) {
|
||||
if ctx.botInfoFunc == nil {
|
||||
return nil, fmt.Errorf("BotInfo not available (runtime context not fully initialized)")
|
||||
}
|
||||
return ctx.botInfoFunc()
|
||||
}
|
||||
|
||||
// fetchBotInfo calls /bot/v3/info using bot identity and parses the response.
|
||||
func (ctx *RuntimeContext) fetchBotInfo() (*BotInfo, error) {
|
||||
if !ctx.Config.CanBot() {
|
||||
return nil, fmt.Errorf("fetch bot info: bot identity is not available in current credential context")
|
||||
}
|
||||
resp, err := ctx.DoAPIAsBot(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/bot/v3/info",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch bot info: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("fetch bot info: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
OpenID string `json:"open_id"`
|
||||
AppName string `json:"app_name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.RawBody, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("fetch bot info: unmarshal: %w", err)
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
return nil, fmt.Errorf("fetch bot info: [%d] %s", envelope.Code, envelope.Msg)
|
||||
}
|
||||
if envelope.Data.OpenID == "" {
|
||||
return nil, fmt.Errorf("fetch bot info: open_id is empty")
|
||||
}
|
||||
return &BotInfo{OpenID: envelope.Data.OpenID, AppName: envelope.Data.AppName}, nil
|
||||
}
|
||||
|
||||
// Ctx returns the context.Context propagated from cmd.Context().
|
||||
func (ctx *RuntimeContext) Ctx() context.Context { return ctx.ctx }
|
||||
|
||||
@@ -639,6 +691,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
rctx.apiClientFunc = sync.OnceValues(func() (*client.APIClient, error) {
|
||||
return f.NewAPIClientWithConfig(config)
|
||||
})
|
||||
rctx.botInfoFunc = sync.OnceValues(rctx.fetchBotInfo)
|
||||
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
|
||||
297
shortcuts/common/runner_botinfo_test.go
Normal file
297
shortcuts/common/runner_botinfo_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// botInfoTestConfig returns a CliConfig suitable for bot info tests.
|
||||
func botInfoTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
return &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
// runBotInfoShortcut mounts a shortcut that calls BotInfo() and executes it.
|
||||
// The shortcut stores the result (or error) in the provided pointers.
|
||||
func runBotInfoShortcut(t *testing.T, f *cmdutil.Factory, gotInfo **BotInfo, gotErr *error) {
|
||||
t.Helper()
|
||||
s := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+bot-info",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *RuntimeContext) error {
|
||||
info, err := rctx.BotInfo()
|
||||
*gotInfo = info
|
||||
*gotErr = err
|
||||
return nil
|
||||
},
|
||||
}
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+bot-info", "--as", "bot"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("shortcut execution failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_Success(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "ou_bot_abc123",
|
||||
"app_name": "TestBot",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
var info *BotInfo
|
||||
var err error
|
||||
runBotInfoShortcut(t, f, &info, &err)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if info.OpenID != "ou_bot_abc123" {
|
||||
t.Errorf("OpenID = %q, want %q", info.OpenID, "ou_bot_abc123")
|
||||
}
|
||||
if info.AppName != "TestBot" {
|
||||
t.Errorf("AppName = %q, want %q", info.AppName, "TestBot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_ShortcutHeaders(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "ou_bot_header",
|
||||
"app_name": "HeaderBot",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
var info *BotInfo
|
||||
var err error
|
||||
runBotInfoShortcut(t, f, &info, &err)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Verify shortcut context headers were injected
|
||||
if stub.CapturedHeaders.Get("X-Cli-Shortcut") == "" {
|
||||
t.Error("missing X-Cli-Shortcut header on /bot/v3/info request")
|
||||
}
|
||||
if stub.CapturedHeaders.Get("X-Cli-Execution-Id") == "" {
|
||||
t.Error("missing X-Cli-Execution-Id header on /bot/v3/info request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_OnceSemantics(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
||||
// Only register one stub — if fetchBotInfo is called twice, the second call
|
||||
// would fail with "no stub" since the first stub is already matched.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "ou_bot_once",
|
||||
"app_name": "OnceBot",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
s := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+bot-info-once",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *RuntimeContext) error {
|
||||
// Call BotInfo twice — second should use cached result
|
||||
_, _ = rctx.BotInfo()
|
||||
info, err := rctx.BotInfo()
|
||||
if err != nil {
|
||||
t.Errorf("second BotInfo() call failed: %v", err)
|
||||
}
|
||||
if info.OpenID != "ou_bot_once" {
|
||||
t.Errorf("OpenID = %q, want %q", info.OpenID, "ou_bot_once")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+bot-info-once", "--as", "bot"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("shortcut execution failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_APICodeNonZero(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
var info *BotInfo
|
||||
var err error
|
||||
runBotInfoShortcut(t, f, &info, &err)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "[99991]") {
|
||||
t.Errorf("error = %q, want substring [99991]", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "",
|
||||
"app_name": "EmptyBot",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
var info *BotInfo
|
||||
var err error
|
||||
runBotInfoShortcut(t, f, &info, &err)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty open_id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "open_id is empty") {
|
||||
t.Errorf("error = %q, want substring 'open_id is empty'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_HTTP4xx(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Status: 403,
|
||||
Body: map[string]interface{}{"code": 403, "msg": "forbidden"},
|
||||
})
|
||||
|
||||
var info *BotInfo
|
||||
var err error
|
||||
runBotInfoShortcut(t, f, &info, &err)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 403")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("error = %q, want substring '403'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_InvalidJSON(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
RawBody: []byte("not json"),
|
||||
})
|
||||
|
||||
var info *BotInfo
|
||||
var err error
|
||||
runBotInfoShortcut(t, f, &info, &err)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
// Error may come from SDK-level parse or our unmarshal wrapper
|
||||
if !strings.Contains(err.Error(), "unmarshal") && !strings.Contains(err.Error(), "invalid character") {
|
||||
t.Errorf("error = %q, want JSON parse failure", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBotInfo_CanBotFalse(t *testing.T) {
|
||||
cfg := botInfoTestConfig(t)
|
||||
cfg.SupportedIdentities = 1 // user only
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
// Use a dual-auth shortcut running as user, calling BotInfo() internally.
|
||||
// No /bot/v3/info stub — CanBot should short-circuit before API call.
|
||||
var info *BotInfo
|
||||
var err error
|
||||
s := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+bot-info-canbot",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Execute: func(_ context.Context, rctx *RuntimeContext) error {
|
||||
i, e := rctx.BotInfo()
|
||||
info = i
|
||||
err = e
|
||||
return nil
|
||||
},
|
||||
}
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+bot-info-canbot", "--as", "user"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if execErr := parent.Execute(); execErr != nil {
|
||||
t.Fatalf("shortcut execution failed: %v", execErr)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error when bot identity not available")
|
||||
}
|
||||
if info != nil {
|
||||
t.Errorf("expected nil info, got %+v", info)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not available") {
|
||||
t.Errorf("error = %q, want substring 'not available'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotInfo_NilFunc(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
rctx := TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
_, err := rctx.BotInfo()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil botInfoFunc")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not fully initialized") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -27,3 +28,12 @@ func TestNewRuntimeContextWithCtx(ctx context.Context, cmd *cobra.Command, cfg *
|
||||
func TestNewRuntimeContextWithIdentity(cmd *cobra.Command, cfg *core.CliConfig, as core.Identity) *RuntimeContext {
|
||||
return &RuntimeContext{Cmd: cmd, Config: cfg, resolvedAs: as}
|
||||
}
|
||||
|
||||
// TestNewRuntimeContextWithBotInfo creates a RuntimeContext with a pre-set BotInfo for testing.
|
||||
func TestNewRuntimeContextWithBotInfo(cmd *cobra.Command, cfg *core.CliConfig, info *BotInfo) *RuntimeContext {
|
||||
rctx := &RuntimeContext{Cmd: cmd, Config: cfg}
|
||||
rctx.botInfoFunc = sync.OnceValues(func() (*BotInfo, error) {
|
||||
return info, nil
|
||||
})
|
||||
return rctx
|
||||
}
|
||||
|
||||
148
shortcuts/drive/drive_delete.go
Normal file
148
shortcuts/drive/drive_delete.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveDeleteAllowedTypes = map[string]bool{
|
||||
"file": true,
|
||||
"docx": true,
|
||||
"bitable": true,
|
||||
"doc": true,
|
||||
"sheet": true,
|
||||
"mindnote": true,
|
||||
"folder": true,
|
||||
"shortcut": true,
|
||||
"slides": true,
|
||||
}
|
||||
|
||||
// driveDeleteSpec contains the normalized input needed to issue a delete
|
||||
// request against the Drive files endpoint.
|
||||
type driveDeleteSpec struct {
|
||||
FileToken string
|
||||
FileType string
|
||||
}
|
||||
|
||||
// DriveDelete deletes a Drive file or folder and handles the async task
|
||||
// polling required by folder deletes.
|
||||
var DriveDelete = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+delete",
|
||||
Description: "Delete a file or folder in Drive",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"space:document:delete"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "file or folder token to delete", Required: true},
|
||||
{Name: "type", Desc: "file type (file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveDeleteSpec(driveDeleteSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveDeleteSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Delete file or folder in Drive")
|
||||
|
||||
dry.DELETE("/open-apis/drive/v1/files/:file_token").
|
||||
Desc("[1] Delete file/folder").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"type": spec.FileType})
|
||||
|
||||
if spec.FileType == "folder" {
|
||||
dry.GET("/open-apis/drive/v1/files/task_check").
|
||||
Desc("[2] Poll async task status (for folder delete)").
|
||||
Params(driveTaskCheckParams("<task_id>"))
|
||||
}
|
||||
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveDeleteSpec{
|
||||
FileToken: runtime.Str("file-token"),
|
||||
FileType: strings.ToLower(runtime.Str("type")),
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Deleting %s %s...\n", spec.FileType, common.MaskToken(spec.FileToken))
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"DELETE",
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)),
|
||||
map[string]interface{}{"type": spec.FileType},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.FileType == "folder" {
|
||||
taskID := common.GetString(data, "task_id")
|
||||
if taskID == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "delete folder returned no task_id")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete is async, polling task %s...\n", taskID)
|
||||
|
||||
status, ready, err := pollDriveTaskCheck(runtime, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"status": status.StatusLabel(),
|
||||
"file_token": spec.FileToken,
|
||||
"type": spec.FileType,
|
||||
"ready": ready,
|
||||
}
|
||||
if ready {
|
||||
out["deleted"] = true
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"deleted": true,
|
||||
"file_token": spec.FileToken,
|
||||
"type": spec.FileType,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func validateDriveDeleteSpec(spec driveDeleteSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if spec.FileType == "wiki" {
|
||||
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported")
|
||||
}
|
||||
if !driveDeleteAllowedTypes[spec.FileType] {
|
||||
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides", spec.FileType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
224
shortcuts/drive/drive_delete_test.go
Normal file
224
shortcuts/drive/drive_delete_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestValidateDriveDeleteSpecRejectsWiki(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveDeleteSpec(driveDeleteSpec{
|
||||
FileToken: "wiki_token_test",
|
||||
FileType: "wiki",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected wiki type error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "wiki documents are not supported") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteDryRunFolderIncludesTaskCheckParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +delete"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
if err := cmd.Flags().Set("file-token", "fld_src"); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "folder"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveDelete.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Method != "DELETE" {
|
||||
t.Fatalf("first method = %q, want DELETE", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].Params["type"] != "folder" {
|
||||
t.Fatalf("delete params = %#v", got.API[0].Params)
|
||||
}
|
||||
if got.API[1].Params["task_id"] != "<task_id>" {
|
||||
t.Fatalf("task check params = %#v", got.API[1].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteRequiresYes(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveDelete, []string{
|
||||
"+delete",
|
||||
"--file-token", "file_token_test",
|
||||
"--type", "file",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteFileSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/file_token_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveDelete, []string{
|
||||
"+delete",
|
||||
"--file-token", "file_token_test",
|
||||
"--type", "file",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"deleted": true`)) {
|
||||
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"file_token": "file_token_test"`)) {
|
||||
t.Fatalf("stdout missing file token: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
taskCheckBody map[string]interface{}
|
||||
wantErrContains string
|
||||
wantStdout []string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "success"},
|
||||
},
|
||||
wantStdout: []string{
|
||||
`"task_id": "task_123"`,
|
||||
`"deleted": true`,
|
||||
`"ready": true`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "timeout",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "process"},
|
||||
},
|
||||
wantStdout: []string{
|
||||
`"ready": false`,
|
||||
`"timed_out": true`,
|
||||
`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123 --as bot"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "failed",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "fail"},
|
||||
},
|
||||
wantErrContains: "folder task failed",
|
||||
},
|
||||
{
|
||||
name: "task_check error",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 1061001,
|
||||
"msg": "internal error",
|
||||
},
|
||||
wantErrContains: "internal error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/fld_src",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: tt.taskCheckBody,
|
||||
})
|
||||
|
||||
withSingleDriveTaskCheckPoll(t)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDelete, []string{
|
||||
"+delete",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
if tt.wantErrContains != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected delete failure, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErrContains) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, needle := range tt.wantStdout {
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(needle)) {
|
||||
t.Fatalf("stdout missing %q: %s", needle, stdout.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveTaskCheckPollMu sync.Mutex
|
||||
|
||||
func driveTestConfig() *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "drive-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -37,6 +40,18 @@ func mountAndRunDrive(t *testing.T, s common.Shortcut, args []string, f *cmdutil
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func withSingleDriveTaskCheckPoll(t *testing.T) {
|
||||
t.Helper()
|
||||
driveTaskCheckPollMu.Lock()
|
||||
|
||||
prevAttempts, prevInterval := driveTaskCheckPollAttempts, driveTaskCheckPollInterval
|
||||
driveTaskCheckPollAttempts, driveTaskCheckPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveTaskCheckPollAttempts, driveTaskCheckPollInterval = prevAttempts, prevInterval
|
||||
driveTaskCheckPollMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func withDriveWorkingDir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
cwd, err := os.Getwd()
|
||||
|
||||
@@ -115,7 +115,7 @@ var DriveMove = common.Shortcut{
|
||||
"ready": ready,
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveTaskCheckResultCommand(taskID)
|
||||
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder move task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
driveMovePollAttempts = 30
|
||||
driveMovePollInterval = 2 * time.Second
|
||||
driveTaskCheckPollAttempts = 30
|
||||
driveTaskCheckPollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
// driveMoveAllowedTypes mirrors the document kinds accepted by the Drive move
|
||||
@@ -61,7 +61,7 @@ func validateDriveMoveSpec(spec driveMoveSpec) error {
|
||||
}
|
||||
|
||||
// driveTaskCheckStatus represents the status payload returned by
|
||||
// /drive/v1/files/task_check for async folder operations.
|
||||
// /drive/v1/files/task_check for async folder move/delete operations.
|
||||
type driveTaskCheckStatus struct {
|
||||
TaskID string
|
||||
Status string
|
||||
@@ -72,7 +72,11 @@ func (s driveTaskCheckStatus) Ready() bool {
|
||||
}
|
||||
|
||||
func (s driveTaskCheckStatus) Failed() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(s.Status), "failed")
|
||||
status := strings.TrimSpace(s.Status)
|
||||
// The shared task_check endpoint is reused by multiple async flows. Some
|
||||
// backends return "failed", while folder delete can return the shorter
|
||||
// terminal state "fail".
|
||||
return strings.EqualFold(status, "failed") || strings.EqualFold(status, "fail")
|
||||
}
|
||||
|
||||
func (s driveTaskCheckStatus) Pending() bool {
|
||||
@@ -91,8 +95,8 @@ func (s driveTaskCheckStatus) StatusLabel() string {
|
||||
|
||||
// driveTaskCheckResultCommand prints the resume command shown when bounded
|
||||
// polling ends before the backend task completes.
|
||||
func driveTaskCheckResultCommand(taskID string) string {
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s", taskID)
|
||||
func driveTaskCheckResultCommand(taskID, as string) string {
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s --as %s", taskID, as)
|
||||
}
|
||||
|
||||
// driveTaskCheckParams keeps the task_check query parameter shape in one place
|
||||
@@ -130,31 +134,42 @@ func parseDriveTaskCheckStatus(taskID string, data map[string]interface{}) drive
|
||||
}
|
||||
}
|
||||
|
||||
// pollDriveTaskCheck polls the backend for a bounded period and returns the
|
||||
// last seen status so callers can emit a follow-up command when needed.
|
||||
// pollDriveTaskCheck polls the shared task_check endpoint for a bounded period
|
||||
// and returns the last seen status so callers can emit a follow-up command
|
||||
// when needed.
|
||||
func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, bool, error) {
|
||||
lastStatus := driveTaskCheckStatus{TaskID: taskID}
|
||||
for attempt := 1; attempt <= driveMovePollAttempts; attempt++ {
|
||||
var (
|
||||
seenStatus bool
|
||||
lastErr error
|
||||
)
|
||||
for attempt := 1; attempt <= driveTaskCheckPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
time.Sleep(driveMovePollInterval)
|
||||
time.Sleep(driveTaskCheckPollInterval)
|
||||
}
|
||||
|
||||
status, err := getDriveTaskCheckStatus(runtime, taskID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Error polling task %s: %s\n", taskID, err)
|
||||
continue
|
||||
}
|
||||
seenStatus = true
|
||||
lastStatus = status
|
||||
// Success and failure are terminal backend states. Any other value is kept
|
||||
// as pending so the caller can decide whether to continue or resume later.
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder move completed successfully.\n")
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Folder task completed successfully.\n")
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder move task failed")
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder task failed")
|
||||
}
|
||||
}
|
||||
|
||||
if !seenStatus && lastErr != nil {
|
||||
return driveTaskCheckStatus{}, false, lastErr
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
|
||||
@@ -102,91 +102,91 @@ func TestDriveMoveDryRunFolderIncludesTaskCheckParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMoveFolderSuccessUsesTaskCheckHelper(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
func TestDriveMoveFolderTaskCheckOutcomes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
taskCheckBody map[string]interface{}
|
||||
wantErrContains string
|
||||
wantStdout []string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "success"},
|
||||
},
|
||||
wantStdout: []string{
|
||||
`"task_id": "task_123"`,
|
||||
`"ready": true`,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "success"},
|
||||
{
|
||||
name: "timeout",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "pending"},
|
||||
},
|
||||
wantStdout: []string{
|
||||
`"ready": false`,
|
||||
`"timed_out": true`,
|
||||
`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123 --as bot"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all polls fail",
|
||||
taskCheckBody: map[string]interface{}{
|
||||
"code": 1061001,
|
||||
"msg": "internal error",
|
||||
},
|
||||
wantErrContains: "internal error",
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
|
||||
driveMovePollAttempts, driveMovePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--folder-token", "fld_dst",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_123"`)) {
|
||||
t.Fatalf("stdout missing task id: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
|
||||
t.Fatalf("stdout missing ready=true: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMoveFolderTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "pending"},
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
|
||||
driveMovePollAttempts, driveMovePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--folder-token", "fld_dst",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"timed_out": true`)) {
|
||||
t.Fatalf("stdout missing timed_out=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123"`)) {
|
||||
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_123"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: tt.taskCheckBody,
|
||||
})
|
||||
|
||||
withSingleDriveTaskCheckPoll(t)
|
||||
|
||||
err := mountAndRunDrive(t, DriveMove, []string{
|
||||
"+move",
|
||||
"--file-token", "fld_src",
|
||||
"--type", "folder",
|
||||
"--folder-token", "fld_dst",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
if tt.wantErrContains != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected task_check polling error, got nil")
|
||||
}
|
||||
if !bytes.Contains([]byte(err.Error()), []byte(tt.wantErrContains)) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, needle := range tt.wantStdout {
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(needle)) {
|
||||
t.Fatalf("stdout missing %q: %s", needle, stdout.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,3 +246,34 @@ func TestDriveTaskResultTaskCheckIncludesReadyFlags(t *testing.T) {
|
||||
t.Fatalf("stdout missing failed=false: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultTaskCheckTreatsFailAsFailed(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"status": "fail"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "task_check",
|
||||
"--task-id", "task_123",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"status": "fail"`)) {
|
||||
t.Fatalf("stdout missing fail status: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"failed": true`)) {
|
||||
t.Fatalf("stdout missing failed=true: %s", stdout.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
|
||||
t.Fatalf("stdout missing ready=false: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveExportDownload,
|
||||
DriveImport,
|
||||
DriveMove,
|
||||
DriveDelete,
|
||||
DriveTaskResult,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+export-download",
|
||||
"+import",
|
||||
"+move",
|
||||
"+delete",
|
||||
"+task_result",
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,9 @@ func TestResolveMarkdownAsPost(t *testing.T) {
|
||||
if !strings.Contains(got, `"tag":"md"`) {
|
||||
t.Fatalf("resolveMarkdownAsPost() = %q, want post payload", got)
|
||||
}
|
||||
if !strings.Contains(got, `"tag":"text"`) {
|
||||
t.Fatalf("resolveMarkdownAsPost() = %q, want segmented blank-line text paragraph", got)
|
||||
}
|
||||
if !strings.Contains(got, `#### Title`) || !strings.Contains(got, `##### Subtitle`) {
|
||||
t.Fatalf("resolveMarkdownAsPost() = %q, want optimized heading levels", got)
|
||||
}
|
||||
|
||||
@@ -764,25 +764,49 @@ func readMp4Duration(f fileio.File, fileSize int64) int64 {
|
||||
// 5. Compress excess blank lines
|
||||
// 6. Strip invalid image references (keep only img_xxx keys)
|
||||
var (
|
||||
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
|
||||
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
|
||||
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
|
||||
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
|
||||
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
|
||||
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
|
||||
reExcessNL = regexp.MustCompile(`\n{3,}`)
|
||||
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
|
||||
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
|
||||
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
|
||||
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
|
||||
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
|
||||
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
|
||||
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
|
||||
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
|
||||
reExcessNL = regexp.MustCompile(`\n{3,}`)
|
||||
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
|
||||
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
|
||||
reBlankLineSeparator = regexp.MustCompile(`\n(?:[ \t]*\n)+`)
|
||||
)
|
||||
|
||||
func optimizeMarkdownStyle(text string) string {
|
||||
const mark = "___CB_"
|
||||
const (
|
||||
markdownCodeBlockPlaceholder = "___CB_"
|
||||
postBlankLinePlaceholder = "\u200B"
|
||||
)
|
||||
|
||||
type markdownPart struct {
|
||||
text string
|
||||
newlineCount int
|
||||
isSeparator bool
|
||||
}
|
||||
|
||||
func protectMarkdownCodeBlocks(text string) (string, []string) {
|
||||
var codeBlocks []string
|
||||
r := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
|
||||
protected := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
|
||||
idx := len(codeBlocks)
|
||||
codeBlocks = append(codeBlocks, m)
|
||||
return fmt.Sprintf("%s%d___", mark, idx)
|
||||
return fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, idx)
|
||||
})
|
||||
return protected, codeBlocks
|
||||
}
|
||||
|
||||
func restoreMarkdownCodeBlocks(text string, codeBlocks []string) string {
|
||||
restored := text
|
||||
for i, block := range codeBlocks {
|
||||
restored = strings.Replace(restored, fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, i), block, 1)
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
func optimizeMarkdownStyle(text string) string {
|
||||
r, codeBlocks := protectMarkdownCodeBlocks(text)
|
||||
|
||||
// Only downgrade when original text has H1~H3; order matters (H2~H6 first).
|
||||
if reHasH1toH3.MatchString(text) {
|
||||
@@ -795,9 +819,7 @@ func optimizeMarkdownStyle(text string) string {
|
||||
r = reTableNoGap.ReplaceAllString(r, "$1\n\n$2")
|
||||
r = reTableAfter.ReplaceAllString(r, "$1\n")
|
||||
|
||||
for i, block := range codeBlocks {
|
||||
r = strings.Replace(r, fmt.Sprintf("%s%d___", mark, i), block, 1)
|
||||
}
|
||||
r = restoreMarkdownCodeBlocks(r, codeBlocks)
|
||||
|
||||
r = reExcessNL.ReplaceAllString(r, "\n\n")
|
||||
|
||||
@@ -816,12 +838,109 @@ func optimizeMarkdownStyle(text string) string {
|
||||
return r
|
||||
}
|
||||
|
||||
func shouldUseSegmentedPost(markdown string) bool {
|
||||
protected, _ := protectMarkdownCodeBlocks(markdown)
|
||||
return reBlankLineSeparator.MatchString(protected)
|
||||
}
|
||||
|
||||
func splitMarkdownByBlankLines(markdown string) []markdownPart {
|
||||
protected, codeBlocks := protectMarkdownCodeBlocks(markdown)
|
||||
locs := reBlankLineSeparator.FindAllStringIndex(protected, -1)
|
||||
if len(locs) == 0 {
|
||||
return []markdownPart{{text: markdown}}
|
||||
}
|
||||
|
||||
parts := make([]markdownPart, 0, len(locs)*2+1)
|
||||
last := 0
|
||||
for _, loc := range locs {
|
||||
if loc[0] > last {
|
||||
content := restoreMarkdownCodeBlocks(protected[last:loc[0]], codeBlocks)
|
||||
if content != "" {
|
||||
parts = append(parts, markdownPart{text: content})
|
||||
}
|
||||
}
|
||||
separator := protected[loc[0]:loc[1]]
|
||||
parts = append(parts, markdownPart{
|
||||
isSeparator: true,
|
||||
newlineCount: strings.Count(separator, "\n"),
|
||||
})
|
||||
last = loc[1]
|
||||
}
|
||||
|
||||
if last < len(protected) {
|
||||
content := restoreMarkdownCodeBlocks(protected[last:], codeBlocks)
|
||||
if content != "" {
|
||||
parts = append(parts, markdownPart{text: content})
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return []markdownPart{{text: markdown}}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func marshalMarkdownPostContent(content [][]map[string]interface{}) string {
|
||||
payload := map[string]interface{}{
|
||||
"zh_cn": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func buildSingleMDPost(markdown string) string {
|
||||
return marshalMarkdownPostContent([][]map[string]interface{}{
|
||||
{{
|
||||
"tag": "md",
|
||||
"text": optimizeMarkdownStyle(markdown),
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func buildSegmentedPost(markdown string) string {
|
||||
parts := splitMarkdownByBlankLines(markdown)
|
||||
content := make([][]map[string]interface{}, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part.isSeparator {
|
||||
for i := 1; i < part.newlineCount; i++ {
|
||||
content = append(content, []map[string]interface{}{{
|
||||
"tag": "text",
|
||||
"text": postBlankLinePlaceholder,
|
||||
}})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if part.text == "" {
|
||||
continue
|
||||
}
|
||||
optimized := strings.Trim(optimizeMarkdownStyle(part.text), "\n")
|
||||
if optimized == "" {
|
||||
continue
|
||||
}
|
||||
content = append(content, []map[string]interface{}{{
|
||||
"tag": "md",
|
||||
"text": optimized,
|
||||
}})
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return buildSingleMDPost(markdown)
|
||||
}
|
||||
return marshalMarkdownPostContent(content)
|
||||
}
|
||||
|
||||
func buildMarkdownPostContent(markdown string) string {
|
||||
if shouldUseSegmentedPost(markdown) {
|
||||
return buildSegmentedPost(markdown)
|
||||
}
|
||||
return buildSingleMDPost(markdown)
|
||||
}
|
||||
|
||||
// wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network).
|
||||
// Used by DryRun. Output: {"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
|
||||
// Used by DryRun. Output may include md/text paragraphs when blank-line separators are present.
|
||||
func wrapMarkdownAsPost(markdown string) string {
|
||||
optimized := optimizeMarkdownStyle(markdown)
|
||||
inner, _ := json.Marshal(optimized)
|
||||
return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}`
|
||||
return buildMarkdownPostContent(markdown)
|
||||
}
|
||||
|
||||
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\((https?://[^)\s]+)\)`)
|
||||
@@ -856,9 +975,7 @@ func wrapMarkdownAsPostForDryRun(markdown string) (content, desc string) {
|
||||
// and wraps as post format JSON. Used by Execute (makes network calls).
|
||||
func resolveMarkdownAsPost(ctx context.Context, runtime *common.RuntimeContext, markdown string) string {
|
||||
resolved := resolveMarkdownImageURLs(ctx, runtime, markdown)
|
||||
optimized := optimizeMarkdownStyle(resolved)
|
||||
inner, _ := json.Marshal(optimized)
|
||||
return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}`
|
||||
return buildMarkdownPostContent(resolved)
|
||||
}
|
||||
|
||||
// resolveMarkdownImageURLs finds  in markdown, downloads each URL,
|
||||
|
||||
@@ -6,6 +6,7 @@ package im
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -16,6 +17,36 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func decodePostContentForTest(t *testing.T, raw string) []interface{} {
|
||||
t.Helper()
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v, raw=%s", err, raw)
|
||||
}
|
||||
locale, _ := payload["zh_cn"].(map[string]interface{})
|
||||
content, _ := locale["content"].([]interface{})
|
||||
if content == nil {
|
||||
t.Fatalf("post content missing: %#v", payload)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func decodePostParagraphForTest(t *testing.T, raw string, idx int) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
content := decodePostContentForTest(t, raw)
|
||||
if idx >= len(content) {
|
||||
t.Fatalf("paragraph index %d out of range, len=%d, raw=%s", idx, len(content), raw)
|
||||
}
|
||||
paragraph, _ := content[idx].([]interface{})
|
||||
if len(paragraph) != 1 {
|
||||
t.Fatalf("paragraph %d = %#v, want single node", idx, paragraph)
|
||||
}
|
||||
node, _ := paragraph[0].(map[string]interface{})
|
||||
return node
|
||||
}
|
||||
|
||||
func TestNormalizeAtMentions(t *testing.T) {
|
||||
input := `<at id=ou_alpha/> hi <at open_id="ou_beta"> and <at user_id=ou_gamma /> and <at email="x@example.com"/>`
|
||||
got := normalizeAtMentions(input)
|
||||
@@ -140,6 +171,16 @@ func TestWrapMarkdownAsPostForDryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPostForDryRun_SegmentedBlankLines(t *testing.T) {
|
||||
content, _ := wrapMarkdownAsPostForDryRun("hello\n\n")
|
||||
if !strings.Contains(content, ``) {
|
||||
t.Fatalf("wrapMarkdownAsPostForDryRun(segmented) content = %q, want placeholder img key", content)
|
||||
}
|
||||
if !strings.Contains(content, `"tag":"text"`) {
|
||||
t.Fatalf("wrapMarkdownAsPostForDryRun(segmented) content = %q, want blank-line text paragraph", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaContentWithoutUploads(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -334,15 +375,88 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
|
||||
|
||||
func TestWrapMarkdownAsPost(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("hello **world**")
|
||||
// Should produce valid JSON with post structure
|
||||
if !strings.Contains(got, `"tag":"md"`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() missing md tag: %s", got)
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
|
||||
}
|
||||
if !strings.Contains(got, `"zh_cn"`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() missing zh_cn: %s", got)
|
||||
node := decodePostParagraphForTest(t, got, 0)
|
||||
if node["tag"] != "md" {
|
||||
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
|
||||
}
|
||||
if !strings.Contains(got, "hello **world**") {
|
||||
t.Fatalf("wrapMarkdownAsPost() missing content: %s", got)
|
||||
if node["text"] != "hello **world**" {
|
||||
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldUseSegmentedPost(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
want bool
|
||||
}{
|
||||
{name: "single newline", markdown: "a\nb", want: false},
|
||||
{name: "blank line", markdown: "a\n\nb", want: true},
|
||||
{name: "blank line with spaces", markdown: "a\n \nb", want: true},
|
||||
{name: "multiple blank lines", markdown: "a\n \n \n b", want: true},
|
||||
{name: "blank lines inside code block only", markdown: "```go\n\n\nfmt.Println(1)\n```\nnext", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldUseSegmentedPost(tt.markdown); got != tt.want {
|
||||
t.Fatalf("shouldUseSegmentedPost(%q) = %v, want %v", tt.markdown, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPost_SegmentedBlankLines(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("a\n\nb")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 3 {
|
||||
t.Fatalf("wrapMarkdownAsPost(a\\n\\nb) content len = %d, want 3", len(content))
|
||||
}
|
||||
|
||||
first := decodePostParagraphForTest(t, got, 0)
|
||||
if first["tag"] != "md" || first["text"] != "a" {
|
||||
t.Fatalf("first paragraph = %#v, want md/a", first)
|
||||
}
|
||||
|
||||
second := decodePostParagraphForTest(t, got, 1)
|
||||
if second["tag"] != "text" || second["text"] != postBlankLinePlaceholder {
|
||||
t.Fatalf("second paragraph = %#v, want blank text placeholder", second)
|
||||
}
|
||||
|
||||
third := decodePostParagraphForTest(t, got, 2)
|
||||
if third["tag"] != "md" || third["text"] != "b" {
|
||||
t.Fatalf("third paragraph = %#v, want md/b", third)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPost_SegmentedMultipleBlankLines(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("a\n\n\nb")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 4 {
|
||||
t.Fatalf("wrapMarkdownAsPost(a\\n\\n\\nb) content len = %d, want 4", len(content))
|
||||
}
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
node := decodePostParagraphForTest(t, got, i)
|
||||
if node["tag"] != "text" || node["text"] != postBlankLinePlaceholder {
|
||||
t.Fatalf("blank paragraph %d = %#v, want blank text placeholder", i, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPost_SegmentedBlankLinesWithSpaces(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("a\n \nb")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 3 {
|
||||
t.Fatalf("wrapMarkdownAsPost(a\\n \\nb) content len = %d, want 3", len(content))
|
||||
}
|
||||
node := decodePostParagraphForTest(t, got, 1)
|
||||
if node["tag"] != "text" || node["text"] != postBlankLinePlaceholder {
|
||||
t.Fatalf("middle paragraph = %#v, want blank text placeholder", node)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,10 @@ var MailTriage = common.Shortcut{
|
||||
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "table", Desc: "output format: table | json | data (both json/data output messages array only)"},
|
||||
{Name: "format", Default: "table", Desc: "output format: table | json | data (json/data output object with pagination fields)"},
|
||||
{Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
|
||||
{Name: "page-size", Type: "int", Desc: "alias for --max"},
|
||||
{Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"},
|
||||
{Name: "filter", Desc: `exact-match condition filter (JSON). Narrow results by folder, label, sender, recipient, etc. Run --print-filter-schema to see all fields. Example: {"folder":"INBOX","from":["alice@example.com"]}`},
|
||||
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
|
||||
{Name: "query", Desc: `full-text keyword search across from/to/subject/body (max 50 chars). Example: "budget report"`},
|
||||
@@ -66,13 +68,21 @@ var MailTriage = common.Shortcut{
|
||||
mailbox := resolveMailboxID(runtime)
|
||||
query := runtime.Str("query")
|
||||
showLabels := runtime.Bool("labels")
|
||||
maxCount := normalizeTriageMax(runtime.Int("max"))
|
||||
maxCount := resolveTriagePageSize(runtime)
|
||||
parsed, parseErr := parseTriagePageToken(runtime.Str("page-token"))
|
||||
filter, err := parseTriageFilter(runtime.Str("filter"))
|
||||
d := common.NewDryRunAPI().Set("input_filter", runtime.Str("filter"))
|
||||
if parseErr != nil {
|
||||
return d.Set("filter_error", parseErr.Error())
|
||||
}
|
||||
if err != nil {
|
||||
return d.Set("filter_error", err.Error())
|
||||
}
|
||||
if usesTriageSearchPath(query, filter) {
|
||||
useSearch, pathErr := resolveTriagePath(parsed, query, filter)
|
||||
if pathErr != nil {
|
||||
return d.Set("filter_error", pathErr.Error())
|
||||
}
|
||||
if useSearch {
|
||||
resolvedFilter, err := resolveSearchFilter(runtime, mailbox, filter, true)
|
||||
if err != nil {
|
||||
return d.Set("filter_error", err.Error())
|
||||
@@ -81,11 +91,15 @@ var MailTriage = common.Shortcut{
|
||||
if pageSize > searchPageMax {
|
||||
pageSize = searchPageMax
|
||||
}
|
||||
searchParams, searchBody, _ := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, "", true)
|
||||
searchDesc := "search messages (auto-paginates up to --max)"
|
||||
if parsed.RawToken != "" {
|
||||
searchDesc = "search messages (continues from --page-token, up to --max)"
|
||||
}
|
||||
searchParams, searchBody, _ := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, parsed.RawToken, true)
|
||||
d = d.POST(mailboxPath(mailbox, "search")).
|
||||
Params(searchParams).
|
||||
Body(searchBody).
|
||||
Desc("search messages (auto-paginates up to --max)")
|
||||
Desc(searchDesc)
|
||||
if showLabels {
|
||||
d = d.POST(mailboxPath(mailbox, "messages", "batch_get")).
|
||||
Body(map[string]interface{}{"format": "metadata", "message_ids": []string{"<message_id>"}}).
|
||||
@@ -101,12 +115,16 @@ var MailTriage = common.Shortcut{
|
||||
if pageSize > listPageMax {
|
||||
pageSize = listPageMax
|
||||
}
|
||||
listParams, _ := buildListParams(runtime, mailbox, resolvedFilter, pageSize, "", true)
|
||||
listDesc := "list message IDs (auto-paginates up to --max); batch_get with format=metadata"
|
||||
if parsed.RawToken != "" {
|
||||
listDesc = "list message IDs (continues from --page-token, up to --max); batch_get with format=metadata"
|
||||
}
|
||||
listParams, _ := buildListParams(runtime, mailbox, resolvedFilter, pageSize, parsed.RawToken, true)
|
||||
return d.GET(mailboxPath(mailbox, "messages")).
|
||||
Params(listParams).
|
||||
POST(mailboxPath(mailbox, "messages", "batch_get")).
|
||||
Body(map[string]interface{}{"format": "metadata", "message_ids": []string{"<message_id>"}}).
|
||||
Desc("list message IDs (auto-paginates up to --max); batch_get with format=metadata").
|
||||
Desc(listDesc).
|
||||
Set("resolve_note", "name→ID resolution for filter.folder/filter.label runs during execution; dry-run does not call folders/labels list APIs")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -128,16 +146,27 @@ var MailTriage = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maxCount := normalizeTriageMax(runtime.Int("max"))
|
||||
maxCount := resolveTriagePageSize(runtime)
|
||||
parsed, err := parseTriagePageToken(runtime.Str("page-token"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var messages []map[string]interface{}
|
||||
var hasMore bool
|
||||
var nextPageToken string
|
||||
|
||||
if usesTriageSearchPath(query, filter) {
|
||||
useSearch, err := resolveTriagePath(parsed, query, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if useSearch {
|
||||
resolvedFilter, err := resolveSearchFilter(runtime, mailbox, filter, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var pageToken string
|
||||
pageToken := parsed.RawToken
|
||||
for len(messages) < maxCount {
|
||||
pageSize := maxCount - len(messages)
|
||||
if pageSize > searchPageMax {
|
||||
@@ -161,8 +190,12 @@ var MailTriage = common.Shortcut{
|
||||
pageHasMore, _ := searchData["has_more"].(bool)
|
||||
pageToken, _ = searchData["page_token"].(string)
|
||||
if !pageHasMore || pageToken == "" {
|
||||
hasMore = false
|
||||
nextPageToken = ""
|
||||
break
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
nextPageToken = encodeTriagePageToken("search", pageToken)
|
||||
}
|
||||
if len(messages) > maxCount {
|
||||
messages = messages[:maxCount]
|
||||
@@ -185,7 +218,7 @@ var MailTriage = common.Shortcut{
|
||||
}
|
||||
var (
|
||||
messageIDs []string
|
||||
pageToken string
|
||||
pageToken = parsed.RawToken
|
||||
)
|
||||
for len(messageIDs) < maxCount {
|
||||
pageSize := maxCount - len(messageIDs)
|
||||
@@ -209,8 +242,12 @@ var MailTriage = common.Shortcut{
|
||||
pageHasMore, _ := listData["has_more"].(bool)
|
||||
pageToken, _ = listData["page_token"].(string)
|
||||
if !pageHasMore || pageToken == "" {
|
||||
hasMore = false
|
||||
nextPageToken = ""
|
||||
break
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
nextPageToken = encodeTriagePageToken("list", pageToken)
|
||||
}
|
||||
if len(messageIDs) > maxCount {
|
||||
messageIDs = messageIDs[:maxCount]
|
||||
@@ -221,9 +258,19 @@ var MailTriage = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
if messages == nil {
|
||||
messages = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
switch outFormat {
|
||||
case "json", "data":
|
||||
output.PrintJson(runtime.IO().Out, messages)
|
||||
outData := map[string]interface{}{
|
||||
"messages": messages,
|
||||
"count": len(messages),
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
output.PrintJson(runtime.IO().Out, outData)
|
||||
default: // "table"
|
||||
if len(messages) == 0 {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
|
||||
@@ -244,6 +291,18 @@ var MailTriage = common.Shortcut{
|
||||
}
|
||||
output.PrintTable(runtime.IO().Out, rows)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "\n%d message(s)\n", len(messages))
|
||||
if hasMore && nextPageToken != "" {
|
||||
var hint strings.Builder
|
||||
hint.WriteString("next page: mail +triage")
|
||||
if query != "" {
|
||||
hint.WriteString(" --query " + shellQuote(query))
|
||||
}
|
||||
if filterStr := runtime.Str("filter"); filterStr != "" {
|
||||
hint.WriteString(" --filter " + shellQuote(filterStr))
|
||||
}
|
||||
hint.WriteString(" --page-token " + shellQuote(nextPageToken))
|
||||
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
|
||||
}
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id <id> to read full content")
|
||||
}
|
||||
return nil
|
||||
@@ -841,6 +900,85 @@ func buildSearchCreateTime(rng *triageTimeRange) map[string]interface{} {
|
||||
return createTime
|
||||
}
|
||||
|
||||
// shellQuote wraps a string in single quotes, escaping any embedded single quotes.
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// resolveTriagePath determines whether to use the search API path,
|
||||
// validating that --page-token prefix is consistent with query/filter params.
|
||||
//
|
||||
// Rules:
|
||||
// - No token: path decided by usesTriageSearchPath(query, filter).
|
||||
// - "search:" prefix: must not have list-only params (no query/search filter fields is OK for continuation).
|
||||
// - "list:" prefix: must not have query or search-only filter fields that would be silently ignored.
|
||||
// - Bare token (no prefix): rejected — all tokens emitted by triage carry a prefix.
|
||||
func resolveTriagePath(parsed triagePageToken, query string, filter triageFilter) (useSearch bool, err error) {
|
||||
if parsed.RawToken == "" {
|
||||
return usesTriageSearchPath(query, filter), nil
|
||||
}
|
||||
paramWantsSearch := usesTriageSearchPath(query, filter)
|
||||
switch parsed.Path {
|
||||
case "search":
|
||||
if !paramWantsSearch && (strings.TrimSpace(query) != "" || len(triageQueryFilterFields(filter)) > 0) {
|
||||
return false, fmt.Errorf("--page-token has search: prefix but current --query/--filter parameters indicate list path; remove conflicting parameters or use the correct token")
|
||||
}
|
||||
return true, nil
|
||||
case "list":
|
||||
if paramWantsSearch {
|
||||
return false, fmt.Errorf("--page-token has list: prefix but --query or --filter contains search-only fields (e.g. from/to/subject); these parameters would be silently ignored — remove them or use a search: token")
|
||||
}
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix (token was obtained from a previous mail +triage response)")
|
||||
}
|
||||
}
|
||||
|
||||
// triagePageToken represents a parsed pagination token.
|
||||
type triagePageToken struct {
|
||||
Path string // "search" or "list"
|
||||
RawToken string // the actual API token
|
||||
}
|
||||
|
||||
// encodeTriagePageToken encodes a pagination token with path prefix.
|
||||
// Format: "search:abc123" or "list:abc123".
|
||||
func encodeTriagePageToken(path string, rawToken string) string {
|
||||
if rawToken == "" {
|
||||
return ""
|
||||
}
|
||||
return path + ":" + rawToken
|
||||
}
|
||||
|
||||
// parseTriagePageToken parses a token encoded by encodeTriagePageToken.
|
||||
// Returns an error for bare tokens or malformed tokens.
|
||||
func parseTriagePageToken(token string) (triagePageToken, error) {
|
||||
if token == "" {
|
||||
return triagePageToken{}, nil
|
||||
}
|
||||
idx := strings.IndexByte(token, ':')
|
||||
if idx < 0 {
|
||||
return triagePageToken{}, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix (token was obtained from a previous mail +triage response)")
|
||||
}
|
||||
path := token[:idx]
|
||||
raw := token[idx+1:]
|
||||
if path != "search" && path != "list" {
|
||||
return triagePageToken{}, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix, got %q", path)
|
||||
}
|
||||
if raw == "" {
|
||||
return triagePageToken{}, fmt.Errorf("invalid --page-token: token value is empty after '%s:' prefix", path)
|
||||
}
|
||||
return triagePageToken{Path: path, RawToken: raw}, nil
|
||||
}
|
||||
|
||||
// resolveTriagePageSize returns the effective max count from --page-size or --max.
|
||||
// --page-size is an alias for --max; if both are set, --page-size takes priority.
|
||||
func resolveTriagePageSize(runtime *common.RuntimeContext) int {
|
||||
if ps := runtime.Int("page-size"); ps > 0 {
|
||||
return normalizeTriageMax(ps)
|
||||
}
|
||||
return normalizeTriageMax(runtime.Int("max"))
|
||||
}
|
||||
|
||||
func normalizeTriageMax(maxCount int) int {
|
||||
if maxCount <= 0 {
|
||||
return 20
|
||||
|
||||
@@ -967,4 +967,441 @@ func TestBuildSearchParamsPageToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveTriagePageSize ---
|
||||
|
||||
func TestResolveTriagePageSizeDefaultMax(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil) // max=0 (unset) → normalizeTriageMax returns 20
|
||||
got := resolveTriagePageSize(rt)
|
||||
if got != 20 {
|
||||
t.Fatalf("expected 20, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePageSizeFromMax(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, map[string]string{"max": "30"})
|
||||
got := resolveTriagePageSize(rt)
|
||||
if got != 30 {
|
||||
t.Fatalf("expected 30, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePageSizeFromPageSize(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, map[string]string{"page-size": "10"})
|
||||
got := resolveTriagePageSize(rt)
|
||||
if got != 10 {
|
||||
t.Fatalf("expected 10, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePageSizePageSizeOverridesMax(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, map[string]string{"max": "30", "page-size": "5"})
|
||||
got := resolveTriagePageSize(rt)
|
||||
if got != 5 {
|
||||
t.Fatalf("expected page-size=5 to override max=30, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePageSizeClamped(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, map[string]string{"page-size": "999"})
|
||||
got := resolveTriagePageSize(rt)
|
||||
if got != 400 {
|
||||
t.Fatalf("expected clamped to 400, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- page-token path validation ---
|
||||
|
||||
func TestResolveTriagePathSearchTokenContinuation(t *testing.T) {
|
||||
// search: token without --query is valid (continuation)
|
||||
useSearch, err := resolveTriagePath(mustParseTriagePageToken(t, "search:abc123"), "", triageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !useSearch {
|
||||
t.Fatal("search: prefix should select search path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathListTokenConflictsWithQuery(t *testing.T) {
|
||||
// list: token + --query → error (query would be silently ignored)
|
||||
_, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc123"), "hello", triageFilter{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for list: token with --query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathListTokenConflictsWithSearchFilter(t *testing.T) {
|
||||
// list: token + search-only filter field → error
|
||||
_, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc123"), "", triageFilter{From: []string{"a@b.com"}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for list: token with search-only filter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathListTokenWithListFilter(t *testing.T) {
|
||||
// list: token + list-compatible filter → OK
|
||||
useSearch, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc123"), "", triageFilter{Folder: "inbox"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if useSearch {
|
||||
t.Fatal("list: prefix should select list path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathBareTokenRejected(t *testing.T) {
|
||||
// Bare tokens are rejected at parse time, not at resolveTriagePath time
|
||||
_, err := parseTriagePageToken("baretoken123")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare token without prefix")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "prefix") {
|
||||
t.Fatalf("error should mention prefix, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathEmptyToken(t *testing.T) {
|
||||
// No token → falls back to usesTriageSearchPath
|
||||
useSearch, err := resolveTriagePath(triagePageToken{}, "hello", triageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !useSearch {
|
||||
t.Fatal("query present → should use search path")
|
||||
}
|
||||
|
||||
useSearch, err = resolveTriagePath(triagePageToken{}, "", triageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if useSearch {
|
||||
t.Fatal("no query → should use list path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageTokenSearchPrefixStripped(t *testing.T) {
|
||||
raw := "search:72d98412d30aa6af"
|
||||
got := strings.TrimPrefix(raw, "search:")
|
||||
if got != "72d98412d30aa6af" {
|
||||
t.Fatalf("expected stripped token, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageTokenListPrefixStripped(t *testing.T) {
|
||||
raw := "list:FfccvoqPd_loLhtcRx8cx"
|
||||
got := strings.TrimPrefix(raw, "list:")
|
||||
if got != "FfccvoqPd_loLhtcRx8cx" {
|
||||
t.Fatalf("expected stripped token, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageTokenBareTokenRejected(t *testing.T) {
|
||||
_, err := parseTriagePageToken("FfccvoqPd_loLhtcRx8cx")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare token without prefix")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "prefix") {
|
||||
t.Fatalf("error should mention prefix requirement, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun with page-size ---
|
||||
|
||||
func TestMailTriageDryRunPageSizeOverridesMax(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"max": "50",
|
||||
"page-size": "8",
|
||||
"filter": `{"folder_id":"INBOX"}`,
|
||||
})
|
||||
apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
|
||||
if len(apis) < 1 {
|
||||
t.Fatalf("expected at least 1 dry-run api, got %d", len(apis))
|
||||
}
|
||||
got, ok := apis[0].Params["page_size"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("page_size type mismatch, got %#v", apis[0].Params["page_size"])
|
||||
}
|
||||
if int(got) != 8 {
|
||||
t.Fatalf("expected page_size=8 (from --page-size), got %d", int(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageDryRunSearchPathCapsPageSizeAt15(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"query": "hello",
|
||||
"page-size": "30",
|
||||
})
|
||||
apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
|
||||
if len(apis) < 1 {
|
||||
t.Fatalf("expected at least 1 dry-run api, got %d", len(apis))
|
||||
}
|
||||
got, ok := apis[0].Params["page_size"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("page_size type mismatch, got %#v", apis[0].Params["page_size"])
|
||||
}
|
||||
if int(got) != searchPageMax {
|
||||
t.Fatalf("expected page_size capped at %d, got %d", searchPageMax, int(got))
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun with page-token ---
|
||||
|
||||
func TestMailTriageDryRunListPathWithPageToken(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"filter": `{"folder_id":"INBOX"}`,
|
||||
"page-token": "list:abc123token",
|
||||
})
|
||||
apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
|
||||
if len(apis) < 1 {
|
||||
t.Fatalf("expected at least 1 dry-run api, got %d", len(apis))
|
||||
}
|
||||
got, ok := apis[0].Params["page_token"]
|
||||
if !ok {
|
||||
t.Fatalf("expected page_token in params")
|
||||
}
|
||||
if got != "abc123token" {
|
||||
t.Fatalf("expected stripped page_token='abc123token', got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageDryRunSearchPathWithPageToken(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"query": "test",
|
||||
"page-token": "search:def456token",
|
||||
})
|
||||
apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
|
||||
if len(apis) < 1 {
|
||||
t.Fatalf("expected at least 1 dry-run api, got %d", len(apis))
|
||||
}
|
||||
got, ok := apis[0].Params["page_token"]
|
||||
if !ok {
|
||||
t.Fatalf("expected page_token in params")
|
||||
}
|
||||
if got != "def456token" {
|
||||
t.Fatalf("expected stripped page_token='def456token', got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageDryRunBarePageTokenErrors(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"filter": `{"folder_id":"INBOX"}`,
|
||||
"page-token": "baretoken123",
|
||||
})
|
||||
dry := MailTriage.DryRun(context.Background(), runtime)
|
||||
b, _ := json.Marshal(dry)
|
||||
s := string(b)
|
||||
if !strings.Contains(s, "filter_error") {
|
||||
t.Fatalf("expected filter_error for bare token, got %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveTriagePath ---
|
||||
|
||||
func TestResolveTriagePathSearchPrefixWithoutQuery(t *testing.T) {
|
||||
useSearch, err := resolveTriagePath(mustParseTriagePageToken(t, "search:abc"), "", triageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !useSearch {
|
||||
t.Fatal("search: prefix should select search path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathListPrefixWithoutConflict(t *testing.T) {
|
||||
useSearch, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc"), "", triageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if useSearch {
|
||||
t.Fatal("list: prefix should select list path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathListPrefixWithQueryErrors(t *testing.T) {
|
||||
_, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc"), "hello", triageFilter{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for list: token with --query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathListPrefixWithSearchFilterErrors(t *testing.T) {
|
||||
_, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc"), "", triageFilter{Subject: "test"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for list: token with search-only filter field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathBareTokenErrors(t *testing.T) {
|
||||
_, err := parseTriagePageToken("baretoken")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePathEmptyTokenFallsBack(t *testing.T) {
|
||||
useSearch, err := resolveTriagePath(triagePageToken{}, "", triageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if useSearch {
|
||||
t.Fatal("no query → should use list path")
|
||||
}
|
||||
|
||||
useSearch, err = resolveTriagePath(triagePageToken{}, "keyword", triageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !useSearch {
|
||||
t.Fatal("query present → should use search path")
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun: token prefix overrides path ---
|
||||
|
||||
func TestMailTriageDryRunSearchTokenWithoutQueryUsesSearchPath(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"page-token": "search:abc123",
|
||||
})
|
||||
apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
|
||||
if len(apis) < 1 {
|
||||
t.Fatalf("expected at least 1 dry-run api, got %d", len(apis))
|
||||
}
|
||||
if apis[0].URL != mailboxPath("me", "search") {
|
||||
t.Fatalf("search: prefix should force search path, got url %s", apis[0].URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageDryRunListTokenWithQueryErrors(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"query": "hello",
|
||||
"page-token": "list:abc123",
|
||||
})
|
||||
dry := MailTriage.DryRun(context.Background(), runtime)
|
||||
b, _ := json.Marshal(dry)
|
||||
s := string(b)
|
||||
if !strings.Contains(s, "filter_error") {
|
||||
t.Fatalf("expected filter_error for list token with query, got %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun with no page-token has no page_token param ---
|
||||
|
||||
func TestMailTriageDryRunNoPageTokenOmitsParam(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"filter": `{"folder_id":"INBOX"}`,
|
||||
})
|
||||
apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
|
||||
if len(apis) < 1 {
|
||||
t.Fatalf("expected at least 1 dry-run api, got %d", len(apis))
|
||||
}
|
||||
if _, ok := apis[0].Params["page_token"]; ok {
|
||||
t.Fatalf("page_token should not be present when --page-token is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Flag definition checks ---
|
||||
|
||||
func TestMailTriageFlagsIncludePageTokenAndPageSize(t *testing.T) {
|
||||
flagNames := make(map[string]bool)
|
||||
for _, fl := range MailTriage.Flags {
|
||||
flagNames[fl.Name] = true
|
||||
}
|
||||
for _, name := range []string{"page-token", "page-size", "max"} {
|
||||
if !flagNames[name] {
|
||||
t.Fatalf("expected flag --%s to be defined", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseTriagePageToken(t *testing.T, token string) triagePageToken {
|
||||
t.Helper()
|
||||
parsed, err := parseTriagePageToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTriagePageToken(%q) failed: %v", token, err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// --- parseTriagePageToken / encodeTriagePageToken ---
|
||||
|
||||
func TestEncodeTriagePageToken(t *testing.T) {
|
||||
got := encodeTriagePageToken("search", "abc123")
|
||||
if got != "search:abc123" {
|
||||
t.Fatalf("expected search:abc123, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeTriagePageTokenEmpty(t *testing.T) {
|
||||
got := encodeTriagePageToken("search", "")
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty for empty raw token, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTriagePageTokenSearch(t *testing.T) {
|
||||
parsed, err := parseTriagePageToken("search:abc123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if parsed.Path != "search" || parsed.RawToken != "abc123" {
|
||||
t.Fatalf("unexpected parsed: %+v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTriagePageTokenList(t *testing.T) {
|
||||
parsed, err := parseTriagePageToken("list:longtoken123xyz")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if parsed.Path != "list" || parsed.RawToken != "longtoken123xyz" {
|
||||
t.Fatalf("unexpected parsed: %+v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTriagePageTokenWithColonsInRawToken(t *testing.T) {
|
||||
// Raw token may contain colons
|
||||
parsed, err := parseTriagePageToken("search:abc:def:ghi")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if parsed.Path != "search" || parsed.RawToken != "abc:def:ghi" {
|
||||
t.Fatalf("unexpected parsed: %+v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTriagePageTokenBareRejected(t *testing.T) {
|
||||
_, err := parseTriagePageToken("baretoken")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTriagePageTokenEmptyRawTokenRejected(t *testing.T) {
|
||||
_, err := parseTriagePageToken("search:")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty raw token after prefix")
|
||||
}
|
||||
_, err = parseTriagePageToken("list:")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty raw token after prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTriagePageTokenEmpty(t *testing.T) {
|
||||
parsed, err := parseTriagePageToken("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if parsed.RawToken != "" {
|
||||
t.Fatalf("expected empty parsed, got %+v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTriagePageTokenInvalidPrefix(t *testing.T) {
|
||||
_, err := parseTriagePageToken("unknown:abc123")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -50,6 +51,18 @@ func (l *mailWatchLogger) Error(_ context.Context, args ...interface{}) {
|
||||
|
||||
var _ larkcore.Logger = (*mailWatchLogger)(nil)
|
||||
|
||||
// handleMailWatchSignal processes a shutdown signal: logs status, unsubscribes
|
||||
// mailbox events, restores default signal behavior for forced termination, and
|
||||
// cancels the watch context.
|
||||
func handleMailWatchSignal(errOut io.Writer, sig os.Signal, eventCount int64, unsubscribeWithLog func(), stopSignals func(), cancel context.CancelFunc) {
|
||||
fmt.Fprintf(errOut, "\nShutting down (signal: %v)... (received %d events)\n", sig, eventCount)
|
||||
// Restore default signal behavior so a second Ctrl+C can force terminate.
|
||||
stopSignals()
|
||||
signal.Reset(os.Interrupt, syscall.SIGTERM)
|
||||
unsubscribeWithLog()
|
||||
cancel()
|
||||
}
|
||||
|
||||
const mailEventType = "mail.user_mailbox.event.message_received_v1"
|
||||
|
||||
// promptInjectionPatterns lists known prompt injection trigger phrases.
|
||||
@@ -260,19 +273,30 @@ var MailWatch = common.Shortcut{
|
||||
})
|
||||
return unsubErr
|
||||
}
|
||||
var unsubLogOnce sync.Once
|
||||
unsubscribeWithLog := func() {
|
||||
unsubLogOnce.Do(func() {
|
||||
info("Unsubscribing mailbox events...")
|
||||
if err := unsubscribe(); err != nil {
|
||||
fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", err)
|
||||
} else {
|
||||
info("Mailbox unsubscribed.")
|
||||
}
|
||||
})
|
||||
}
|
||||
defer unsubscribeWithLog()
|
||||
|
||||
// Resolve "me" to the actual email address so we can filter events.
|
||||
mailboxFilter := mailbox
|
||||
if mailbox == "me" {
|
||||
resolved, profileErr := fetchMailboxPrimaryEmail(runtime, "me")
|
||||
if profileErr != nil {
|
||||
unsubscribe() //nolint:errcheck // best-effort cleanup; primary error is profileErr
|
||||
return enhanceProfileError(profileErr)
|
||||
}
|
||||
mailboxFilter = resolved
|
||||
}
|
||||
|
||||
eventCount := 0
|
||||
var eventCount atomic.Int64
|
||||
|
||||
handleEvent := func(data map[string]interface{}) {
|
||||
// Extract event body
|
||||
@@ -338,7 +362,7 @@ var MailWatch = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
eventCount++
|
||||
eventCount.Add(1)
|
||||
|
||||
// Prompt injection detection: warn when email body contains known injection patterns.
|
||||
// Body fields may be base64url-encoded; decode before scanning.
|
||||
@@ -425,32 +449,59 @@ var MailWatch = common.Shortcut{
|
||||
larkws.WithLogger(sdkLogger),
|
||||
)
|
||||
|
||||
watchCtx, cancelWatch := context.WithCancel(ctx)
|
||||
defer cancelWatch()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
stopSignals := func() { signal.Stop(sigCh) }
|
||||
defer stopSignals()
|
||||
|
||||
shutdownBySignal := make(chan struct{})
|
||||
var shutdownOnce sync.Once
|
||||
triggerShutdown := func() {
|
||||
shutdownOnce.Do(func() { close(shutdownBySignal) })
|
||||
cancelWatch()
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(errOut, "panic in signal handler: %v\n", r)
|
||||
triggerShutdown()
|
||||
}
|
||||
}()
|
||||
<-sigCh
|
||||
info(fmt.Sprintf("\nShutting down... (received %d events)", eventCount))
|
||||
info("Unsubscribing mailbox events...")
|
||||
if unsubErr := unsubscribe(); unsubErr != nil {
|
||||
fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr)
|
||||
} else {
|
||||
info("Mailbox unsubscribed.")
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
handleMailWatchSignal(errOut, sig, eventCount.Load(), unsubscribeWithLog, stopSignals, cancelWatch)
|
||||
triggerShutdown()
|
||||
case <-watchCtx.Done():
|
||||
return
|
||||
}
|
||||
signal.Stop(sigCh)
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
startErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
startErrCh <- cli.Start(watchCtx)
|
||||
}()
|
||||
|
||||
info("Connected. Waiting for mail events... (Ctrl+C to stop)")
|
||||
if err := cli.Start(ctx); err != nil {
|
||||
unsubscribe() //nolint:errcheck // best-effort cleanup
|
||||
return output.ErrNetwork("WebSocket connection failed: %v", err)
|
||||
select {
|
||||
case <-shutdownBySignal:
|
||||
return nil
|
||||
case err := <-startErrCh:
|
||||
if err != nil {
|
||||
select {
|
||||
case <-shutdownBySignal:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
if watchCtx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return output.ErrNetwork("WebSocket connection failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,13 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -579,6 +584,101 @@ func TestSetKeysSorted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- handleMailWatchSignal ---
|
||||
|
||||
// TestHandleMailWatchSignalUnsubscribesAndCancels verifies that all callbacks are invoked and the shutdown message is printed.
|
||||
func TestHandleMailWatchSignalUnsubscribesAndCancels(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
unsubscribed := false
|
||||
stopped := false
|
||||
canceled := false
|
||||
|
||||
handleMailWatchSignal(&buf, os.Interrupt, 3, func() {
|
||||
unsubscribed = true
|
||||
}, func() {
|
||||
stopped = true
|
||||
}, func() {
|
||||
canceled = true
|
||||
})
|
||||
|
||||
if !unsubscribed {
|
||||
t.Fatal("expected unsubscribeWithLog to be called")
|
||||
}
|
||||
if !stopped {
|
||||
t.Fatal("expected signal stop to be called")
|
||||
}
|
||||
if !canceled {
|
||||
t.Fatal("expected cancel to be called")
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Shutting down (signal: interrupt)... (received 3 events)") {
|
||||
t.Fatalf("missing shutdown message, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMailWatchSignalReportsUnsubscribeFailure verifies that unsubscribe errors are written to errOut.
|
||||
func TestHandleMailWatchSignalReportsUnsubscribeFailure(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
handleMailWatchSignal(&buf, os.Interrupt, 1, func() {
|
||||
fmt.Fprintln(&buf, "Warning: unsubscribe failed: boom")
|
||||
}, func() {}, func() {})
|
||||
|
||||
if got := buf.String(); !strings.Contains(got, "Warning: unsubscribe failed: boom") {
|
||||
t.Fatalf("expected unsubscribe warning, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMailWatchSignalPanicUnblocksShutdown verifies that a panic in unsubscribeWithLog still triggers shutdown.
|
||||
func TestHandleMailWatchSignalPanicUnblocksShutdown(t *testing.T) {
|
||||
shutdownBySignal := make(chan struct{})
|
||||
var shutdownOnce sync.Once
|
||||
_, cancelWatch := context.WithCancel(context.Background())
|
||||
triggerShutdown := func() {
|
||||
shutdownOnce.Do(func() { close(shutdownBySignal) })
|
||||
cancelWatch()
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
triggerShutdown()
|
||||
}
|
||||
}()
|
||||
<-sigCh
|
||||
// Simulate panic inside handleMailWatchSignal (e.g. unsubscribeWithLog panics)
|
||||
panic("unsubscribe exploded")
|
||||
}()
|
||||
|
||||
sigCh <- os.Interrupt
|
||||
|
||||
select {
|
||||
case <-shutdownBySignal:
|
||||
// Success: shutdown channel was closed despite the panic
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("shutdownBySignal was not closed after panic — process would hang")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMailWatchSignalCallOrder verifies callbacks execute in order: stop signals → unsubscribe → cancel.
|
||||
func TestHandleMailWatchSignalCallOrder(t *testing.T) {
|
||||
var order []string
|
||||
|
||||
handleMailWatchSignal(io.Discard, os.Interrupt, 0, func() {
|
||||
order = append(order, "unsub")
|
||||
}, func() {
|
||||
order = append(order, "stop")
|
||||
}, func() {
|
||||
order = append(order, "cancel")
|
||||
})
|
||||
|
||||
// Expected: stop → unsub → cancel
|
||||
if len(order) != 3 || order[0] != "stop" || order[1] != "unsub" || order[2] != "cancel" {
|
||||
t.Fatalf("unexpected call order: %v, want [stop unsub cancel]", order)
|
||||
}
|
||||
}
|
||||
|
||||
func assertErr(msg string) error {
|
||||
return &testErr{msg: msg}
|
||||
}
|
||||
|
||||
347
shortcuts/minutes/minutes_search.go
Normal file
347
shortcuts/minutes/minutes_search.go
Normal file
@@ -0,0 +1,347 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMinutesSearchPageSize = 15
|
||||
maxMinutesSearchPageSize = 30
|
||||
maxMinutesSearchQueryLen = 50
|
||||
)
|
||||
|
||||
// parseTimeRange normalizes --start and --end into RFC3339 timestamps.
|
||||
func parseTimeRange(runtime *common.RuntimeContext) (string, string, error) {
|
||||
start := strings.TrimSpace(runtime.Str("start"))
|
||||
end := strings.TrimSpace(runtime.Str("end"))
|
||||
if start == "" && end == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
var startTime, endTime string
|
||||
if start != "" {
|
||||
parsed, err := toRFC3339(start)
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("--start: %v", err)
|
||||
}
|
||||
startTime = parsed
|
||||
}
|
||||
if end != "" {
|
||||
parsed, err := toRFC3339(end, "end")
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("--end: %v", err)
|
||||
}
|
||||
endTime = parsed
|
||||
}
|
||||
if startTime != "" && endTime != "" {
|
||||
st, err := time.Parse(time.RFC3339, startTime)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("parse normalized --start: %w", err)
|
||||
}
|
||||
et, err := time.Parse(time.RFC3339, endTime)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("parse normalized --end: %w", err)
|
||||
}
|
||||
if st.After(et) {
|
||||
return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
|
||||
}
|
||||
}
|
||||
return startTime, endTime, nil
|
||||
}
|
||||
|
||||
// toRFC3339 converts a supported CLI time input into an RFC3339 timestamp.
|
||||
func toRFC3339(input string, hint ...string) (string, error) {
|
||||
ts, err := common.ParseTime(input, hint...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sec, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
|
||||
}
|
||||
return time.Unix(sec, 0).Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// resolveUserIDs expands special user identifiers and removes duplicates.
|
||||
func resolveUserIDs(flagName string, ids []string, runtime *common.RuntimeContext) ([]string, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
currentUserID := runtime.UserOpenId()
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if strings.EqualFold(id, "me") {
|
||||
if currentUserID == "" {
|
||||
return nil, output.ErrValidation("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
|
||||
}
|
||||
id = currentUserID
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildTimeFilter builds the create_time filter block for the API request.
|
||||
func buildTimeFilter(startTime, endTime string) map[string]interface{} {
|
||||
if startTime == "" && endTime == "" {
|
||||
return nil
|
||||
}
|
||||
timeRange := map[string]interface{}{}
|
||||
if startTime != "" {
|
||||
timeRange["start_time"] = startTime
|
||||
}
|
||||
if endTime != "" {
|
||||
timeRange["end_time"] = endTime
|
||||
}
|
||||
return timeRange
|
||||
}
|
||||
|
||||
// buildMinutesSearchFilter builds the filter object for the API request body.
|
||||
func buildMinutesSearchFilter(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
|
||||
filter := map[string]interface{}{}
|
||||
|
||||
ownerIDs, err := resolveUserIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ownerIDs) > 0 {
|
||||
filter["owner_ids"] = ownerIDs
|
||||
}
|
||||
|
||||
participantIDs, err := resolveUserIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(participantIDs) > 0 {
|
||||
filter["participant_ids"] = participantIDs
|
||||
}
|
||||
|
||||
if timeRange := buildTimeFilter(startTime, endTime); timeRange != nil {
|
||||
filter["create_time"] = timeRange
|
||||
}
|
||||
|
||||
if len(filter) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
// buildMinutesSearchBody builds the POST body for the minutes search API.
|
||||
func buildMinutesSearchBody(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
|
||||
body["query"] = q
|
||||
}
|
||||
|
||||
filter, err := buildMinutesSearchFilter(runtime, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter != nil {
|
||||
body["filter"] = filter
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// buildMinutesSearchParams builds the query parameters for the search request.
|
||||
func buildMinutesSearchParams(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
|
||||
pageSize := strings.TrimSpace(runtime.Str("page-size"))
|
||||
if pageSize == "" {
|
||||
pageSize = fmt.Sprintf("%d", defaultMinutesSearchPageSize)
|
||||
}
|
||||
params["page_size"] = pageSize
|
||||
|
||||
if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// minuteSearchItems extracts the result items from the API response payload.
|
||||
func minuteSearchItems(data map[string]interface{}) []interface{} {
|
||||
return common.GetSlice(data, "items")
|
||||
}
|
||||
|
||||
// minuteSearchToken extracts the minute token from a search result item.
|
||||
func minuteSearchToken(item map[string]interface{}) string {
|
||||
return common.GetString(item, "token")
|
||||
}
|
||||
|
||||
// minuteSearchDisplayInfo extracts the display_info field from a search result item.
|
||||
func minuteSearchDisplayInfo(item map[string]interface{}) string {
|
||||
return common.GetString(item, "display_info")
|
||||
}
|
||||
|
||||
// minuteSearchDescription extracts the description field from a search result item.
|
||||
func minuteSearchDescription(item map[string]interface{}) string {
|
||||
meta := common.GetMap(item, "meta_data")
|
||||
return common.GetString(meta, "description")
|
||||
}
|
||||
|
||||
// minuteSearchAppLink extracts the app link from a search result item.
|
||||
func minuteSearchAppLink(item map[string]interface{}) string {
|
||||
meta := common.GetMap(item, "meta_data")
|
||||
return common.GetString(meta, "app_link")
|
||||
}
|
||||
|
||||
// minuteSearchAvatar extracts the avatar URL from a search result item.
|
||||
func minuteSearchAvatar(item map[string]interface{}) string {
|
||||
meta := common.GetMap(item, "meta_data")
|
||||
return common.GetString(meta, "avatar")
|
||||
}
|
||||
|
||||
// buildMinuteSearchRows converts API items into pretty output rows.
|
||||
func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
|
||||
rows := make([]map[string]interface{}, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"token": minuteSearchToken(item),
|
||||
"display_info": common.TruncateStr(minuteSearchDisplayInfo(item), 40),
|
||||
"description": common.TruncateStr(minuteSearchDescription(item), 40),
|
||||
"app_link": common.TruncateStr(minuteSearchAppLink(item), 80),
|
||||
"avatar": common.TruncateStr(minuteSearchAvatar(item), 80),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// MinutesSearch searches minutes by keyword, owners, participants, and time range.
|
||||
var MinutesSearch = common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+search",
|
||||
Description: "Search minutes by keyword, owners, participants, and time range",
|
||||
Risk: "read",
|
||||
Scopes: []string{"minutes:minutes.search:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "owner-ids", Desc: "owner open_id list, comma-separated (use \"me\" for current user)"},
|
||||
{Name: "participant-ids", Desc: "participant open_id list, comma-separated (use \"me\" for current user)"},
|
||||
{Name: "start", Desc: "time lower bound (ISO 8601 or YYYY-MM-DD)"},
|
||||
{Name: "end", Desc: "time upper bound (ISO 8601 or YYYY-MM-DD)"},
|
||||
{Name: "page-token", Desc: "page token for next page"},
|
||||
{Name: "page-size", Default: "15", Desc: "page size, 1-30 (default 15)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, _, err := parseTimeRange(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if q := strings.TrimSpace(runtime.Str("query")); q != "" && utf8.RuneCountInString(q) > maxMinutesSearchQueryLen {
|
||||
return output.ErrValidation("--query: length must be between 1 and 50 characters")
|
||||
}
|
||||
if _, err := common.ValidatePageSize(runtime, "page-size", defaultMinutesSearchPageSize, 1, maxMinutesSearchPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
ownerIDs, err := resolveUserIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ownerIDs {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
participantIDs, err := resolveUserIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range participantIDs {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, flag := range []string{"query", "owner-ids", "participant-ids", "start", "end"} {
|
||||
if strings.TrimSpace(runtime.Str(flag)) != "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return common.FlagErrorf("specify at least one of --query, --owner-ids, --participant-ids, --start, or --end")
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
startTime, endTime, err := parseTimeRange(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
params := buildMinutesSearchParams(runtime)
|
||||
body, err := buildMinutesSearchBody(runtime, startTime, endTime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
dryRun := common.NewDryRunAPI().
|
||||
POST("/open-apis/minutes/v1/minutes/search")
|
||||
if len(params) > 0 {
|
||||
dryRun.Params(params)
|
||||
}
|
||||
return dryRun.Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
startTime, endTime, err := parseTimeRange(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := buildMinutesSearchBody(runtime, startTime, endTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(http.MethodPost, "/open-apis/minutes/v1/minutes/search", buildMinutesSearchParams(runtime), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
|
||||
items := minuteSearchItems(data)
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
pageToken, _ := data["page_token"].(string)
|
||||
rows := buildMinuteSearchRows(items)
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": items,
|
||||
"total": data["total"],
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) {
|
||||
if len(rows) == 0 {
|
||||
fmt.Fprintln(w, "No minutes.")
|
||||
return
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
})
|
||||
if hasMore && runtime.Format != "json" && runtime.Format != "" {
|
||||
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
691
shortcuts/minutes/minutes_search_test.go
Normal file
691
shortcuts/minutes/minutes_search_test.go
Normal file
@@ -0,0 +1,691 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newMinutesSearchTestCommand builds a command with the flags used by minutes search tests.
|
||||
func newMinutesSearchTestCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("owner-ids", "", "")
|
||||
cmd.Flags().String("participant-ids", "", "")
|
||||
cmd.Flags().String("start", "", "")
|
||||
cmd.Flags().String("end", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
cmd.Flags().String("page-size", "15", "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// configWithoutUserOpenID returns a test config without a resolvable user open_id.
|
||||
func configWithoutUserOpenID() *core.CliConfig {
|
||||
cfg := defaultConfig()
|
||||
cfg.UserOpenId = ""
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestMinutesSearchParseTimeRange verifies valid time inputs are normalized.
|
||||
func TestMinutesSearchParseTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("start", "2026-03-24")
|
||||
_ = cmd.Flags().Set("end", "2026-03-25")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
start, end, err := parseTimeRange(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTimeRange() unexpected error: %v", err)
|
||||
}
|
||||
if start == "" || end == "" {
|
||||
t.Fatalf("expected non-empty start/end, got %q %q", start, end)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchParseTimeRangeErrors verifies invalid time inputs return validation errors.
|
||||
func TestMinutesSearchParseTimeRangeErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start string
|
||||
end string
|
||||
wantMessage string
|
||||
}{
|
||||
{name: "invalid start", start: "bad-start", wantMessage: "--start:"},
|
||||
{name: "invalid end", end: "bad-end", wantMessage: "--end:"},
|
||||
{name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
if tt.start != "" {
|
||||
_ = cmd.Flags().Set("start", tt.start)
|
||||
}
|
||||
if tt.end != "" {
|
||||
_ = cmd.Flags().Set("end", tt.end)
|
||||
}
|
||||
|
||||
_, _, err := parseTimeRange(common.TestNewRuntimeContext(cmd, defaultConfig()))
|
||||
if err == nil {
|
||||
t.Fatal("expected parseTimeRange error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMinutesSearchParams verifies request params and body fields are assembled correctly.
|
||||
func TestBuildMinutesSearchParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("query", "budget")
|
||||
_ = cmd.Flags().Set("owner-ids", "ou_owner,ou_owner_2")
|
||||
_ = cmd.Flags().Set("participant-ids", "ou_c")
|
||||
_ = cmd.Flags().Set("page-size", "5")
|
||||
_ = cmd.Flags().Set("page-token", "next_page")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
params := buildMinutesSearchParams(runtime)
|
||||
body, err := buildMinutesSearchBody(runtime, "2026-03-24T00:00:00Z", "2026-03-25T00:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("buildMinutesSearchBody() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got, _ := params["page_size"].(string); got != "5" {
|
||||
t.Fatalf("page_size = %q, want 5", got)
|
||||
}
|
||||
if got, _ := params["page_token"].(string); got != "next_page" {
|
||||
t.Fatalf("page_token = %q, want next_page", got)
|
||||
}
|
||||
if body["query"] != "budget" {
|
||||
t.Fatalf("body.query = %v, want budget", body["query"])
|
||||
}
|
||||
filter, _ := body["filter"].(map[string]interface{})
|
||||
if filter == nil {
|
||||
t.Fatalf("body.filter = nil, want filter object")
|
||||
}
|
||||
owners, _ := filter["owner_ids"].([]string)
|
||||
if len(owners) != 2 || owners[0] != "ou_owner" || owners[1] != "ou_owner_2" {
|
||||
t.Fatalf("owner_ids = %v, want [ou_owner ou_owner_2]", filter["owner_ids"])
|
||||
}
|
||||
participants, _ := filter["participant_ids"].([]string)
|
||||
if len(participants) != 1 || participants[0] != "ou_c" {
|
||||
t.Fatalf("participant_ids = %v, want [ou_c]", filter["participant_ids"])
|
||||
}
|
||||
createTime, _ := filter["create_time"].(map[string]interface{})
|
||||
if createTime == nil {
|
||||
t.Fatalf("create_time = nil, want time range")
|
||||
}
|
||||
if createTime["start_time"] != "2026-03-24T00:00:00Z" {
|
||||
t.Fatalf("start_time = %v", createTime["start_time"])
|
||||
}
|
||||
if createTime["end_time"] != "2026-03-25T00:00:00Z" {
|
||||
t.Fatalf("end_time = %v", createTime["end_time"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMinutesSearchParamsDefaultPageSize verifies the default page size is applied.
|
||||
func TestBuildMinutesSearchParamsDefaultPageSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
|
||||
params := buildMinutesSearchParams(common.TestNewRuntimeContext(cmd, defaultConfig()))
|
||||
if got, _ := params["page_size"].(string); got != "15" {
|
||||
t.Fatalf("page_size = %q, want 15", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveUserIDs verifies me expansion, deduplication, and nil handling.
|
||||
func TestResolveUserIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
|
||||
got, err := resolveUserIDs("--owner-ids", []string{"me"}, runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveUserIDs([me]) unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "ou_testuser" {
|
||||
t.Fatalf("resolveUserIDs([me]) = %v, want [ou_testuser]", got)
|
||||
}
|
||||
|
||||
got, err = resolveUserIDs("--owner-ids", []string{"ou_other", "me", "Me"}, runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveUserIDs([ou_other, me, Me]) unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "ou_other" || got[1] != "ou_testuser" {
|
||||
t.Fatalf("resolveUserIDs([ou_other, me, Me]) = %v, want [ou_other ou_testuser]", got)
|
||||
}
|
||||
|
||||
got, err = resolveUserIDs("--owner-ids", nil, runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveUserIDs(nil) unexpected error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("resolveUserIDs(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTimeFilter verifies time filters are only populated for provided bounds.
|
||||
func TestBuildTimeFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := buildTimeFilter("", ""); got != nil {
|
||||
t.Fatalf("buildTimeFilter('', '') = %v, want nil", got)
|
||||
}
|
||||
if got := buildTimeFilter("2026-03-24T00:00:00Z", ""); got["start_time"] != "2026-03-24T00:00:00Z" {
|
||||
t.Fatalf("start_time = %v", got["start_time"])
|
||||
}
|
||||
if got := buildTimeFilter("", "2026-03-25T00:00:00Z"); got["end_time"] != "2026-03-25T00:00:00Z" {
|
||||
t.Fatalf("end_time = %v", got["end_time"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationMeOwnerID verifies owner-ids accepts me when open_id is available.
|
||||
func TestMinutesSearchValidationMeOwnerID(t *testing.T) {
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("owner-ids", "me")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := MinutesSearch.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for --owner-ids me, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationMeRequiresResolvableUser verifies me fails without a resolvable open_id.
|
||||
func TestMinutesSearchValidationMeRequiresResolvableUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flag string
|
||||
}{
|
||||
{name: "owner ids", flag: "owner-ids"},
|
||||
{name: "participant ids", flag: "participant-ids"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set(tt.flag, "me")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, configWithoutUserOpenID())
|
||||
err := MinutesSearch.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for unresolved me")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "resolvable open_id") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMinutesSearchFilterMeExpansion verifies me is expanded inside the request filter.
|
||||
func TestBuildMinutesSearchFilterMeExpansion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("owner-ids", "me,ou_other")
|
||||
_ = cmd.Flags().Set("participant-ids", "me")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
body, err := buildMinutesSearchBody(runtime, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildMinutesSearchBody() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filter, _ := body["filter"].(map[string]interface{})
|
||||
if filter == nil {
|
||||
t.Fatal("body.filter = nil, want filter object")
|
||||
}
|
||||
owners, _ := filter["owner_ids"].([]string)
|
||||
if len(owners) != 2 || owners[0] != "ou_testuser" || owners[1] != "ou_other" {
|
||||
t.Fatalf("owner_ids = %v, want [ou_testuser ou_other]", owners)
|
||||
}
|
||||
participants, _ := filter["participant_ids"].([]string)
|
||||
if len(participants) != 1 || participants[0] != "ou_testuser" {
|
||||
t.Fatalf("participant_ids = %v, want [ou_testuser]", participants)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchItems verifies items extraction from the search response payload.
|
||||
func TestMinuteSearchItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
items := minuteSearchItems(map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"minute_token": "tok_1"}},
|
||||
})
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("minuteSearchItems() len = %d, want 1", len(items))
|
||||
}
|
||||
|
||||
if got := minuteSearchItems(map[string]interface{}{}); got != nil {
|
||||
t.Fatalf("minuteSearchItems() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationNoFilter verifies at least one filter is required.
|
||||
func TestMinutesSearchValidationNoFilter(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesSearch, []string{"+search", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty filters")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "specify at least one") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationInvalidParticipantID verifies participant IDs must be valid open_ids.
|
||||
func TestMinutesSearchValidationInvalidParticipantID(t *testing.T) {
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("participant-ids", "user_123")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := MinutesSearch.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid user ID error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationInvalidOwnerID verifies owner IDs must be valid open_ids.
|
||||
func TestMinutesSearchValidationInvalidOwnerID(t *testing.T) {
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("owner-ids", "user_123")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := MinutesSearch.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid owner ID error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationQueryTooLong verifies overly long queries are rejected.
|
||||
func TestMinutesSearchValidationQueryTooLong(t *testing.T) {
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("query", strings.Repeat("a", 51))
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := MinutesSearch.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected query length error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "length must be between 1 and 50 characters") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationMaxPageSize30 verifies the maximum allowed page size passes validation.
|
||||
func TestMinutesSearchValidationMaxPageSize30(t *testing.T) {
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("query", "budget")
|
||||
_ = cmd.Flags().Set("page-size", "30")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := MinutesSearch.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for --page-size 30, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationPageSizeAboveMax verifies page sizes above the limit are rejected.
|
||||
func TestMinutesSearchValidationPageSizeAboveMax(t *testing.T) {
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("query", "budget")
|
||||
_ = cmd.Flags().Set("page-size", "31")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
|
||||
err := MinutesSearch.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for --page-size 31")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--page-size") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchValidationTimeErrors verifies time parsing failures surface through validation.
|
||||
func TestMinutesSearchValidationTimeErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start string
|
||||
end string
|
||||
wantMessage string
|
||||
}{
|
||||
{name: "invalid start", start: "bad-start", wantMessage: "--start:"},
|
||||
{name: "invalid end", end: "bad-end", wantMessage: "--end:"},
|
||||
{name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newMinutesSearchTestCommand()
|
||||
_ = cmd.Flags().Set("query", "budget")
|
||||
if tt.start != "" {
|
||||
_ = cmd.Flags().Set("start", tt.start)
|
||||
}
|
||||
if tt.end != "" {
|
||||
_ = cmd.Flags().Set("end", tt.end)
|
||||
}
|
||||
|
||||
err := MinutesSearch.Validate(context.Background(), common.TestNewRuntimeContext(cmd, defaultConfig()))
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchDryRun verifies dry-run output includes the expected API request details.
|
||||
func TestMinutesSearchDryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--owner-ids", "ou_owner,ou_owner_2", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/search") {
|
||||
t.Fatalf("dry-run should show API path, got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"method\": \"POST\"") {
|
||||
t.Fatalf("dry-run should use POST, got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"query\": \"budget\"") {
|
||||
t.Fatalf("dry-run should show query in body, got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "\"owner_ids\": [") || !strings.Contains(stdout.String(), "\"ou_owner\"") {
|
||||
t.Fatalf("dry-run should show owner_ids in filter, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchExecuteRendersRowsAndMoreHint verifies pretty output renders rows and pagination hints.
|
||||
func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/minutes/v1/minutes/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"token": "minute_1",
|
||||
"display_info": "周会摘要",
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "周会纪要",
|
||||
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
|
||||
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
|
||||
},
|
||||
},
|
||||
},
|
||||
"total": 1,
|
||||
"has_more": true,
|
||||
"page_token": "next_token",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
|
||||
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--owner-ids", "me", "--format", "pretty", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal request body: %v", err)
|
||||
}
|
||||
if body["query"] != "budget" {
|
||||
t.Fatalf("request query = %v, want budget", body["query"])
|
||||
}
|
||||
filter, _ := body["filter"].(map[string]interface{})
|
||||
if filter == nil {
|
||||
t.Fatalf("request filter = %v, want object", body["filter"])
|
||||
}
|
||||
owners, _ := filter["owner_ids"].([]interface{})
|
||||
if len(owners) != 1 || owners[0] != "ou_testuser" {
|
||||
t.Fatalf("request owner_ids = %v, want [ou_testuser]", filter["owner_ids"])
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "https://p3-lark-file.byteimg.com/img/xxxx.jpg", "next_token", "more available"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q, got: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchExecuteNoMinutes verifies empty results render the no-data message.
|
||||
func TestMinutesSearchExecuteNoMinutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/minutes/v1/minutes/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--format", "pretty", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
if !strings.Contains(stdout.String(), "No minutes.") {
|
||||
t.Fatalf("expected no minutes message, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchExecuteShowsPaginationHintForTableFormat verifies table output includes pagination hints.
|
||||
func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/minutes/v1/minutes/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"token": "minute_1",
|
||||
"display_info": "周会摘要",
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "周会纪要",
|
||||
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
|
||||
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
|
||||
},
|
||||
},
|
||||
},
|
||||
"total": 1,
|
||||
"has_more": true,
|
||||
"page_token": "next_token",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--format", "table", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "next_token") || !strings.Contains(out, "more available") {
|
||||
t.Fatalf("expected pagination hint in table output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchExecuteJSONCountUsesRenderedRows verifies JSON metadata counts rendered rows only.
|
||||
func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/minutes/v1/minutes/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"token": "minute_1",
|
||||
"display_info": "周会摘要",
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "周会纪要",
|
||||
},
|
||||
},
|
||||
},
|
||||
"total": 2,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope struct {
|
||||
Meta struct {
|
||||
Count int `json:"count"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failed to parse output: %v\nraw: %s", err, stdout.String())
|
||||
}
|
||||
if envelope.Meta.Count != 1 {
|
||||
t.Fatalf("meta.count = %d, want 1", envelope.Meta.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchFieldExtractors verifies field extractors read populated metadata correctly.
|
||||
func TestMinuteSearchFieldExtractors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
item := map[string]interface{}{
|
||||
"token": "minute_1",
|
||||
"display_info": "<h>周会</h>摘要",
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "周会纪要",
|
||||
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
|
||||
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
if got := minuteSearchToken(item); got != "minute_1" {
|
||||
t.Fatalf("minuteSearchToken() = %q, want minute_1", got)
|
||||
}
|
||||
if got := minuteSearchDisplayInfo(item); got != "<h>周会</h>摘要" {
|
||||
t.Fatalf("minuteSearchDisplayInfo() = %q", got)
|
||||
}
|
||||
if got := minuteSearchDescription(item); got != "周会纪要" {
|
||||
t.Fatalf("minuteSearchDescription() = %q, want 周会纪要", got)
|
||||
}
|
||||
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/obcn123" {
|
||||
t.Fatalf("minuteSearchAppLink() = %q", got)
|
||||
}
|
||||
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/xxxx.jpg" {
|
||||
t.Fatalf("minuteSearchAvatar() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchFieldExtractorsFallbacks verifies extractors keep working for alternate sample data.
|
||||
func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
item := map[string]interface{}{
|
||||
"token": "minute_2",
|
||||
"display_info": "回退摘要",
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "回退纪要",
|
||||
"app_link": "https://meetings.feishu.cn/minutes/fallback",
|
||||
"avatar": "https://p3-lark-file.byteimg.com/img/fallback.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
if got := minuteSearchToken(item); got != "minute_2" {
|
||||
t.Fatalf("minuteSearchToken() = %q, want minute_2", got)
|
||||
}
|
||||
if got := minuteSearchDescription(item); got != "回退纪要" {
|
||||
t.Fatalf("minuteSearchDescription() = %q, want 回退纪要", got)
|
||||
}
|
||||
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/fallback" {
|
||||
t.Fatalf("minuteSearchAppLink() = %q", got)
|
||||
}
|
||||
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/fallback.jpg" {
|
||||
t.Fatalf("minuteSearchAvatar() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchFieldExtractorsMissingMetaData verifies extractors fall back to empty values without metadata.
|
||||
func TestMinuteSearchFieldExtractorsMissingMetaData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
item := map[string]interface{}{
|
||||
"token": "minute_3",
|
||||
"display_info": "无元信息摘要",
|
||||
}
|
||||
|
||||
if got := minuteSearchToken(item); got != "minute_3" {
|
||||
t.Fatalf("minuteSearchToken() = %q, want minute_3", got)
|
||||
}
|
||||
if got := minuteSearchDescription(item); got != "" {
|
||||
t.Fatalf("minuteSearchDescription() = %q, want empty", got)
|
||||
}
|
||||
if got := minuteSearchAppLink(item); got != "" {
|
||||
t.Fatalf("minuteSearchAppLink() = %q, want empty", got)
|
||||
}
|
||||
if got := minuteSearchAvatar(item); got != "" {
|
||||
t.Fatalf("minuteSearchAvatar() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
// Shortcuts returns all minutes shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MinutesSearch,
|
||||
MinutesDownload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/mail"
|
||||
"github.com/larksuite/cli/shortcuts/minutes"
|
||||
"github.com/larksuite/cli/shortcuts/sheets"
|
||||
"github.com/larksuite/cli/shortcuts/slides"
|
||||
"github.com/larksuite/cli/shortcuts/task"
|
||||
"github.com/larksuite/cli/shortcuts/vc"
|
||||
"github.com/larksuite/cli/shortcuts/whiteboard"
|
||||
@@ -38,6 +39,7 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, base.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, event.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, task.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
|
||||
|
||||
81
shortcuts/sheets/sheet_add_dimension.go
Normal file
81
shortcuts/sheets/sheet_add_dimension.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetAddDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+add-dimension",
|
||||
Description: "Add rows or columns at the end of a sheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "length", Type: "int", Desc: "number of rows/columns to add (1-5000)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
length := runtime.Int("length")
|
||||
if length < 1 || length > 5000 {
|
||||
return common.FlagErrorf("--length must be between 1 and 5000, got %d", length)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
|
||||
Body(map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"length": runtime.Int("length"),
|
||||
},
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"length": runtime.Int("length"),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
83
shortcuts/sheets/sheet_batch_set_style.go
Normal file
83
shortcuts/sheets/sheet_batch_set_style.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetBatchSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+batch-set-style",
|
||||
Description: "Batch set cell styles for multiple ranges",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "data", Desc: "JSON array of {ranges, style} objects", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
}
|
||||
arr, ok := data.([]interface{})
|
||||
if !ok || len(arr) == 0 {
|
||||
return common.FlagErrorf("--data must be a non-empty JSON array")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
var data interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("data")), &data)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").
|
||||
Body(map[string]interface{}{
|
||||
"data": data,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
}
|
||||
|
||||
result, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"data": data,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
539
shortcuts/sheets/sheet_cell_ops_test.go
Normal file
539
shortcuts/sheets/sheet_cell_ops_test.go
Normal file
@@ -0,0 +1,539 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// ── MergeCells ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetMergeCellsValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL",
|
||||
}, nil)
|
||||
err := SheetMergeCells.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsValidateRelativeRangeWithoutSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL",
|
||||
}, nil)
|
||||
err := SheetMergeCells.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--sheet-id") {
|
||||
t.Fatalf("expected sheet-id error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ROWS",
|
||||
}, nil)
|
||||
if err := SheetMergeCells.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1", "merge-type": "MERGE_ALL",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetMergeCells.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `merge_cells`) {
|
||||
t.Fatalf("DryRun URL missing merge_cells: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
|
||||
t.Fatalf("DryRun range not normalized: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"mergeType":"MERGE_ALL"`) {
|
||||
t.Fatalf("DryRun missing mergeType: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetMergeCells, []string{
|
||||
"+merge-cells", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "spreadsheetToken") {
|
||||
t.Fatalf("stdout missing spreadsheetToken: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMergeCellsExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetMergeCells, []string{
|
||||
"+merge-cells", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── UnmergeCells ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetUnmergeCellsValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
}, nil)
|
||||
err := SheetUnmergeCells.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUnmergeCellsValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
}, nil)
|
||||
if err := SheetUnmergeCells.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUnmergeCellsDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetUnmergeCells.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `unmerge_cells`) {
|
||||
t.Fatalf("DryRun URL missing unmerge_cells: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
|
||||
t.Fatalf("DryRun missing range: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUnmergeCellsExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUnmergeCells, []string{
|
||||
"+unmerge-cells", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUnmergeCellsExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUnmergeCells, []string{
|
||||
"+unmerge-cells", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Replace ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetReplaceValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "find": "a", "replacement": "b", "range": "",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
err := SheetReplace.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "find": "hello", "replacement": "world", "range": "",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
if err := SheetReplace.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceValidateMismatchedRangeSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b",
|
||||
"range": "sheet2!A1:B2",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
err := SheetReplace.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match") {
|
||||
t.Fatalf("expected mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceValidateMatchingRangeSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b",
|
||||
"range": "sheet1!A1:B2",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
if err := SheetReplace.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "old", "replacement": "new", "range": "A1:C5",
|
||||
}, map[string]bool{"match-case": true, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `replace`) {
|
||||
t.Fatalf("DryRun URL missing replace: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"find":"old"`) {
|
||||
t.Fatalf("DryRun missing find: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"replacement":"new"`) {
|
||||
t.Fatalf("DryRun missing replacement: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"match_case":true`) {
|
||||
t.Fatalf("DryRun missing match_case: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceDryRunNoRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "a", "replacement": "b", "range": "",
|
||||
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
|
||||
got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt))
|
||||
// When no range specified, range defaults to sheet-id
|
||||
if !strings.Contains(got, `"range":"sheet1"`) {
|
||||
t.Fatalf("DryRun range should default to sheet-id: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"replace_result": map[string]interface{}{
|
||||
"matched_cells": []interface{}{"A1"}, "rows_count": float64(1),
|
||||
},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetReplace, []string{
|
||||
"+replace", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--find", "hello", "--replacement", "world", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "matched_cells") {
|
||||
t.Fatalf("stdout missing matched_cells: %s", stdout.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if body["find"] != "hello" || body["replacement"] != "world" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetReplaceExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetReplace, []string{
|
||||
"+replace", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--find", "a", "--replacement", "b", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── SetStyle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetSetStyleValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `{"font":{"bold":true}}`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `{invalid}`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--style must be valid JSON") {
|
||||
t.Fatalf("expected JSON error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateRejectsArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `[{"bold":true}]`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "JSON object") {
|
||||
t.Fatalf("expected object error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateRejectsString(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `"bold"`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "JSON object") {
|
||||
t.Fatalf("expected object error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateRejectsNull(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `null`,
|
||||
}, nil)
|
||||
err := SheetSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "JSON object") {
|
||||
t.Fatalf("expected object error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
|
||||
"style": `{"font":{"bold":true},"backColor":"#ff0000"}`,
|
||||
}, nil)
|
||||
if err := SheetSetStyle.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1",
|
||||
"style": `{"font":{"bold":true}}`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"PUT"`) {
|
||||
t.Fatalf("DryRun should use PUT: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `/style`) {
|
||||
t.Fatalf("DryRun URL missing /style: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
|
||||
t.Fatalf("DryRun range not normalized: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"bold":true`) {
|
||||
t.Fatalf("DryRun missing style: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"updates": map[string]interface{}{"updatedCells": float64(4), "updatedRange": "sheet1!A1:B2"},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetSetStyle, []string{
|
||||
"+set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "updatedCells") {
|
||||
t.Fatalf("stdout missing updatedCells: %s", stdout.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
appendStyle, _ := body["appendStyle"].(map[string]interface{})
|
||||
if appendStyle["range"] != "sheet1!A1:B2" {
|
||||
t.Fatalf("unexpected range: %v", appendStyle["range"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetSetStyle, []string{
|
||||
"+set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── BatchSetStyle ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetBatchSetStyleValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "",
|
||||
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`,
|
||||
}, nil)
|
||||
err := SheetBatchSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleValidateInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "data": `not-json`,
|
||||
}, nil)
|
||||
err := SheetBatchSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--data must be valid JSON") {
|
||||
t.Fatalf("expected JSON error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleValidateNotArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "data": `{"not":"array"}`,
|
||||
}, nil)
|
||||
err := SheetBatchSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") {
|
||||
t.Fatalf("expected array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleValidateEmptyArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "data": `[]`,
|
||||
}, nil)
|
||||
err := SheetBatchSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") {
|
||||
t.Fatalf("expected empty array error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1",
|
||||
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`,
|
||||
}, nil)
|
||||
if err := SheetBatchSetStyle.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test",
|
||||
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"backColor":"#ff0000"}}]`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `styles_batch_update`) {
|
||||
t.Fatalf("DryRun URL missing styles_batch_update: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"method":"PUT"`) {
|
||||
t.Fatalf("DryRun should use PUT: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"totalUpdatedCells": float64(4), "revision": float64(90),
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
|
||||
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--data", `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "totalUpdatedCells") {
|
||||
t.Fatalf("stdout missing totalUpdatedCells: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
|
||||
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--data", `[{"ranges":["sheet1!A1:B2"],"style":{}}]`, "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
86
shortcuts/sheets/sheet_delete_dimension.go
Normal file
86
shortcuts/sheets/sheet_delete_dimension.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetDeleteDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-dimension",
|
||||
Description: "Delete rows or columns",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
|
||||
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if runtime.Int("start-index") < 1 {
|
||||
return common.FlagErrorf("--start-index must be >= 1")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
|
||||
Body(map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("DELETE",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
923
shortcuts/sheets/sheet_dimension_test.go
Normal file
923
shortcuts/sheets/sheet_dimension_test.go
Normal file
@@ -0,0 +1,923 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// newDimTestRuntime creates a RuntimeContext with string, int, and bool flags.
|
||||
func newDimTestRuntime(t *testing.T, strFlags map[string]string, intFlags map[string]int, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for name := range strFlags {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range intFlags {
|
||||
cmd.Flags().Int(name, 0, "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for name, value := range strFlags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
for name, value := range intFlags {
|
||||
if err := cmd.Flags().Set(name, strconv.Itoa(value)); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
for name, value := range boolFlags {
|
||||
if err := cmd.Flags().Set(name, strconv.FormatBool(value)); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func marshalDryRun(t *testing.T, v interface{}) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ── AddDimension ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetAddDimensionValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"length": 10}, nil)
|
||||
err := SheetAddDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionValidateLengthOutOfRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, length := range []int{0, -1, 5001} {
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"length": length}, nil)
|
||||
err := SheetAddDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--length") {
|
||||
t.Fatalf("length=%d: expected length error, got: %v", length, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"length": 100}, nil)
|
||||
if err := SheetAddDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionValidateWithURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
|
||||
map[string]int{"length": 5}, nil)
|
||||
if err := SheetAddDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
|
||||
map[string]int{"length": 8}, nil)
|
||||
got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt))
|
||||
|
||||
if !strings.Contains(got, `dimension_range`) {
|
||||
t.Fatalf("DryRun URL missing dimension_range: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"sheetId":"sheet1"`) {
|
||||
t.Fatalf("DryRun missing sheetId: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"majorDimension":"ROWS"`) {
|
||||
t.Fatalf("DryRun missing majorDimension: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"length":8`) {
|
||||
t.Fatalf("DryRun missing length: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionDryRunWithURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
|
||||
map[string]int{"length": 3}, nil)
|
||||
got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, "shtFromURL") {
|
||||
t.Fatalf("DryRun should extract token from URL: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "Success",
|
||||
"data": map[string]interface{}{"addCount": float64(8), "majorDimension": "ROWS"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetAddDimension, []string{
|
||||
"+add-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--length", "8",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"addCount"`) {
|
||||
t.Fatalf("stdout missing addCount: %s", stdout.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse request body: %v", err)
|
||||
}
|
||||
dim, _ := body["dimension"].(map[string]interface{})
|
||||
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
|
||||
t.Fatalf("unexpected request body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetAddDimension, []string{
|
||||
"+add-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--length", "8",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ── InsertDimension ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetInsertDimensionValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
|
||||
map[string]int{"start-index": 0, "end-index": 3}, nil)
|
||||
err := SheetInsertDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionValidateNegativeStartIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
|
||||
map[string]int{"start-index": -1, "end-index": 3}, nil)
|
||||
err := SheetInsertDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--start-index") {
|
||||
t.Fatalf("expected start-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionValidateEndNotGreaterThanStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
|
||||
map[string]int{"start-index": 5, "end-index": 5}, nil)
|
||||
err := SheetInsertDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--end-index") {
|
||||
t.Fatalf("expected end-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS", "inherit-style": ""},
|
||||
map[string]int{"start-index": 0, "end-index": 4}, nil)
|
||||
if err := SheetInsertDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS", "inherit-style": "BEFORE"},
|
||||
map[string]int{"start-index": 3, "end-index": 7}, nil)
|
||||
got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt))
|
||||
|
||||
if !strings.Contains(got, `insert_dimension_range`) {
|
||||
t.Fatalf("DryRun URL missing insert_dimension_range: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"startIndex":3`) {
|
||||
t.Fatalf("DryRun missing startIndex: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"endIndex":7`) {
|
||||
t.Fatalf("DryRun missing endIndex: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"inheritStyle":"BEFORE"`) {
|
||||
t.Fatalf("DryRun missing inheritStyle: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionDryRunNoInheritStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "COLUMNS", "inherit-style": ""},
|
||||
map[string]int{"start-index": 0, "end-index": 2}, nil)
|
||||
got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt))
|
||||
|
||||
if strings.Contains(got, `inheritStyle`) {
|
||||
t.Fatalf("DryRun should omit inheritStyle when empty: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetInsertDimension, []string{
|
||||
"+insert-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "3",
|
||||
"--end-index", "7",
|
||||
"--inherit-style", "AFTER",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse request body: %v", err)
|
||||
}
|
||||
dim, _ := body["dimension"].(map[string]interface{})
|
||||
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
|
||||
t.Fatalf("unexpected dimension: %#v", dim)
|
||||
}
|
||||
if body["inheritStyle"] != "AFTER" {
|
||||
t.Fatalf("unexpected inheritStyle: %v", body["inheritStyle"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionExecuteWithoutInheritStyle(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetInsertDimension, []string{
|
||||
"+insert-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "COLUMNS",
|
||||
"--start-index", "0",
|
||||
"--end-index", "2",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse request body: %v", err)
|
||||
}
|
||||
if _, ok := body["inheritStyle"]; ok {
|
||||
t.Fatalf("inheritStyle should be absent when not specified: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInsertDimensionExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetInsertDimension, []string{
|
||||
"+insert-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "0",
|
||||
"--end-index", "3",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateDimension ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetUpdateDimensionValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50},
|
||||
map[string]bool{"visible": true})
|
||||
err := SheetUpdateDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionValidateStartIndexLessThan1(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 0, "end-index": 3, "fixed-size": 50},
|
||||
map[string]bool{"visible": true})
|
||||
err := SheetUpdateDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--start-index") {
|
||||
t.Fatalf("expected start-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionValidateEndLessThanStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 5, "end-index": 3, "fixed-size": 50},
|
||||
map[string]bool{"visible": true})
|
||||
err := SheetUpdateDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--end-index") {
|
||||
t.Fatalf("expected end-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionValidateNoProperties(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Neither --visible nor --fixed-size is set (Changed returns false)
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3}, nil)
|
||||
// Register the flags but don't set them so Changed() returns false
|
||||
rt.Cmd.Flags().Bool("visible", false, "")
|
||||
rt.Cmd.Flags().Int("fixed-size", 0, "")
|
||||
err := SheetUpdateDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--visible or --fixed-size") {
|
||||
t.Fatalf("expected properties error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionValidateSuccessWithVisible(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3},
|
||||
map[string]bool{"visible": true})
|
||||
// Ensure fixed-size flag exists but is not set
|
||||
rt.Cmd.Flags().Int("fixed-size", 0, "")
|
||||
if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionValidateFixedSizeZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 0}, nil)
|
||||
rt.Cmd.Flags().Bool("visible", false, "")
|
||||
err := SheetUpdateDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") {
|
||||
t.Fatalf("expected fixed-size error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionValidateFixedSizeNegative(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": -10}, nil)
|
||||
rt.Cmd.Flags().Bool("visible", false, "")
|
||||
err := SheetUpdateDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") {
|
||||
t.Fatalf("expected fixed-size error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionValidateSuccessWithFixedSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
|
||||
map[string]int{"start-index": 1, "end-index": 5, "fixed-size": 120}, nil)
|
||||
// Ensure visible flag exists but is not set
|
||||
rt.Cmd.Flags().Bool("visible", false, "")
|
||||
if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50},
|
||||
map[string]bool{"visible": true})
|
||||
got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt))
|
||||
|
||||
if !strings.Contains(got, `"method":"PUT"`) {
|
||||
t.Fatalf("DryRun should use PUT: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `dimension_range`) {
|
||||
t.Fatalf("DryRun URL missing dimension_range: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"visible":true`) {
|
||||
t.Fatalf("DryRun missing visible: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"fixedSize":50`) {
|
||||
t.Fatalf("DryRun missing fixedSize: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionDryRunOnlyVisible(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3},
|
||||
map[string]bool{"visible": false})
|
||||
// Add fixed-size flag but don't set it
|
||||
rt.Cmd.Flags().Int("fixed-size", 0, "")
|
||||
got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt))
|
||||
|
||||
if !strings.Contains(got, `"visible":false`) {
|
||||
t.Fatalf("DryRun missing visible: %s", got)
|
||||
}
|
||||
if strings.Contains(got, `fixedSize`) {
|
||||
t.Fatalf("DryRun should omit fixedSize when not set: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetUpdateDimension, []string{
|
||||
"+update-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "1",
|
||||
"--end-index", "3",
|
||||
"--visible=true",
|
||||
"--fixed-size", "50",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse request body: %v", err)
|
||||
}
|
||||
props, _ := body["dimensionProperties"].(map[string]interface{})
|
||||
if props["visible"] != true {
|
||||
t.Fatalf("expected visible=true, got: %#v", props)
|
||||
}
|
||||
if props["fixedSize"] != float64(50) {
|
||||
t.Fatalf("expected fixedSize=50, got: %#v", props)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateDimensionExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetUpdateDimension, []string{
|
||||
"+update-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "1",
|
||||
"--end-index", "3",
|
||||
"--visible=true",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ── MoveDimension ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetMoveDimensionValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
|
||||
err := SheetMoveDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionValidateNegativeStartIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": -1, "end-index": 1, "destination-index": 4}, nil)
|
||||
err := SheetMoveDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--start-index") {
|
||||
t.Fatalf("expected start-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionValidateEndLessThanStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 5, "end-index": 3, "destination-index": 0}, nil)
|
||||
err := SheetMoveDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--end-index") {
|
||||
t.Fatalf("expected end-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionValidateNegativeDestinationIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 0, "end-index": 1, "destination-index": -1}, nil)
|
||||
err := SheetMoveDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--destination-index") {
|
||||
t.Fatalf("expected destination-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
|
||||
map[string]int{"start-index": 0, "end-index": 2, "destination-index": 5}, nil)
|
||||
if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionValidateWithURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
|
||||
if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
|
||||
got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt))
|
||||
|
||||
if !strings.Contains(got, `move_dimension`) {
|
||||
t.Fatalf("DryRun URL missing move_dimension: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"major_dimension":"ROWS"`) {
|
||||
t.Fatalf("DryRun missing major_dimension: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"start_index":0`) {
|
||||
t.Fatalf("DryRun missing start_index: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"destination_index":4`) {
|
||||
t.Fatalf("DryRun missing destination_index: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionDryRunWithURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3, "destination-index": 0}, nil)
|
||||
got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, "shtFromURL") {
|
||||
t.Fatalf("DryRun should extract token from URL: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetMoveDimension, []string{
|
||||
"+move-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "0",
|
||||
"--end-index", "1",
|
||||
"--destination-index", "4",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse request body: %v", err)
|
||||
}
|
||||
source, _ := body["source"].(map[string]interface{})
|
||||
if source["major_dimension"] != "ROWS" {
|
||||
t.Fatalf("unexpected major_dimension: %v", source["major_dimension"])
|
||||
}
|
||||
if body["destination_index"] != float64(4) {
|
||||
t.Fatalf("unexpected destination_index: %v", body["destination_index"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionExecuteWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/move_dimension",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetMoveDimension, []string{
|
||||
"+move-dimension",
|
||||
"--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "COLUMNS",
|
||||
"--start-index", "1",
|
||||
"--end-index", "2",
|
||||
"--destination-index", "0",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMoveDimensionExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"code": 1310211, "msg": "wrong sheet id"},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetMoveDimension, []string{
|
||||
"+move-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "0",
|
||||
"--end-index", "1",
|
||||
"--destination-index", "4",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteDimension ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetDeleteDimensionValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 1, "end-index": 3}, nil)
|
||||
err := SheetDeleteDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionValidateStartIndexLessThan1(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 0, "end-index": 3}, nil)
|
||||
err := SheetDeleteDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--start-index") {
|
||||
t.Fatalf("expected start-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionValidateEndLessThanStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
|
||||
map[string]int{"start-index": 5, "end-index": 3}, nil)
|
||||
err := SheetDeleteDimension.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--end-index") {
|
||||
t.Fatalf("expected end-index error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 3, "end-index": 7}, nil)
|
||||
if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionValidateWithURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
|
||||
map[string]int{"start-index": 1, "end-index": 2}, nil)
|
||||
if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
|
||||
map[string]int{"start-index": 3, "end-index": 7}, nil)
|
||||
got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt))
|
||||
|
||||
if !strings.Contains(got, `"method":"DELETE"`) {
|
||||
t.Fatalf("DryRun should use DELETE: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `dimension_range`) {
|
||||
t.Fatalf("DryRun URL missing dimension_range: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"startIndex":3`) {
|
||||
t.Fatalf("DryRun missing startIndex: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"endIndex":7`) {
|
||||
t.Fatalf("DryRun missing endIndex: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionDryRunWithURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"},
|
||||
map[string]int{"start-index": 1, "end-index": 5}, nil)
|
||||
got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, "shtFromURL") {
|
||||
t.Fatalf("DryRun should extract token from URL: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"delCount": float64(5), "majorDimension": "ROWS"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
|
||||
"+delete-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "3",
|
||||
"--end-index", "7",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"delCount"`) {
|
||||
t.Fatalf("stdout missing delCount: %s", stdout.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse request body: %v", err)
|
||||
}
|
||||
dim, _ := body["dimension"].(map[string]interface{})
|
||||
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
|
||||
t.Fatalf("unexpected dimension: %#v", dim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionExecuteWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dimension_range",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"delCount": float64(2), "majorDimension": "COLUMNS"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
|
||||
"+delete-dimension",
|
||||
"--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "COLUMNS",
|
||||
"--start-index", "1",
|
||||
"--end-index", "2",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteDimensionExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
|
||||
"+delete-dimension",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dimension", "ROWS",
|
||||
"--start-index", "3",
|
||||
"--end-index", "7",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
}
|
||||
95
shortcuts/sheets/sheet_insert_dimension.go
Normal file
95
shortcuts/sheets/sheet_insert_dimension.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetInsertDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+insert-dimension",
|
||||
Description: "Insert rows or columns at a specified position",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "start-index", Type: "int", Desc: "start position (0-indexed)", Required: true},
|
||||
{Name: "end-index", Type: "int", Desc: "end position (0-indexed, exclusive)", Required: true},
|
||||
{Name: "inherit-style", Desc: "style inheritance: BEFORE or AFTER", Enum: []string{"BEFORE", "AFTER"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if runtime.Int("start-index") < 0 {
|
||||
return common.FlagErrorf("--start-index must be >= 0")
|
||||
}
|
||||
if runtime.Int("end-index") <= runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be greater than --start-index")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
}
|
||||
if s := runtime.Str("inherit-style"); s != "" {
|
||||
body["inheritStyle"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/insert_dimension_range").
|
||||
Body(body).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
}
|
||||
if s := runtime.Str("inherit-style"); s != "" {
|
||||
body["inheritStyle"] = s
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)),
|
||||
nil, body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user