mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 23:15:25 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3756f3642 | ||
|
|
27a2f2758b | ||
|
|
15ae1fabec | ||
|
|
d317493e49 | ||
|
|
a8f078478e | ||
|
|
06275415b1 | ||
|
|
b4c9c09de0 | ||
|
|
7fb71c6947 | ||
|
|
020aeb87ad | ||
|
|
686c91dc71 | ||
|
|
cfd89e0e28 | ||
|
|
ac4c34f2ad | ||
|
|
3ed691b25c | ||
|
|
30ad38d4b6 | ||
|
|
4fab062219 | ||
|
|
f27b8fdf40 | ||
|
|
c100ca049e | ||
|
|
4d68e09537 | ||
|
|
a3bbe00ee0 | ||
|
|
0250054a90 | ||
|
|
d7ee5b5769 |
39
CHANGELOG.md
39
CHANGELOG.md
@@ -2,6 +2,43 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.24] - 2026-05-06
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add sheet management shortcuts (#722)
|
||||
- **base**: Support batch record get and delete (#630)
|
||||
- **task**: Add upload task attachment shortcut (#736)
|
||||
- **drive**: Pre-flight 10000-rune total cap for `+add-comment` `reply_elements` (#605)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **auth**: Handle missing scopes and device flow improvements (#752)
|
||||
- Add url to markdown `+create` output (#753)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Refine field update conversion guidance (#748)
|
||||
|
||||
## [v1.0.23] - 2026-04-30
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Add `+pull` shortcut for one-way Drive → local mirror (#696)
|
||||
- **drive**: Add `+push` shortcut for one-way local → Drive mirror (#709)
|
||||
- **drive**: Add `+status` shortcut for content-hash diff (#692)
|
||||
- **drive**: Support `--file-name` for drive export (#685)
|
||||
- **base**: Add markdown output for record reads (#726)
|
||||
- **minutes**: Add media upload shortcut (#725)
|
||||
- **doc**: Warn when callout uses `type=` without `background-color` (#467)
|
||||
- **cmdutil**: Support `@file` for params and data (#724)
|
||||
- Add markdown shortcuts and skill docs (#704)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Guide lark-doc v2 usage (#710)
|
||||
- **minutes**: Clarify minutes file-to-notes routing (#732)
|
||||
|
||||
## [v1.0.22] - 2026-04-29
|
||||
|
||||
### Features
|
||||
@@ -560,6 +597,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.24]: https://github.com/larksuite/cli/releases/tag/v1.0.24
|
||||
[v1.0.23]: https://github.com/larksuite/cli/releases/tag/v1.0.23
|
||||
[v1.0.22]: https://github.com/larksuite/cli/releases/tag/v1.0.22
|
||||
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21
|
||||
[v1.0.20]: https://github.com/larksuite/cli/releases/tag/v1.0.20
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 23 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 23 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 16 business domains, 200+ curated commands, 23 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/)
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
||||
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||
@@ -28,6 +28,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
@@ -139,6 +140,7 @@ lark-cli auth status
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 23 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — 23 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 16 大业务域、200+ 精选命令、23 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -28,6 +28,7 @@
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
@@ -140,6 +141,7 @@ lark-cli auth status
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
|
||||
@@ -81,8 +81,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin, @file for file input)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
||||
@@ -112,6 +112,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
// Validate --file mutual exclusions first.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
|
||||
@@ -123,7 +124,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
@@ -145,7 +146,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
// Parse --data as JSON map for form fields (not as body).
|
||||
var dataFields any
|
||||
if opts.Data != "" {
|
||||
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
@@ -161,7 +162,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
fileIO,
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -171,7 +172,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
// Normal path: JSON body.
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -42,7 +43,18 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not configured yet. Run `lark-cli config init` to initialize.")
|
||||
// auth list is a read-only probe; the "configured but no users"
|
||||
// branch below already returns exit 0 with a stderr hint, so we
|
||||
// keep the same contract here. We still want the hint to be
|
||||
// workspace-aware, so we pull the message+hint out of
|
||||
// NotConfiguredError() instead of hard-coding it.
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
||||
if cfgErr.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
59
cmd/auth/list_test.go
Normal file
59
cmd/auth/list_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
|
||||
// `lark-cli auth list` is a read-only probe and must not fail-hard when no
|
||||
// config exists yet — scripts and AI agents use it as an idempotent "do I
|
||||
// have any users?" check, so the exit code carries semantic weight. Pair
|
||||
// that with the existing "configured but no logged-in users" branch (also
|
||||
// exit 0) and both empty states are consistent.
|
||||
func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||
}
|
||||
// Local workspace → hint must mention init, not bind.
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "config init") {
|
||||
t.Errorf("local hint missing config init: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "config bind") {
|
||||
t.Errorf("local hint must not mention config bind: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
||||
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||
// `config bind --help` instead of the local-only `config init`.
|
||||
func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
prev := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(prev) })
|
||||
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should still succeed under agent workspace; got: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "config bind --help") {
|
||||
t.Errorf("agent hint must point at config bind --help: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "config init") {
|
||||
t.Errorf("agent hint must not mention config init: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,9 @@ For AI agents: this command blocks until the user completes authorization in the
|
||||
browser. Run it in the background and retrieve the verification URL from its output.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, user login is not allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode)
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
|
||||
"if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
@@ -243,7 +242,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL
|
||||
// Step 2: Show user code and verification URL.
|
||||
// Both branches surface AgentTimeoutHint, but on different channels:
|
||||
// JSON mode embeds it as a structured field (so an agent that captures
|
||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
||||
// text mode prints to stderr (alongside the URL prompt).
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -251,6 +254,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_uri_complete": authResp.VerificationUriComplete,
|
||||
"user_code": authResp.UserCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"agent_hint": msg.AgentTimeoutHint,
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
@@ -260,6 +264,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -346,9 +351,15 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
|
||||
// device_code already returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
|
||||
opts.DeviceCode, 5, 600, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
if shouldRemoveLoginRequestedScope(result) {
|
||||
|
||||
@@ -22,6 +22,7 @@ type loginMsg struct {
|
||||
// Non-interactive prompts (login.go)
|
||||
OpenURL string
|
||||
WaitingAuth string
|
||||
AgentTimeoutHint string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
AuthorizedUser string
|
||||
@@ -58,6 +59,7 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout ≥ 600s;如不支持长 timeout,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询,**不要短 timeout 反复重试**——每次重启会作废上一轮的 device code,导致用户授权的链接失效。",
|
||||
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
@@ -93,6 +95,7 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is ≥ 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout** — each restart invalidates the previous device code, so any URL the user already authorized becomes useless.",
|
||||
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
@@ -122,5 +125,5 @@ func getLoginMsg(lang string) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs"}
|
||||
return []string{"base", "contact", "docs", "markdown"}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -94,3 +95,21 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentTimeoutHint_CarriesKeyInfo guards the contract that the synchronous
|
||||
// auth-login output tells AI agents two things: (a) this command blocks for
|
||||
// minutes — set a long runner timeout, and (b) the alternative is the
|
||||
// --no-wait + --device-code split-flow. Without (a) AI sets a 10s timeout and
|
||||
// kills the process before the user can authorize; without (b) the AI has no
|
||||
// recovery path and just retries with the same short timeout, invalidating
|
||||
// each new device code in turn.
|
||||
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
hint := getLoginMsg(lang).AgentTimeoutHint
|
||||
for _, want := range []string{"--no-wait", "--device-code"} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("%s AgentTimeoutHint missing %q: %s", lang, want, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
if loginSucceeded {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return nil
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
detail := map[string]interface{}{
|
||||
"requested": issue.Summary.Requested,
|
||||
@@ -200,9 +200,6 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
if issue.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
|
||||
}
|
||||
if loginSucceeded {
|
||||
return nil
|
||||
}
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/zalando/go-keyring"
|
||||
@@ -371,8 +372,12 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
@@ -410,8 +415,12 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
@@ -616,8 +625,12 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
|
||||
@@ -62,11 +62,32 @@ func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.
|
||||
Short: "Bind Agent config to a workspace (source / app-id / force)",
|
||||
Long: `Bind an AI Agent's (OpenClaw / Hermes) Feishu credentials to a lark-cli workspace.
|
||||
|
||||
For AI agents: pass --source and --app-id to bind non-interactively.
|
||||
Credentials are synced once; subsequent calls in the Agent's process
|
||||
context automatically use the bound workspace.`,
|
||||
Example: ` lark-cli config bind --source openclaw --app-id <id>
|
||||
lark-cli config bind --source hermes`,
|
||||
--source is auto-detected from env (OPENCLAW_HOME / HERMES_HOME); pass it only to override.
|
||||
|
||||
For AI agents — DO NOT bind without user confirmation. Binding may
|
||||
overwrite an existing one and locks in an identity policy. Ask the user:
|
||||
|
||||
--identity bot-only bot only (safer default; no impersonation;
|
||||
cannot access user resources like personal
|
||||
calendar / mail / drive)
|
||||
--identity user-default user identity allowed (impersonates the user;
|
||||
needed for personal-resource access)
|
||||
|
||||
Default to bot-only if the user is unsure. Only run the command after
|
||||
the user confirms both intent and identity preset.
|
||||
|
||||
If lark-cli is already bound and the user only wants to change identity
|
||||
policy on the SAME app, use 'config strict-mode' — that's the policy
|
||||
switch and does not require re-bind. Use 'config bind' only when the
|
||||
underlying app itself changes.
|
||||
|
||||
Interactive terminal use: run with no flags to enter the TUI form.`,
|
||||
Example: ` # AI flow: confirm intent + identity with user FIRST, then run:
|
||||
lark-cli config bind --source openclaw --app-id <id> --identity bot-only
|
||||
lark-cli config bind --source hermes --identity user-default
|
||||
|
||||
# Interactive (terminal user) — TUI prompts for everything:
|
||||
lark-cli config bind`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
if runF != nil {
|
||||
@@ -125,6 +146,7 @@ func configBindRun(opts *BindOptions) error {
|
||||
return err
|
||||
}
|
||||
applyPreferences(appConfig, opts)
|
||||
noticeUserDefaultRisk(opts)
|
||||
|
||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
||||
}
|
||||
@@ -308,6 +330,23 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
|
||||
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
|
||||
}
|
||||
|
||||
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
|
||||
// flag-mode bind that lands on user-default. The bot-only → user-default
|
||||
// escalation is already covered by warnIdentityEscalation (errors out before
|
||||
// applyPreferences runs), and the TUI flow shows IdentityUserDefaultDesc
|
||||
// during identity selection — so this fires specifically for the case those
|
||||
// two miss: a fresh flag-mode bind that goes directly to user-default with
|
||||
// no previous bot lock to escalate from. Without this, AI agents finish such
|
||||
// a bind with only a "配置成功" message and never relay to the user that the
|
||||
// AI can now act under their identity.
|
||||
func noticeUserDefaultRisk(opts *BindOptions) {
|
||||
if opts.IsTUI || opts.Identity != "user-default" {
|
||||
return
|
||||
}
|
||||
msg := getBindMsg(opts.Lang)
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
|
||||
}
|
||||
|
||||
// applyPreferences expands the chosen identity preset into the underlying
|
||||
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
|
||||
// profile's intent survives later changes to global strict-mode settings.
|
||||
|
||||
@@ -377,16 +377,28 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unbound workspace")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
// Should be a structured ConfigError suggesting config bind, not config init.
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
|
||||
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
|
||||
}
|
||||
// Hint must point at config bind --help (NOT a ready-to-run bind command):
|
||||
// AI must read the help and confirm identity preset with the user first.
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hint must point at `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config init") {
|
||||
t.Errorf("agent hint must not mention config init; got %q", cfgErr.Hint)
|
||||
}
|
||||
// Should suggest config bind, not config init
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "openclaw context detected but lark-cli not bound to openclaw workspace",
|
||||
Hint: "run: lark-cli config bind --source openclaw",
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helper function tests (dotenv, brand, path resolution) ──
|
||||
|
||||
62
cmd/config/bind_warning_test.go
Normal file
62
cmd/config/bind_warning_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// runHermesBindWithIdentity boots a Hermes-shaped fake env, runs `config bind`
|
||||
// with the given identity preset in flag (non-TUI) mode, and returns captured
|
||||
// stderr. Hermes is the simplest source to fake (single .env file).
|
||||
func runHermesBindWithIdentity(t *testing.T, identity string) string {
|
||||
t.Helper()
|
||||
saveWorkspace(t)
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
hermesHome := t.TempDir()
|
||||
t.Setenv("HERMES_HOME", hermesHome)
|
||||
envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"
|
||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte(envContent), 0600); err != nil {
|
||||
t.Fatalf("write .env: %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{
|
||||
Factory: f,
|
||||
Source: "hermes",
|
||||
Identity: identity,
|
||||
Lang: "zh",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bind failed: %v", err)
|
||||
}
|
||||
return stderr.String()
|
||||
}
|
||||
|
||||
// TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation covers the
|
||||
// gap that previously slipped through: a fresh flag-mode bind landing on
|
||||
// user-default. warnIdentityEscalation requires a previous bot lock to fire,
|
||||
// and IdentityUserDefaultDesc only renders in TUI selection — so without
|
||||
// noticeUserDefaultRisk the user/AI never see the impersonation risk on a
|
||||
// first-time user-default bind.
|
||||
func TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation(t *testing.T) {
|
||||
out := runHermesBindWithIdentity(t, "user-default")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("user-default bind must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigBindRun_BotOnlyIdentity_NoImpersonationWarning(t *testing.T) {
|
||||
out := runHermesBindWithIdentity(t, "bot-only")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot-only bind must NOT warn about impersonation; got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -90,15 +90,15 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "not configured" {
|
||||
t.Fatalf("detail = %#v, want config/not configured", exitErr.Detail)
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,14 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
@@ -33,6 +34,13 @@ type ConfigInitOptions struct {
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||
|
||||
// ForceInit overrides the agent-workspace guard. Without it, running
|
||||
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
|
||||
// at config bind — which is what AI agents almost always want. Manual
|
||||
// users with a legitimate need for a separate app can pass --force-init
|
||||
// to bypass.
|
||||
ForceInit bool
|
||||
}
|
||||
|
||||
// NewCmdConfigInit creates the config init subcommand.
|
||||
@@ -46,10 +54,18 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.`,
|
||||
verification URL from its output.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
|
||||
refuses by default — use 'lark-cli config bind' to bind to the Agent's
|
||||
existing app instead of creating a parallel one. Pass --force-init only
|
||||
if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
if err := guardAgentWorkspace(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -63,10 +79,33 @@ verification URL from its output.`,
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
|
||||
// Hermes Agent context, because the Agent has already provisioned an app
|
||||
// and 'config bind' is the right tool for hooking lark-cli into it.
|
||||
// Running init here would create a parallel app under the agent's workspace
|
||||
// dir, breaking the binding the user actually wants. --force-init lets a
|
||||
// human user override when they really do want a separate app.
|
||||
func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
if opts.ForceInit {
|
||||
return nil
|
||||
}
|
||||
ws := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
if ws.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
return &core.ConfigError{
|
||||
Code: 2,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
|
||||
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
|
||||
return o.New || o.AppID != "" || o.AppSecretStdin
|
||||
|
||||
69
cmd/config/init_guard_test.go
Normal file
69
cmd/config/init_guard_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
t.Setenv("OPENCLAW_CLI", "")
|
||||
t.Setenv("HERMES_HOME", "")
|
||||
|
||||
if err := guardAgentWorkspace(&ConfigInitOptions{}); err != nil {
|
||||
t.Errorf("local workspace should allow init, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", t.TempDir())
|
||||
|
||||
err := guardAgentWorkspace(&ConfigInitOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in OpenClaw context, got nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "--force-init") {
|
||||
t.Errorf("hint must mention --force-init escape hatch; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
|
||||
t.Setenv("HERMES_HOME", t.TempDir())
|
||||
|
||||
err := guardAgentWorkspace(&ConfigInitOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in Hermes context, got nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "hermes" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_ForceInitOverride(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", t.TempDir())
|
||||
|
||||
// --force-init must let the user proceed even inside an Agent context.
|
||||
if err := guardAgentWorkspace(&ConfigInitOptions{ForceInit: true}); err != nil {
|
||||
t.Errorf("--force-init should bypass the guard, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,12 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return notConfiguredError()
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
@@ -75,18 +75,3 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
// notConfiguredError returns the "not configured" error with a hint that
|
||||
// points the user to the right next step: config init for the default local
|
||||
// workspace, config bind for an Agent workspace that has not been bound yet.
|
||||
func notConfiguredError() error {
|
||||
ws := core.CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return output.ErrWithHint(output.ExitValidation, "config",
|
||||
"not configured",
|
||||
"run: lark-cli config init")
|
||||
}
|
||||
return output.ErrWithHint(output.ExitValidation, ws.Display(),
|
||||
fmt.Sprintf("%s context detected but lark-cli not bound to %s workspace", ws.Display(), ws.Display()),
|
||||
fmt.Sprintf("run: lark-cli config bind --source %s", ws.Display()))
|
||||
}
|
||||
|
||||
@@ -21,44 +21,44 @@ func NewCmdConfigStrictMode(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "strict-mode [bot|user|off]",
|
||||
Short: "View or set strict mode (identity restriction policy)",
|
||||
Long: `View or set strict mode (identity restriction policy).
|
||||
Long: `View or set strict mode — the identity restriction policy.
|
||||
|
||||
Without arguments, shows the current strict mode status and its source.
|
||||
Pass "bot", "user", or "off" to set strict mode.
|
||||
Use --global to set at the global level.
|
||||
Use --reset to clear the profile-level setting (inherit global).
|
||||
bot only bot identity allowed (user commands hidden)
|
||||
user only user identity allowed (bot commands hidden)
|
||||
off no restriction (default)
|
||||
|
||||
Modes:
|
||||
bot — only bot identity is allowed, user commands are hidden
|
||||
user — only user identity is allowed, bot commands are hidden
|
||||
off — no restriction (default)
|
||||
No args: show current mode. Switching does NOT require re-bind.
|
||||
|
||||
WARNING: Strict mode is a security policy set by the administrator.
|
||||
AI agents are strictly prohibited from modifying this setting.`,
|
||||
For AI agents: this is a security policy. DO NOT switch without
|
||||
explicit user confirmation — never run on your own initiative.`,
|
||||
Example: ` lark-cli config strict-mode # show current
|
||||
lark-cli config strict-mode user # switch (after user confirms)
|
||||
lark-cli config strict-mode bot --global # set globally
|
||||
lark-cli config strict-mode --reset # clear profile override`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
if reset {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return resetStrictMode(f, multi, app, global, args)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return showStrictMode(cmd.Context(), f, multi, app)
|
||||
}
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if !global && app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return setStrictMode(f, multi, app, args[0], global)
|
||||
},
|
||||
@@ -106,6 +106,24 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
|
||||
}
|
||||
|
||||
// Capture the old mode at the SAME scope being changed, so we can warn
|
||||
// only when the policy actually expands user-identity at that scope.
|
||||
// --global → compare raw multi.StrictMode (profiles with explicit
|
||||
// overrides are unaffected; their warning comes from the existing
|
||||
// "profile %q has strict-mode explicitly set" notice below).
|
||||
// profile → compare effective mode (override > global > default), so
|
||||
// a profile flipping from inherited bot to explicit off still warns.
|
||||
// The previous version always used the profile's effective mode, which
|
||||
// false-positived (--global change while current profile has an explicit
|
||||
// override) and false-negatived (--global broadening that doesn't affect
|
||||
// the current profile but does affect other inheriting profiles).
|
||||
var oldMode core.StrictMode
|
||||
if global {
|
||||
oldMode = multi.StrictMode
|
||||
} else {
|
||||
oldMode, _ = resolveStrictModeStatus(multi, app)
|
||||
}
|
||||
|
||||
if global {
|
||||
multi.StrictMode = mode
|
||||
for _, a := range multi.Apps {
|
||||
@@ -119,7 +137,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
}
|
||||
} else {
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
app.StrictMode = &mode
|
||||
}
|
||||
@@ -127,6 +145,11 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "⚠️ "+strictModeRelaxLang(app).IdentityEscalationMessage)
|
||||
}
|
||||
|
||||
scope := "profile"
|
||||
if global {
|
||||
scope = "global"
|
||||
@@ -135,6 +158,16 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictModeRelaxLang picks the bind-message bundle whose language matches the
|
||||
// active profile's Lang setting. Falls back to bindMsgZh when no profile is
|
||||
// available (global mutation with no current app).
|
||||
func strictModeRelaxLang(app *core.AppConfig) *bindMsg {
|
||||
if app != nil {
|
||||
return getBindMsg(app.Lang)
|
||||
}
|
||||
return getBindMsg("")
|
||||
}
|
||||
|
||||
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) {
|
||||
if app != nil && app.StrictMode != nil {
|
||||
return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName())
|
||||
|
||||
140
cmd/config/strict_mode_warning_test.go
Normal file
140
cmd/config/strict_mode_warning_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// runStrictMode is a small helper that runs `config strict-mode <args...>` and
|
||||
// returns the captured stderr — that's where success-path messages and the
|
||||
// new user-identity warning land.
|
||||
func runStrictMode(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("strict-mode %v failed: %v", args, err)
|
||||
}
|
||||
return stderr.String()
|
||||
}
|
||||
|
||||
// expandsUserIdentity covers the only two transitions where AI gains the
|
||||
// ability to act under the user's identity, and asserts the warning fires.
|
||||
// Reuses bind_messages.go's IdentityEscalationMessage as the canonical text
|
||||
// so all three call sites (bind upgrade, fresh user-default bind, strict-mode
|
||||
// relax) stay phrased identically.
|
||||
func TestStrictMode_BotToUser_WarnsAboutIdentityRisk(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot")
|
||||
|
||||
out := runStrictMode(t, "user")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot→user transition must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_BotToOff_WarnsAboutIdentityRisk(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot")
|
||||
|
||||
out := runStrictMode(t, "off")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot→off transition must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// narrowingDoesNotWarn covers the cases that revoke or keep user-identity
|
||||
// scope — those should stay quiet, otherwise AI will spam users with risk
|
||||
// text on every restrictive change.
|
||||
func TestStrictMode_UserToBot_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "user")
|
||||
|
||||
out := runStrictMode(t, "bot")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("user→bot is a narrowing change; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_OffToBot_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
// Default starts at off; explicitly set bot — narrowing.
|
||||
out := runStrictMode(t, "bot")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("off→bot is a narrowing change; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_OffToUser_NoWarning(t *testing.T) {
|
||||
// Off already permits user-identity, so off→user is not a NEW grant
|
||||
// even though it forces user identity. Don't warn.
|
||||
setupStrictModeTestConfig(t)
|
||||
out := runStrictMode(t, "user")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("off→user does not newly permit user identity; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- --global path: comparison must use multi.StrictMode, not profile's
|
||||
// effective mode. The previous (buggy) version used resolveStrictModeStatus
|
||||
// here too, leading to both false positives (current profile has explicit
|
||||
// override unaffected by --global → still warned) and false negatives
|
||||
// (current profile has explicit override that masks an actual bot → off
|
||||
// global broadening for OTHER inheriting profiles → didn't warn).
|
||||
|
||||
func TestStrictMode_GlobalBotToUser_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global")
|
||||
|
||||
out := runStrictMode(t, "user", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→user must warn (broadens user-identity for inheriting profiles); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_GlobalBotToOff_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global")
|
||||
|
||||
out := runStrictMode(t, "off", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→off must warn (newly permits user identity in inheriting profiles); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// FalsePositive: current profile has explicit "bot" override, global goes
|
||||
// off → user. The current profile is unaffected (still bot via override),
|
||||
// and off→user at the global level is not a new grant either. Must not warn.
|
||||
func TestStrictMode_GlobalOffToUser_WithProfileBotOverride_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot") // profile-level explicit bot
|
||||
runStrictMode(t, "off", "--global") // global = off
|
||||
|
||||
out := runStrictMode(t, "user", "--global")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global off→user with profile-bot-override must not warn (profile unaffected, global wasn't bot); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// FalseNegative: global = bot, current profile has explicit "off" override.
|
||||
// Running --global off broadens OTHER inheriting profiles (bot → off). The
|
||||
// current profile doesn't change effective mode, but the policy still expanded
|
||||
// user-identity, so warning must fire. The pre-fix logic compared via the
|
||||
// current profile's effective mode and missed this case.
|
||||
func TestStrictMode_GlobalBotToOff_WithProfileOffOverride_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global") // global = bot
|
||||
runStrictMode(t, "off") // profile-level explicit off (already shows the warning at profile scope)
|
||||
|
||||
out := runStrictMode(t, "off", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→off must warn even when current profile has explicit off (other profiles inherit and newly permit user identity); got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -83,7 +84,20 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
// ── 1. Config file ──
|
||||
_, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
checks = append(checks, fail("config_file", err.Error(), "run: lark-cli config init"))
|
||||
// For "config not present" cases, prefer the workspace-aware
|
||||
// NotConfiguredError message + hint (e.g. "openclaw context
|
||||
// detected but lark-cli is not bound to it" → bind --help) over
|
||||
// the OS-level "open ... no such file or directory".
|
||||
// For other errors (parse, perms), keep the raw error so the
|
||||
// underlying problem is still visible.
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
}
|
||||
checks = append(checks, fail("config_file", msg, hint))
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
checks = append(checks, pass("config_file", "config.json found"))
|
||||
|
||||
@@ -32,9 +32,9 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(name)
|
||||
|
||||
@@ -32,9 +32,9 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(oldName)
|
||||
|
||||
@@ -31,9 +31,9 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle "-" for toggle-back
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -48,10 +49,9 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
||||
Hidden: true,
|
||||
DisableFlagParsing: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, only %s identity is allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode, mode.ForcedIdentity())
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
|
||||
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,11 +343,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
|
||||
"auth", "login", "--json", "--scope", "im:message.send_as_user",
|
||||
})
|
||||
|
||||
// auth login is user-only, so it gets pruned in strict-mode-bot and the
|
||||
// stub error fires (not login.go's inline check, which is shadowed by
|
||||
// pruning).
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -364,7 +368,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -401,7 +406,8 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "user", only user-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -419,7 +425,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -436,7 +443,8 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "user", only user-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -454,7 +462,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -167,10 +167,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
|
||||
}
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
@@ -354,6 +354,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
// stdin is an io.Reader consumed at most once. Only one of --params/--data
|
||||
// may use "-" (stdin); the conflict check below prevents silent data loss.
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
// Validate --file mutual exclusions.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil {
|
||||
@@ -362,7 +363,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
@@ -431,7 +432,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
// Parse --data as form fields.
|
||||
var dataFields any
|
||||
if opts.Data != "" {
|
||||
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
@@ -447,7 +448,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
fileIO,
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -456,7 +457,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
request.Data = fd
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
|
||||
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
@@ -142,8 +142,12 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
if interval < 1 {
|
||||
interval = 5
|
||||
}
|
||||
|
||||
const maxPollInterval = 60
|
||||
const maxPollAttempts = 200
|
||||
const maxPollAttempts = 600
|
||||
|
||||
endpoints := ResolveOAuthEndpoints(brand)
|
||||
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
@@ -5,10 +5,12 @@ package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +19,12 @@ import (
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
// TestResolveOAuthEndpoints_Feishu validates endpoints for the Feishu brand.
|
||||
func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
|
||||
ep := ResolveOAuthEndpoints(core.BrandFeishu)
|
||||
@@ -172,3 +180,33 @@ func TestLogAuthError_RecordsStructuredEntry(t *testing.T) {
|
||||
t.Fatalf("expected truncated cmdline in log, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var requests atomic.Int32
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requests.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
|
||||
if result == nil {
|
||||
t.Fatal("PollDeviceToken() returned nil result")
|
||||
}
|
||||
if result.Message != "Polling was cancelled" {
|
||||
t.Fatalf("PollDeviceToken() message = %q, want polling cancellation", result.Message)
|
||||
}
|
||||
if got := requests.Load(); got != 0 {
|
||||
t.Fatalf("PollDeviceToken() sent %d requests before context cancellation, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +160,9 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
|
||||
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
|
||||
mode := f.ResolveStrictMode(ctx)
|
||||
if mode.IsActive() && !mode.AllowsIdentity(as) {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, only %s identity is allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode, mode.ForcedIdentity())
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
|
||||
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,19 +7,20 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// ParseOptionalBody parses --data JSON for methods that accept a request body.
|
||||
// Supports stdin (-) and single-quote stripping via ResolveInput.
|
||||
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
|
||||
// Returns (nil, nil) if the method has no body or data is empty.
|
||||
func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) {
|
||||
func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.FileIO) (interface{}, error) {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
resolved, err := ResolveInput(data, stdin)
|
||||
resolved, err := ResolveInput(data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("--data: %s", err)
|
||||
}
|
||||
@@ -34,9 +35,9 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, e
|
||||
}
|
||||
|
||||
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
|
||||
// Supports stdin (-) and single-quote stripping via ResolveInput.
|
||||
func ParseJSONMap(input, label string, stdin io.Reader) (map[string]any, error) {
|
||||
resolved, err := ResolveInput(input, stdin)
|
||||
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
|
||||
func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) {
|
||||
resolved, err := ResolveInput(input, stdin, fileIO)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("%s: %s", label, err)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestParseOptionalBody(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseOptionalBody(tt.method, tt.data, nil)
|
||||
got, err := ParseOptionalBody(tt.method, tt.data, nil, nil)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
@@ -53,7 +53,7 @@ func TestParseJSONMap(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseJSONMap(tt.input, tt.label, nil)
|
||||
got, err := ParseJSONMap(tt.input, tt.label, nil, nil)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
||||
@@ -4,19 +4,27 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// ResolveInput resolves special input conventions for a raw flag value:
|
||||
// - "-" → read all bytes from stdin
|
||||
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
|
||||
// - other → return as-is
|
||||
// - "-" → read all bytes from stdin
|
||||
// - "@<path>" → read all bytes from the file at <path> via fileIO
|
||||
// - "@@..." → strip leading @ (escape for a literal @-prefixed value)
|
||||
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
|
||||
// - other → return as-is
|
||||
//
|
||||
// This allows callers to bypass shell quoting issues (especially on Windows
|
||||
// PowerShell) by piping JSON via stdin instead of command-line arguments.
|
||||
func ResolveInput(raw string, stdin io.Reader) (string, error) {
|
||||
// fileIO is required for "@<path>" inputs and goes through path validation
|
||||
// (SafeInputPath); pass nil only when callers know "@" inputs are not possible.
|
||||
//
|
||||
// Allows callers to bypass shell quoting issues (especially Windows PowerShell 5)
|
||||
// by reading JSON from a file (@path) or piping via stdin (-).
|
||||
func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, error) {
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
@@ -37,6 +45,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// escape: @@... → literal @... (no file read)
|
||||
if strings.HasPrefix(raw, "@@") {
|
||||
return raw[1:], nil
|
||||
}
|
||||
|
||||
// file: @path
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("file path cannot be empty after @")
|
||||
}
|
||||
data, err := ReadInputFile(fileIO, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := strings.TrimSpace(string(data))
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("file %q is empty", path)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// strip surrounding single quotes (Windows cmd.exe passes them literally)
|
||||
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
|
||||
raw = raw[1 : len(raw)-1]
|
||||
@@ -44,3 +74,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) {
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
|
||||
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
|
||||
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
|
||||
if fileIO == nil {
|
||||
return nil, fmt.Errorf("file input is not available in this context")
|
||||
}
|
||||
f, err := fileIO.Open(path)
|
||||
if err != nil {
|
||||
return nil, wrapInputFileError(path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, wrapInputFileError(path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func wrapInputFileError(path string, err error) error {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return fmt.Errorf("invalid file path %q: %w", path, err)
|
||||
}
|
||||
return fmt.Errorf("cannot read file %q: %w", path, err)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
func TestResolveInput_Stdin(t *testing.T) {
|
||||
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`))
|
||||
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -20,7 +23,7 @@ func TestResolveInput_Stdin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
|
||||
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"))
|
||||
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -30,7 +33,7 @@ func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveInput_Stdin_Empty(t *testing.T) {
|
||||
_, err := ResolveInput("-", strings.NewReader(""))
|
||||
_, err := ResolveInput("-", strings.NewReader(""), nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty stdin")
|
||||
}
|
||||
@@ -44,21 +47,21 @@ type errorReader struct{}
|
||||
func (errorReader) Read([]byte) (int, error) { return 0, fmt.Errorf("disk failure") }
|
||||
|
||||
func TestResolveInput_Stdin_ReadError(t *testing.T) {
|
||||
_, err := ResolveInput("-", errorReader{})
|
||||
_, err := ResolveInput("-", errorReader{}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "failed to read stdin") {
|
||||
t.Errorf("expected read error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_Stdin_WhitespaceOnly(t *testing.T) {
|
||||
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "))
|
||||
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "), nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for whitespace-only stdin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_Stdin_Nil(t *testing.T) {
|
||||
_, err := ResolveInput("-", nil)
|
||||
_, err := ResolveInput("-", nil, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nil stdin")
|
||||
}
|
||||
@@ -77,7 +80,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ResolveInput(tt.in, nil)
|
||||
got, err := ResolveInput(tt.in, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -89,7 +92,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveInput_Empty(t *testing.T) {
|
||||
got, err := ResolveInput("", nil)
|
||||
got, err := ResolveInput("", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -99,7 +102,7 @@ func TestResolveInput_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveInput_PlainValue(t *testing.T) {
|
||||
got, err := ResolveInput(`{"already":"valid"}`, nil)
|
||||
got, err := ResolveInput(`{"already":"valid"}`, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -108,21 +111,103 @@ func TestResolveInput_PlainValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtPrefixPassedThrough(t *testing.T) {
|
||||
// Without @file support, @-prefixed values are passed as-is
|
||||
got, err := ResolveInput("@something", nil)
|
||||
func TestResolveInput_AtFile(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ResolveInput("@params.json", nil, fio)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "@something" {
|
||||
t.Errorf("got %q, want %q", got, "@something")
|
||||
if got != `{"folder_token":"abc123"}` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_TrimsWhitespace(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("p.json", []byte("\n {\"k\":\"v\"}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ResolveInput("@p.json", nil, fio)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != `{"k":"v"}` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_NotFound(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
_, err := ResolveInput("@missing.json", nil, fio)
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Errorf("expected read error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_PathValidation(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
// Absolute paths are rejected by SafeInputPath; the error must surface
|
||||
// as an invalid-path message, not a generic read failure.
|
||||
_, err := ResolveInput("@/etc/passwd", nil, fio)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid file path") {
|
||||
t.Errorf("expected path-validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_EmptyPath(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
_, err := ResolveInput("@", nil, fio)
|
||||
if err == nil || !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("expected empty-path error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_EmptyContent(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("empty.json", []byte(" \n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := ResolveInput("@empty.json", nil, fio)
|
||||
if err == nil || !strings.Contains(err.Error(), "is empty") {
|
||||
t.Errorf("expected empty-file error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_AtFile_NoFileIO(t *testing.T) {
|
||||
// When fileIO is nil, @path must error rather than silently fall back.
|
||||
_, err := ResolveInput("@params.json", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "not available") {
|
||||
t.Errorf("expected unavailable error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInput_DoubleAtEscape(t *testing.T) {
|
||||
got, err := ResolveInput("@@literal", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "@literal" {
|
||||
t.Errorf("got %q, want %q", got, "@literal")
|
||||
}
|
||||
}
|
||||
|
||||
// Integration: ResolveInput flows through ParseJSONMap correctly.
|
||||
func TestParseJSONMap_WithStdin(t *testing.T) {
|
||||
stdin := strings.NewReader(`{"message_id":"om_xxx","user_id_type":"open_id"}`)
|
||||
got, err := ParseJSONMap("-", "--params", stdin)
|
||||
got, err := ParseJSONMap("-", "--params", stdin, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -131,8 +216,48 @@ func TestParseJSONMap_WithStdin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Integration: @file flows through ParseJSONMap correctly.
|
||||
func TestParseJSONMap_WithAtFile(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123","type":"folder"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ParseJSONMap("@params.json", "--params", nil, fio)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("got %d keys, want 2", len(got))
|
||||
}
|
||||
if got["folder_token"] != "abc123" {
|
||||
t.Errorf("got %v, want folder_token=abc123", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptionalBody_WithAtFile(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
dir := t.TempDir()
|
||||
TestChdir(t, dir)
|
||||
if err := os.WriteFile("data.json", []byte(`{"text":"hello"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ParseOptionalBody("POST", "@data.json", nil, fio)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", got)
|
||||
}
|
||||
if m["text"] != "hello" {
|
||||
t.Errorf("got %v, want text=hello", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
|
||||
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
|
||||
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -143,7 +268,7 @@ func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
|
||||
|
||||
func TestParseOptionalBody_WithStdin(t *testing.T) {
|
||||
stdin := strings.NewReader(`{"text":"hello"}`)
|
||||
got, err := ParseOptionalBody("POST", "-", stdin)
|
||||
got, err := ParseOptionalBody("POST", "-", stdin, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -176,7 +301,7 @@ func TestParseJSONMap_WindowsShellScenarios(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseJSONMap(tt.input, "--params", nil)
|
||||
got, err := ParseJSONMap(tt.input, "--params", nil, nil)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
||||
@@ -225,7 +225,7 @@ func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
|
||||
raw, err := LoadMultiAppConfig()
|
||||
if err != nil || raw == nil || len(raw.Apps) == 0 {
|
||||
return nil, &ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
return ResolveConfigFromMulti(raw, kc, profileOverride)
|
||||
}
|
||||
|
||||
120
internal/core/notconfigured.go
Normal file
120
internal/core/notconfigured.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// LoadOrNotConfigured wraps LoadMultiAppConfig with the standard "not yet
|
||||
// configured vs. couldn't read" disambiguation that every config-required
|
||||
// command should use:
|
||||
//
|
||||
// - file missing → workspace-aware NotConfiguredError (init / bind hint)
|
||||
// - parse error / permission error → real load failure with the original
|
||||
// cause preserved, so the user can actually fix the broken file
|
||||
//
|
||||
// Without this, every call site that did `if err != nil { return
|
||||
// NotConfiguredError() }` silently coerced corrupt-config into "run init",
|
||||
// which sent users in circles when their config.json was just malformed.
|
||||
func LoadOrNotConfigured() (*MultiAppConfig, error) {
|
||||
multi, err := LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
// Surface the real cause (parse error, permission denied, etc.)
|
||||
// so the user can fix the broken file. Wrapping as ConfigError
|
||||
// keeps it on the standard structured-envelope path at the root
|
||||
// command's error sink.
|
||||
return nil, &ConfigError{
|
||||
Code: 2,
|
||||
Type: "config",
|
||||
Message: fmt.Sprintf("failed to load config: %v", err),
|
||||
}
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
return multi, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// localInitHint is the canonical "you're in a regular terminal, run
|
||||
// init" guidance — shared by NotConfiguredError and NoActiveProfileError
|
||||
// so the same session can't show two different recommended commands.
|
||||
localInitHint = "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."
|
||||
|
||||
// agentBindHint is the canonical "you're in an Agent workspace, see
|
||||
// the binding workflow" guidance. Always points at --help (never a
|
||||
// ready-to-run bind command) so the AI reads the confirmation
|
||||
// discipline (identity preset, user opt-in) before acting.
|
||||
agentBindHint = "read `lark-cli config bind --help`, then ask the user to confirm intent and identity preset (bot-only or user-default); only after both are confirmed, run `lark-cli config bind`"
|
||||
)
|
||||
|
||||
// NotConfiguredError returns the canonical "not configured" error, with a
|
||||
// hint that depends on the active workspace:
|
||||
//
|
||||
// - WorkspaceLocal → suggest `config init --new` (creates a new app).
|
||||
// - WorkspaceOpenClaw / WorkspaceHermes → point at `config bind --help`
|
||||
// rather than a ready-to-run command, because binding is policy-laden:
|
||||
// the user must pick an identity preset (bot-only vs user-default),
|
||||
// and re-binding may overwrite an existing one. The help text walks
|
||||
// the AI through the confirmation flow.
|
||||
//
|
||||
// All "config not loaded yet" call sites should use this helper rather than
|
||||
// hand-rolling a hint, so AI agents always get a workspace-correct next step.
|
||||
func NotConfiguredError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Type: "config",
|
||||
Message: "not configured",
|
||||
Hint: localInitHint,
|
||||
}
|
||||
}
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("%s context detected but lark-cli is not bound to it", ws.Display()),
|
||||
Hint: agentBindHint,
|
||||
}
|
||||
}
|
||||
|
||||
// reconfigureHint returns the workspace-aware "fix it from scratch" hint
|
||||
// used by error paths that aren't full ConfigErrors (e.g. plain fmt.Errorf
|
||||
// strings from keychain / secret validation). Local → `config init`;
|
||||
// Agent → `config bind --help` so the AI reads the binding workflow and
|
||||
// confirms identity preset with the user before running the actual command.
|
||||
func reconfigureHint() string {
|
||||
if CurrentWorkspace().IsLocal() {
|
||||
return "please run `lark-cli config init` to reconfigure"
|
||||
}
|
||||
return agentBindHint
|
||||
}
|
||||
|
||||
// NoActiveProfileError mirrors NotConfiguredError for the related
|
||||
// "config exists but the requested profile cannot be resolved" case. In agent
|
||||
// workspaces a missing profile typically means the binding was wiped while
|
||||
// the workspace marker remained — re-binding is the correct fix, not init.
|
||||
func NoActiveProfileError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Type: "config",
|
||||
Message: "no active profile",
|
||||
Hint: localInitHint,
|
||||
}
|
||||
}
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("no active profile in %s workspace", ws.Display()),
|
||||
Hint: agentBindHint,
|
||||
}
|
||||
}
|
||||
181
internal/core/notconfigured_test.go
Normal file
181
internal/core/notconfigured_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// saveAndRestoreWorkspace ensures package-level currentWorkspace is reset
|
||||
// between subtests so cross-test pollution can't make assertions pass by
|
||||
// accident.
|
||||
func saveAndRestoreWorkspace(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := CurrentWorkspace()
|
||||
t.Cleanup(func() { SetCurrentWorkspace(prev) })
|
||||
}
|
||||
|
||||
func TestNotConfiguredError_Local(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Errorf("unexpected detail: %+v", cfgErr)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config init --new") {
|
||||
t.Errorf("local hint should suggest config init --new; got %q", cfgErr.Hint)
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config bind") {
|
||||
t.Errorf("local hint must not mention config bind; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotConfiguredError_OpenClaw(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceOpenClaw)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
// Hint must point at --help (read first, confirm with user, then bind),
|
||||
// NOT a directly-executable bind command — binding is policy-laden
|
||||
// (identity preset, may overwrite existing binding).
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config init") {
|
||||
t.Errorf("agent hint must NOT mention config init (would cause AI to create a new app); got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotConfiguredError_Hermes(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceHermes)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "hermes" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hermes hint must point to `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoActiveProfileError_Local(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
|
||||
err := NoActiveProfileError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Message != "no active profile" {
|
||||
t.Errorf("message = %q, want %q", cfgErr.Message, "no active profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoActiveProfileError_AgentSuggestsBind(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceOpenClaw)
|
||||
|
||||
err := NoActiveProfileError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconfigureHint_Local(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
|
||||
got := reconfigureHint()
|
||||
if !strings.Contains(got, "config init") {
|
||||
t.Errorf("local reconfigure hint must mention config init; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconfigureHint_Agent(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceHermes)
|
||||
|
||||
got := reconfigureHint()
|
||||
if !strings.Contains(got, "config bind --help") {
|
||||
t.Errorf("agent reconfigure hint must point to `config bind --help`; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrNotConfigured_FileMissing_ReturnsNotConfigured(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
_, err := LoadOrNotConfigured()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Message != "not configured" {
|
||||
t.Errorf("message = %q, want \"not configured\"", cfgErr.Message)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config init --new") {
|
||||
t.Errorf("missing-file in local must hint `config init --new`; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrNotConfigured_CorruptFile_PreservesCause is the regression guard
|
||||
// for the previous "every load error → not configured" coercion: a malformed
|
||||
// config.json must surface its real failure cause so the user can fix it,
|
||||
// not get sent in circles by an init/bind hint that wouldn't help here.
|
||||
func TestLoadOrNotConfigured_CorruptFile_PreservesCause(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Write garbage that will fail JSON parsing.
|
||||
if err := os.WriteFile(dir+"/config.json", []byte("{not valid json"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := LoadOrNotConfigured()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for corrupt config")
|
||||
}
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "failed to load config") {
|
||||
t.Errorf("corrupt-file message must say 'failed to load config'; got %q", cfgErr.Message)
|
||||
}
|
||||
// And it must NOT pretend the user just hasn't initialised yet.
|
||||
if cfgErr.Message == "not configured" {
|
||||
t.Errorf("corrupt-file must not be coerced to 'not configured'")
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config init") || strings.Contains(cfgErr.Hint, "config bind") {
|
||||
t.Errorf("corrupt-file hint must not redirect to init/bind; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
@@ -63,9 +63,8 @@ func ValidateSecretKeyMatch(appId string, secret SecretInput) error {
|
||||
expected := secretAccountKey(appId)
|
||||
if secret.Ref.ID != expected {
|
||||
return fmt.Errorf(
|
||||
"appSecret keychain key %q does not match appId %q (expected %q); "+
|
||||
"please run `lark-cli config init` to reconfigure",
|
||||
secret.Ref.ID, appId, expected,
|
||||
"appSecret keychain key %q does not match appId %q (expected %q); %s",
|
||||
secret.Ref.ID, appId, expected, reconfigureHint(),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -203,7 +203,7 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er
|
||||
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
||||
return acct, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no credential provider returned an account; run 'lark-cli config' to set up")
|
||||
return nil, core.NotConfiguredError()
|
||||
}
|
||||
|
||||
// enrichUserInfo resolves user identity when extension provides a UAT.
|
||||
|
||||
@@ -36,7 +36,7 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
|
||||
// Load config once — used for both credentials and strict mode.
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return nil, &core.ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
|
||||
return nil, core.NotConfiguredError()
|
||||
}
|
||||
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
|
||||
|
||||
@@ -10,7 +10,7 @@ const (
|
||||
ExitOK = 0 // 成功
|
||||
ExitAPI = 1 // API / 通用错误(含 permission、not_found、conflict、rate_limit)
|
||||
ExitValidation = 2 // 参数校验失败
|
||||
ExitAuth = 3 // 认证失败(token 无效 / 过期)
|
||||
ExitAuth = 3 // 认证失败(token 无效 / 过期),或登录成功但请求 scopes 未全部授予
|
||||
ExitNetwork = 4 // 网络错误(连接超时、DNS 解析失败等)
|
||||
ExitInternal = 5 // 内部错误(不应发生)
|
||||
ExitContentSafety = 6 // content safety violation (block mode)
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
"en": { "title": "Mail", "description": "Email, draft, folder, and contacts management" },
|
||||
"zh": { "title": "邮箱", "description": "查看和管理用户邮箱数据,包括邮件、草稿、文件夹和联系人" }
|
||||
},
|
||||
"markdown": {
|
||||
"en": { "title": "Markdown", "description": "Drive-native Markdown file create, fetch, and overwrite" },
|
||||
"zh": { "title": "Markdown", "description": "Drive 原生 Markdown 文件的创建、读取和覆盖更新" }
|
||||
},
|
||||
"minutes": {
|
||||
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
|
||||
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.22",
|
||||
"version": "1.0.24",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -112,11 +112,43 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
nil,
|
||||
map[string]int{"max-version": 11, "page-size": 30},
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
|
||||
assertDryRunContains(t, dryRunRecordUpsert(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
|
||||
assertDryRunContains(t, dryRunRecordHistoryList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/record_history", "max_version=11", "page_size=30", "record_id=rec_1", "table_id=tbl_1")
|
||||
|
||||
getSingleRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"record-id": {"rec_1"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getSingleRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_1"]`)
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, getSingleRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_1"]`)
|
||||
|
||||
getSingleFieldsRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"record-id": {"rec_1"}, "field-id": {"Name", "Age"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getSingleFieldsRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_1"]`, `"select_fields":["Name","Age"]`)
|
||||
|
||||
getBatchRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"record-id": {"rec_2", "rec_1"}, "field-id": {"Name", "Age"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getBatchRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_2","rec_1"]`, `"select_fields":["Name","Age"]`)
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, getBatchRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_2","rec_1"]`)
|
||||
|
||||
getJSONRT := newBaseTestRuntime(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"record_id_list":["rec_3"],"select_fields":["Status"]}`},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_3"]`, `"select_fields":["Status"]`)
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_3"]`)
|
||||
|
||||
uploadAttachmentRT := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
|
||||
@@ -827,28 +827,6 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Run("list", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"records": map[string]interface{}{
|
||||
"schema": []interface{}{"Name", "Age"},
|
||||
"record_ids": []interface{}{"rec_1"},
|
||||
"rows": []interface{}{[]interface{}{"Alice", 18}},
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"records"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list with fields and view", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -864,7 +842,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
||||
@@ -887,7 +865,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C"}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"A,B"`) || !strings.Contains(got, `"rec_json_fields"`) {
|
||||
@@ -895,7 +873,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list new shape", func(t *testing.T) {
|
||||
t.Run("list json format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
@@ -904,13 +882,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"field_id_list": []interface{}{"fld_name", "fld_age"},
|
||||
"record_id_list": []interface{}{"rec_2"},
|
||||
"data": []interface{}{[]interface{}{"Bob", 20}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Bob"`) || !strings.Contains(got, `"rec_2"`) {
|
||||
@@ -918,6 +897,47 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list markdown format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=Name&field_id=Age&limit=2&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"field_id_list": []interface{}{"fld_name", "fld_age"},
|
||||
"record_id_list": []interface{}{"rec_1", "rec_2"},
|
||||
"data": []interface{}{
|
||||
[]interface{}{"Alice", 18},
|
||||
[]interface{}{"Bob", 20},
|
||||
},
|
||||
"has_more": false,
|
||||
"query_context": map[string]interface{}{
|
||||
"record_scope": "all_records",
|
||||
"field_scope": "selected_fields",
|
||||
},
|
||||
"ignored_fields": []interface{}{"Formula"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "2", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"`_record_id` is metadata for record operations, not a table field.",
|
||||
"| _record_id | Name | Age |",
|
||||
"| rec_1 | Alice | 18 |",
|
||||
"Meta: count=2; has_more=false; record_scope=all_records; field_scope=selected_fields; ignored_fields=1",
|
||||
"Ignored fields: Formula",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
searchStub := &httpmock.Stub{
|
||||
@@ -948,6 +968,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`,
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
@@ -968,6 +989,53 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search markdown format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Title", "Owner"},
|
||||
"field_id_list": []interface{}{"fld_title", "fld_owner"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Created by AI", "Alice"}},
|
||||
"has_more": false,
|
||||
"query_context": map[string]interface{}{
|
||||
"record_scope": "view_filtered_records",
|
||||
"field_scope": "selected_fields",
|
||||
"search_scope": "fld_title(Title)",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(
|
||||
t,
|
||||
BaseRecordSearch,
|
||||
[]string{
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"keyword":"Created","search_fields":["Title"],"select_fields":["Title","Owner"],"limit":2}`,
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"| _record_id | Title | Owner |",
|
||||
"| rec_1 | Created by AI | Alice |",
|
||||
"Meta: count=1; has_more=false; record_scope=view_filtered_records; field_scope=selected_fields; search_scope=fld_title(Title)",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list legacy fields flag rejected", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
|
||||
@@ -986,42 +1054,322 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1",
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"records": map[string]interface{}{
|
||||
"schema": []interface{}{"Name", "Age"},
|
||||
"record_ids": []interface{}{"rec_1"},
|
||||
"rows": []interface{}{[]interface{}{"Alice", 18}},
|
||||
}},
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_ids"`) || !strings.Contains(got, `"Name"`) || strings.Contains(got, `"raw"`) {
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"`_record_id` is metadata for record operations, not a table field.",
|
||||
"- `_record_id`: rec_1",
|
||||
"- `Name`: Alice",
|
||||
"- `Age`: 18",
|
||||
"Meta: count=1",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_1"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get json format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Alice"`) || !strings.Contains(got, `"Age"`) || strings.Contains(got, `"record":`) || strings.Contains(got, `"raw"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_1"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get passthrough fallback", func(t *testing.T) {
|
||||
t.Run("get with selected fields", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_2",
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"unexpected": "shape", "record_id": "rec_2"},
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2"}, factory, stdout); err != nil {
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"unexpected": "shape"`) || strings.Contains(got, `"raw"`) || strings.Contains(got, `"record":`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Name"`) || !strings.Contains(got, `"Age"`) || !strings.Contains(got, `"Alice"`) || strings.Contains(got, `"record":`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch with repeated record-id flags", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_2", "rec_1"},
|
||||
"fields": []interface{}{"Name"},
|
||||
"data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"| _record_id | Name |",
|
||||
"| rec_2 | Bob |",
|
||||
"| rec_1 | Alice |",
|
||||
"Meta: count=2",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch json format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_2", "rec_1"},
|
||||
"fields": []interface{}{"Name"},
|
||||
"data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) || !strings.Contains(got, `"Bob"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch with json selector", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_3"},
|
||||
"fields": []interface{}{"Name"},
|
||||
"data": []interface{}{[]interface{}{"Carol"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"],"select_fields":["Name"]}`, "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Carol"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_3"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get single returns batch_get error when batch_get is unavailable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{"code": 404, "msg": "not found"},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected batch_get error")
|
||||
}
|
||||
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
|
||||
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get single missing record renders not found markdown", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_missing"},
|
||||
"fields": []interface{}{"Name"},
|
||||
"data": []interface{}{[]interface{}{nil}},
|
||||
"has_more": false,
|
||||
"record_not_found": []interface{}{"rec_missing"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_missing"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Record not found.",
|
||||
"- `_record_id`: rec_missing",
|
||||
"Meta: count=1; has_more=false; record_not_found=1",
|
||||
"Missing records: rec_missing",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "- `Name`:") {
|
||||
t.Fatalf("missing record output should not render business fields:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch returns batch_get error when batch_get is unavailable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{"code": 404, "msg": "not found"},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected batch_get error")
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch with json record ids and field flags", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_4"},
|
||||
"fields": []interface{}{"Status"},
|
||||
"data": []interface{}{[]interface{}{"Done"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_4"]}`, "--field-id", "Status", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"Done"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_4"]`) || !strings.Contains(body, `"select_fields":["Status"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects duplicate record ids", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate record id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects duplicate field ids", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Name"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate field id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects mixed record-id and json", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects mixed field-id and json select_fields", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_2"],"select_fields":["Name"]}`, "--field-id", "Age"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "select_fields") || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects empty selection", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "provide at least one --record-id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
@@ -1121,17 +1469,121 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
||||
})
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"record_id": "rec_1"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || strings.Contains(got, `"deleted": true`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
|
||||
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete returns batch_delete error when unavailable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{"code": 404, "msg": "not found"},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected batch_delete error")
|
||||
}
|
||||
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
|
||||
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete batch with repeated record-id flags", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_2", "rec_1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete batch with json selector", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_3"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"]}`, "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_3"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_3"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete requires yes for batch", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete rejects duplicate record ids", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1", "--yes"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate record id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete rejects mixed record-id and json", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`, "--yes"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment", func(t *testing.T) {
|
||||
@@ -1674,7 +2126,7 @@ func TestBaseRecordExecuteListWithViewPagination(t *testing.T) {
|
||||
}, "total": 201},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1"}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_last"`) || !strings.Contains(got, `"total": 201`) {
|
||||
|
||||
@@ -18,7 +18,7 @@ var BaseFormDelete = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
|
||||
baseTokenFlag(true),
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ var BaseFormGet = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
|
||||
baseTokenFlag(true),
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ var BaseFormQuestionsList = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
|
||||
baseTokenFlag(true),
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
},
|
||||
|
||||
@@ -210,6 +210,140 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantHelp []string
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "record list",
|
||||
shortcut: BaseRecordList,
|
||||
wantHelp: []string{
|
||||
"field ID or name to include; repeat to project only needed fields",
|
||||
"view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view",
|
||||
"pagination size, range 1-200",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
|
||||
"Default output is markdown",
|
||||
"Use --field-id repeatedly to keep output small",
|
||||
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
wantHelp: []string{
|
||||
"requires keyword/search_fields",
|
||||
"optional select_fields/view_id/offset/limit",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
`lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json`,
|
||||
`"select_fields":["Name","Status"]`,
|
||||
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"]`,
|
||||
"search_fields length 1-20",
|
||||
"limit range 1-200 defaults to 10",
|
||||
"view_id scopes search to records in that view",
|
||||
"Default output is markdown",
|
||||
"only for keyword search",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record get",
|
||||
shortcut: BaseRecordGet,
|
||||
wantHelp: []string{
|
||||
"record ID (repeatable)",
|
||||
"field ID or name to project; repeat to keep only needed columns",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
|
||||
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status",
|
||||
"Default output is markdown",
|
||||
"projection boundary",
|
||||
"record_id is already known",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
for _, want := range tt.wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
assertHelpOrder(t, help, "base token", "output format")
|
||||
assertHelpOrder(t, help, "table ID", "output format")
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
wantHelp := []string{
|
||||
"complete field definition JSON object; update uses full PUT semantics, not a patch",
|
||||
}
|
||||
for _, want := range wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
|
||||
"full field-definition PUT semantics",
|
||||
"Read the current field first with +field-get",
|
||||
"Type conversion is allowlist-based",
|
||||
"web UI",
|
||||
"Formula and lookup updates require reading the corresponding guide first.",
|
||||
"lark-base skill's field-update guide",
|
||||
}
|
||||
for _, want := range wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertHelpOrder(t *testing.T, help string, before string, after string) {
|
||||
t.Helper()
|
||||
beforeIndex := strings.Index(help, before)
|
||||
afterIndex := strings.Index(help, after)
|
||||
if beforeIndex < 0 || afterIndex < 0 {
|
||||
return
|
||||
}
|
||||
if beforeIndex > afterIndex {
|
||||
t.Fatalf("flag help order mismatch: %q should appear before %q:\n%s", before, after, help)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
|
||||
@@ -259,8 +393,8 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
if BaseRecordSearch.Validate == nil {
|
||||
t.Fatalf("record search validate should reject invalid JSON before dry-run")
|
||||
}
|
||||
if BaseRecordGet.Validate != nil {
|
||||
t.Fatalf("record get validate should be nil")
|
||||
if BaseRecordGet.Validate == nil {
|
||||
t.Fatalf("record get validate should reject invalid record selection before dry-run")
|
||||
}
|
||||
if BaseRecordUpsert.Validate == nil {
|
||||
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")
|
||||
|
||||
@@ -20,12 +20,16 @@ var BaseFieldUpdate = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
fieldRefFlag(true),
|
||||
{Name: "json", Desc: "field property JSON object", Required: true},
|
||||
{Name: "json", Desc: "complete field definition JSON object; update uses full PUT semantics, not a patch", Required: true},
|
||||
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"name":"Status","type":"text"}'`,
|
||||
"Agent hint: use the lark-base skill's field-update guide for usage and limits.",
|
||||
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
|
||||
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
|
||||
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
|
||||
"Formula and lookup updates require reading the corresponding guide first.",
|
||||
"Agent hint: use the lark-base skill's field-update guide for JSON shape, type-conversion rules, and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFieldUpdate(runtime)
|
||||
|
||||
10
shortcuts/base/help.go
Normal file
10
shortcuts/base/help.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func preserveFlagOrder(cmd *cobra.Command) {
|
||||
cmd.Flags().SortFlags = false
|
||||
}
|
||||
@@ -195,6 +195,62 @@ func TestRecordAndChunkHelpers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSelectionHelpers(t *testing.T) {
|
||||
recordIDs, err := normalizeRecordIDs([]string{" rec_1 ", "rec_2"})
|
||||
if err != nil || !reflect.DeepEqual(recordIDs, []string{"rec_1", "rec_2"}) {
|
||||
t.Fatalf("recordIDs=%v err=%v", recordIDs, err)
|
||||
}
|
||||
if _, err := normalizeRecordIDs([]interface{}{}); err == nil || !strings.Contains(err.Error(), "provide at least one --record-id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordIDs([]interface{}{"rec_1", "rec_1"}); err == nil || !strings.Contains(err.Error(), "duplicate record id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordIDs([]interface{}{" "}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordIDs([]interface{}{1}); err == nil || !strings.Contains(err.Error(), "must be a string") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
tooManyRecords := make([]string, maxRecordSelectionCount+1)
|
||||
if _, err := normalizeRecordIDs(tooManyRecords); err == nil || !strings.Contains(err.Error(), "exceeds maximum limit") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
fields, err := normalizeRecordGetSelectFields([]interface{}{" Name ", "fld_status"})
|
||||
if err != nil || !reflect.DeepEqual(fields, []string{"Name", "fld_status"}) {
|
||||
t.Fatalf("fields=%v err=%v", fields, err)
|
||||
}
|
||||
if fields, err := normalizeRecordGetSelectFields(nil); err != nil || fields != nil {
|
||||
t.Fatalf("fields=%v err=%v", fields, err)
|
||||
}
|
||||
if _, err := normalizeRecordGetSelectFields([]interface{}{"Name", "Name"}); err == nil || !strings.Contains(err.Error(), "duplicate field id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordGetSelectFields([]interface{}{""}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordGetSelectFields([]interface{}{1}); err == nil || !strings.Contains(err.Error(), "must be a string") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
tooManyFields := make([]string, maxBatchGetSelectFieldCount+1)
|
||||
if _, err := normalizeRecordGetSelectFields(tooManyFields); err == nil || !strings.Contains(err.Error(), "exceeds maximum limit") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}})
|
||||
if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) {
|
||||
t.Fatalf("fields=%v err=%v", fields, err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestResolveHelpers(t *testing.T) {
|
||||
fields := []map[string]interface{}{{"id": "fld_1", "name": "Name", "type": "text"}, {"field_id": "fld_2", "field_name": "Age", "type": "number", "multiple": true}}
|
||||
tables := []map[string]interface{}{{"id": "tbl_1", "name": "Orders"}}
|
||||
|
||||
@@ -12,12 +12,20 @@ import (
|
||||
var BaseRecordDelete = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-delete",
|
||||
Description: "Delete a record by ID",
|
||||
Description: "Delete one or more records by ID",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"base:record:delete"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), recordRefFlag(true)},
|
||||
DryRun: dryRunRecordDelete,
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
||||
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordSelection(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordDelete,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordDelete(runtime)
|
||||
},
|
||||
|
||||
@@ -7,21 +7,42 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var BaseRecordGet = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-get",
|
||||
Description: "Get a record by ID",
|
||||
Description: "Get one or more records by ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:record:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
||||
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
|
||||
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordSelection(runtime)
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
|
||||
"Example with projection: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status",
|
||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||
"Use --field-id as a projection boundary to avoid loading large cell values into context when they are not needed.",
|
||||
"Use +record-get when record_id is already known; otherwise use +record-search or +record-list.",
|
||||
"Agent hint: follow the lark-base record read SOP for record read routing.",
|
||||
},
|
||||
DryRun: dryRunRecordGet,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
preserveFlagOrder(cmd)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordGet(runtime)
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var BaseRecordList = common.Shortcut{
|
||||
@@ -19,13 +20,46 @@ var BaseRecordList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "field-id", Type: "string_array", Desc: "field ID or field name to include (repeatable)"},
|
||||
{Name: "view-id", Desc: "view ID"},
|
||||
recordListFieldRefFlag(),
|
||||
recordListViewRefFlag(),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
|
||||
"Example with projection: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
|
||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||
"Use --field-id repeatedly to keep output small and aligned with the task.",
|
||||
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view.",
|
||||
"For structured filters, sorting, Top/Bottom N, and link fields, follow the lark-base record read SOP.",
|
||||
},
|
||||
DryRun: dryRunRecordList,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
preserveFlagOrder(cmd)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordList(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func recordListFieldRefFlag() common.Flag {
|
||||
flag := fieldRefFlag(false)
|
||||
flag.Type = "string_array"
|
||||
flag.Desc = "field ID or name to include; repeat to project only needed fields"
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordListViewRefFlag() common.Flag {
|
||||
flag := viewRefFlag(false)
|
||||
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordReadFormatFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "format",
|
||||
Default: "markdown",
|
||||
Desc: "output format: markdown (default) | json",
|
||||
}
|
||||
}
|
||||
|
||||
337
shortcuts/base/record_markdown.go
Normal file
337
shortcuts/base/record_markdown.go
Normal file
@@ -0,0 +1,337 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const maxRecordMarkdownIgnoredFields = 20
|
||||
|
||||
func validateRecordReadFormat(runtime *common.RuntimeContext) error {
|
||||
switch runtime.Str("format") {
|
||||
case "", "json", "markdown":
|
||||
return nil
|
||||
default:
|
||||
return output.ErrValidation("--format must be json or markdown")
|
||||
}
|
||||
}
|
||||
|
||||
func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
|
||||
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordMarkdown)
|
||||
}
|
||||
|
||||
func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[string]interface{}, renderer func(map[string]interface{}) (string, error)) error {
|
||||
if runtime.JqExpr != "" {
|
||||
if !runtime.Changed("format") {
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
return output.ErrValidation("--jq and --format markdown are mutually exclusive")
|
||||
}
|
||||
rendered, err := renderer(data)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: record markdown render failed, falling back to json: %v\n", err)
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
|
||||
}
|
||||
fmt.Fprint(runtime.IO().Out, rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
|
||||
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown)
|
||||
}
|
||||
|
||||
func renderRecordGetMarkdown(data map[string]interface{}) (string, error) {
|
||||
fields := stringSliceValue(data["fields"])
|
||||
recordIDs := stringSliceValue(data["record_id_list"])
|
||||
rows, ok := data["data"].([]interface{})
|
||||
if len(fields) == 0 || !ok {
|
||||
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||
}
|
||||
if len(recordIDs) == 1 && len(rows) == 1 {
|
||||
rowItems, _ := rows[0].([]interface{})
|
||||
if recordMarkedNotFound(data["record_not_found"], recordIDs[0]) {
|
||||
return renderMissingSingleRecordMarkdown(recordIDs[0], data), nil
|
||||
}
|
||||
return renderSingleRecordMarkdown(recordIDs[0], fields, rowItems, data), nil
|
||||
}
|
||||
return renderRecordMarkdown(data)
|
||||
}
|
||||
|
||||
func renderRecordMarkdown(data map[string]interface{}) (string, error) {
|
||||
fields := stringSliceValue(data["fields"])
|
||||
recordIDs := stringSliceValue(data["record_id_list"])
|
||||
rows, ok := data["data"].([]interface{})
|
||||
if len(fields) == 0 || !ok {
|
||||
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("`_record_id` is metadata for record operations, not a table field.\n\n")
|
||||
|
||||
columns := append([]string{"_record_id"}, fields...)
|
||||
writeMarkdownRow(&b, columns)
|
||||
writeMarkdownSeparator(&b, len(columns))
|
||||
for i, rowValue := range rows {
|
||||
rowItems, _ := rowValue.([]interface{})
|
||||
cells := make([]string, 0, len(columns))
|
||||
if i < len(recordIDs) {
|
||||
cells = append(cells, recordIDs[i])
|
||||
} else {
|
||||
cells = append(cells, "")
|
||||
}
|
||||
for j := range fields {
|
||||
if j < len(rowItems) {
|
||||
cells = append(cells, markdownCell(rowItems[j]))
|
||||
} else {
|
||||
cells = append(cells, "")
|
||||
}
|
||||
}
|
||||
writeMarkdownRow(&b, cells)
|
||||
}
|
||||
|
||||
meta := recordMarkdownMeta(data)
|
||||
if len(meta) > 0 {
|
||||
b.WriteString("\nMeta: ")
|
||||
b.WriteString(strings.Join(meta, "; "))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if ignored := ignoredFieldsMarkdown(data["ignored_fields"]); ignored != "" {
|
||||
b.WriteString("Ignored fields: ")
|
||||
b.WriteString(ignored)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
|
||||
b.WriteString("Missing records: ")
|
||||
b.WriteString(missing)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func renderSingleRecordMarkdown(recordID string, fields []string, rowItems []interface{}, data map[string]interface{}) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("`_record_id` is metadata for record operations, not a table field.\n\n")
|
||||
b.WriteString("- `_record_id`: ")
|
||||
b.WriteString(markdownInlineValue(recordID))
|
||||
b.WriteByte('\n')
|
||||
for i, field := range fields {
|
||||
b.WriteString("- `")
|
||||
b.WriteString(field)
|
||||
b.WriteString("`: ")
|
||||
if i < len(rowItems) {
|
||||
b.WriteString(markdownInlineValue(rowItems[i]))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
meta := recordMarkdownMeta(data)
|
||||
if len(meta) > 0 {
|
||||
b.WriteString("\nMeta: ")
|
||||
b.WriteString(strings.Join(meta, "; "))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if ignored := ignoredFieldsMarkdown(data["ignored_fields"]); ignored != "" {
|
||||
b.WriteString("Ignored fields: ")
|
||||
b.WriteString(ignored)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
|
||||
b.WriteString("Missing records: ")
|
||||
b.WriteString(missing)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderMissingSingleRecordMarkdown(recordID string, data map[string]interface{}) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Record not found.\n\n")
|
||||
b.WriteString("- `_record_id`: ")
|
||||
b.WriteString(markdownInlineValue(recordID))
|
||||
b.WriteByte('\n')
|
||||
meta := recordMarkdownMeta(data)
|
||||
if len(meta) > 0 {
|
||||
b.WriteString("\nMeta: ")
|
||||
b.WriteString(strings.Join(meta, "; "))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
|
||||
b.WriteString("Missing records: ")
|
||||
b.WriteString(missing)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func recordMarkdownMeta(data map[string]interface{}) []string {
|
||||
meta := []string{fmt.Sprintf("count=%d", ignoredFieldsCount(data["record_id_list"]))}
|
||||
if hasMore, ok := data["has_more"]; ok {
|
||||
meta = append(meta, "has_more="+markdownInlineValue(hasMore))
|
||||
}
|
||||
if queryContext, ok := data["query_context"].(map[string]interface{}); ok {
|
||||
for _, key := range []string{"record_scope", "field_scope", "search_scope"} {
|
||||
if value, ok := queryContext[key]; ok {
|
||||
meta = append(meta, key+"="+markdownInlineValue(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
if ignoredCount := ignoredFieldsCount(data["ignored_fields"]); ignoredCount > 0 {
|
||||
meta = append(meta, fmt.Sprintf("ignored_fields=%d", ignoredCount))
|
||||
}
|
||||
if missingCount := ignoredFieldsCount(data["record_not_found"]); missingCount > 0 {
|
||||
meta = append(meta, fmt.Sprintf("record_not_found=%d", missingCount))
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func ignoredFieldsCount(value interface{}) int {
|
||||
switch v := value.(type) {
|
||||
case []interface{}:
|
||||
return len(v)
|
||||
case []string:
|
||||
return len(v)
|
||||
case nil:
|
||||
return 0
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func ignoredFieldsMarkdown(value interface{}) string {
|
||||
items := markdownListItems(value)
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
total := len(items)
|
||||
if len(items) > maxRecordMarkdownIgnoredFields {
|
||||
items = items[:maxRecordMarkdownIgnoredFields]
|
||||
items = append(items, fmt.Sprintf("...(%d total)", total))
|
||||
}
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
|
||||
func recordNotFoundMarkdown(value interface{}) string {
|
||||
return strings.Join(markdownListItems(value), ", ")
|
||||
}
|
||||
|
||||
func recordMarkedNotFound(value interface{}, recordID string) bool {
|
||||
for _, item := range markdownListItems(value) {
|
||||
if item == recordID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func markdownListItems(value interface{}) []string {
|
||||
switch v := value.(type) {
|
||||
case []interface{}:
|
||||
items := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
items = append(items, markdownInlineValue(item))
|
||||
}
|
||||
return items
|
||||
case []string:
|
||||
items := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
items = append(items, markdownInlineValue(item))
|
||||
}
|
||||
return items
|
||||
case nil:
|
||||
return nil
|
||||
default:
|
||||
return []string{markdownInlineValue(v)}
|
||||
}
|
||||
}
|
||||
|
||||
func writeMarkdownRow(b *strings.Builder, cells []string) {
|
||||
b.WriteString("| ")
|
||||
for i, cell := range cells {
|
||||
if i > 0 {
|
||||
b.WriteString(" | ")
|
||||
}
|
||||
b.WriteString(markdownTableText(cell))
|
||||
}
|
||||
b.WriteString(" |\n")
|
||||
}
|
||||
|
||||
func writeMarkdownSeparator(b *strings.Builder, columns int) {
|
||||
b.WriteString("| ")
|
||||
for i := 0; i < columns; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(" | ")
|
||||
}
|
||||
b.WriteString("---")
|
||||
}
|
||||
b.WriteString(" |\n")
|
||||
}
|
||||
|
||||
func markdownCell(value interface{}) string {
|
||||
return markdownInlineValue(value)
|
||||
}
|
||||
|
||||
func markdownInlineValue(value interface{}) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
case json.Number:
|
||||
return v.String()
|
||||
case bool:
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case float64:
|
||||
return fmt.Sprintf("%v", v)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", v)
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
|
||||
func markdownTableText(value string) string {
|
||||
value = strings.ReplaceAll(value, "\\", "\\\\")
|
||||
value = strings.ReplaceAll(value, "|", "\\|")
|
||||
value = strings.ReplaceAll(value, "\r\n", "<br>")
|
||||
value = strings.ReplaceAll(value, "\n", "<br>")
|
||||
return value
|
||||
}
|
||||
|
||||
func stringSliceValue(value interface{}) []string {
|
||||
switch v := value.(type) {
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return append([]string(nil), v...)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
298
shortcuts/base/record_markdown_test.go
Normal file
298
shortcuts/base/record_markdown_test.go
Normal file
@@ -0,0 +1,298 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type recordMarkdownCSTestProvider struct {
|
||||
alert *extcs.Alert
|
||||
}
|
||||
|
||||
func (p *recordMarkdownCSTestProvider) Name() string { return "test" }
|
||||
func (p *recordMarkdownCSTestProvider) Scan(_ context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.alert, nil
|
||||
}
|
||||
|
||||
func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeContext {
|
||||
parentCmd := &cobra.Command{Use: "lark-cli"}
|
||||
baseCmd := &cobra.Command{Use: "base"}
|
||||
cmd := &cobra.Command{Use: "+record-list"}
|
||||
cmd.Flags().String("format", "markdown", "")
|
||||
parentCmd.AddCommand(baseCmd)
|
||||
baseCmd.AddCommand(cmd)
|
||||
return &common.RuntimeContext{
|
||||
Config: &core.CliConfig{Brand: core.BrandFeishu},
|
||||
Cmd: cmd,
|
||||
Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordMarkdownEmptyResult(t *testing.T) {
|
||||
got, err := renderRecordMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{},
|
||||
"data": []interface{}{},
|
||||
"has_more": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"| _record_id | Name | Age |",
|
||||
"Meta: count=0; has_more=false",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordMarkdownEscapesTableCells(t *testing.T) {
|
||||
got, err := renderRecordMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name|Label", "Note"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"A|B", "line1\nline2"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"| _record_id | Name\\|Label | Note |",
|
||||
"| rec_1 | A\\|B | line1<br>line2 |",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordGetMarkdownSingleRecordUsesKVLayout(t *testing.T) {
|
||||
got, err := renderRecordGetMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name|Label", "Note"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"A|B", "line1\nline2"}},
|
||||
"has_more": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"- `_record_id`: rec_1",
|
||||
"- `Name|Label`: A|B",
|
||||
"- `Note`: line1\nline2",
|
||||
"Meta: count=1; has_more=false",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordGetMarkdownSingleMissingRecordUsesNotFoundLayout(t *testing.T) {
|
||||
got, err := renderRecordGetMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Note"},
|
||||
"record_id_list": []interface{}{"rec_missing"},
|
||||
"data": []interface{}{[]interface{}{nil, nil}},
|
||||
"record_not_found": []interface{}{"rec_missing"},
|
||||
"has_more": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Record not found.",
|
||||
"- `_record_id`: rec_missing",
|
||||
"Meta: count=1; has_more=false; record_not_found=1",
|
||||
"Missing records: rec_missing",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "- `Name`:") {
|
||||
t.Fatalf("missing record layout should not render business fields:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordMarkdownIncludesMissingRecords(t *testing.T) {
|
||||
got, err := renderRecordMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1", "rec_missing"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{nil}},
|
||||
"record_not_found": []interface{}{"rec_missing"},
|
||||
"has_more": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Meta: count=2; has_more=false; record_not_found=1",
|
||||
"Missing records: rec_missing",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordMarkdownTruncatesIgnoredFields(t *testing.T) {
|
||||
ignored := make([]interface{}, maxRecordMarkdownIgnoredFields+2)
|
||||
for i := range ignored {
|
||||
ignored[i] = fmt.Sprintf("Field%d", i+1)
|
||||
}
|
||||
got, err := renderRecordMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
"ignored_fields": ignored,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(got, fmt.Sprintf("ignored_fields=%d", len(ignored))) ||
|
||||
!strings.Contains(got, fmt.Sprintf("...(%d total)", len(ignored))) ||
|
||||
strings.Contains(got, "Field22") {
|
||||
t.Fatalf("ignored field truncation mismatch:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownContentSafetyWarnKeepsStdoutClean(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&recordMarkdownCSTestProvider{
|
||||
alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}},
|
||||
})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "| rec_1 | Alice |") || strings.Contains(got, "content safety") {
|
||||
t.Fatalf("stdout should contain only markdown data, got:\n%s", got)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: content safety alert") {
|
||||
t.Fatalf("stderr missing content safety warning:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownContentSafetyBlockDoesNotWriteStdout(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
extcs.Register(&recordMarkdownCSTestProvider{
|
||||
alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}},
|
||||
})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitContentSafety {
|
||||
t.Fatalf("err=%v, want content safety exit error", err)
|
||||
}
|
||||
if stdout.Len() > 0 {
|
||||
t.Fatalf("block mode should not write stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
t.Fatalf("block mode should not write warning to stderr, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownFallsBackToJSONWhenRenderFails(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"schema": []interface{}{"Name"},
|
||||
"rows": []interface{}{[]interface{}{"Alice"}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "markdown render failed") {
|
||||
t.Fatalf("stdout should not contain fallback warning:\n%s", stdout.String())
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("stdout should be JSON fallback, got err=%v stdout=%s", err, stdout.String())
|
||||
}
|
||||
if !env.OK || !strings.Contains(stdout.String(), `"records"`) {
|
||||
t.Fatalf("stdout missing JSON fallback data:\n%s", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: record markdown render failed, falling back to json") {
|
||||
t.Fatalf("stderr missing fallback warning:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownDefaultFormatAllowsJqJSONFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
runtime := newRecordMarkdownTestRuntime(stdout, stderr)
|
||||
runtime.JqExpr = ".data.record_id_list[0]"
|
||||
err := outputRecordMarkdown(runtime, map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "rec_1" {
|
||||
t.Fatalf("stdout jq fallback mismatch: %q", got)
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
t.Fatalf("stderr should be empty, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRecordMarkdownExplicitFormatRejectsJq(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
runtime := newRecordMarkdownTestRuntime(stdout, stderr)
|
||||
runtime.JqExpr = ".data"
|
||||
if err := runtime.Cmd.Flags().Set("format", "markdown"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
err := outputRecordMarkdown(runtime, map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "--jq and --format markdown are mutually exclusive") {
|
||||
t.Fatalf("err=%v, want jq markdown conflict", err)
|
||||
}
|
||||
if stdout.Len() > 0 {
|
||||
t.Fatalf("stdout should be empty, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,194 @@ import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const maxRecordSelectionCount = 200
|
||||
const maxBatchGetSelectFieldCount = 100
|
||||
|
||||
type recordSelection struct {
|
||||
recordIDs []string
|
||||
selectFields []string
|
||||
fromJSON bool
|
||||
}
|
||||
|
||||
type stringListNormalizeOptions struct {
|
||||
typeError string
|
||||
emptyError string
|
||||
itemName string
|
||||
duplicateName string
|
||||
limitName string
|
||||
max int
|
||||
allowNil bool
|
||||
allowEmpty bool
|
||||
}
|
||||
|
||||
func validateRecordSelection(runtime *common.RuntimeContext) error {
|
||||
_, err := resolveRecordSelection(runtime)
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
|
||||
recordIDs := runtime.StrArray("record-id")
|
||||
fieldIDs := runtime.StrArray("field-id")
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if len(recordIDs) > 0 && jsonRaw != "" {
|
||||
return recordSelection{}, common.FlagErrorf("--record-id and --json are mutually exclusive")
|
||||
}
|
||||
if jsonRaw != "" {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, jsonRaw, "json")
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
recordIDListValue, ok := body["record_id_list"]
|
||||
if !ok {
|
||||
return recordSelection{}, common.FlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
recordIDItems, ok := recordIDListValue.([]interface{})
|
||||
if !ok {
|
||||
return recordSelection{}, common.FlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
normalized, err := normalizeRecordIDs(recordIDItems)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
return recordSelection{
|
||||
recordIDs: normalized,
|
||||
selectFields: selectFields,
|
||||
fromJSON: true,
|
||||
}, nil
|
||||
}
|
||||
normalized, err := normalizeRecordIDs(recordIDs)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
return recordSelection{
|
||||
recordIDs: normalized,
|
||||
selectFields: selectFields,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeRecordIDs(values interface{}) ([]string, error) {
|
||||
return normalizeStringList(values, stringListNormalizeOptions{
|
||||
typeError: "record selection must be a string array",
|
||||
emptyError: `provide at least one --record-id, or use --json with "record_id_list"`,
|
||||
itemName: "record selection item",
|
||||
duplicateName: "record id",
|
||||
limitName: "record selection",
|
||||
max: maxRecordSelectionCount,
|
||||
})
|
||||
}
|
||||
|
||||
func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
|
||||
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body == nil {
|
||||
return fromFlags, nil
|
||||
}
|
||||
rawJSONFields, ok := body["select_fields"]
|
||||
if !ok {
|
||||
return fromFlags, nil
|
||||
}
|
||||
if len(fromFlags) > 0 {
|
||||
return nil, common.FlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
|
||||
}
|
||||
items, ok := rawJSONFields.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, common.FlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
|
||||
}
|
||||
normalized, err := normalizeRecordGetSelectFields(items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
|
||||
return normalizeStringList(values, stringListNormalizeOptions{
|
||||
typeError: "field selection must be a string array",
|
||||
itemName: "field selection item",
|
||||
duplicateName: "field id",
|
||||
limitName: "field selection",
|
||||
max: maxBatchGetSelectFieldCount,
|
||||
allowNil: true,
|
||||
allowEmpty: true,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([]string, error) {
|
||||
var rawItems []interface{}
|
||||
switch typed := values.(type) {
|
||||
case nil:
|
||||
if opts.allowNil {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, common.FlagErrorf(opts.typeError)
|
||||
case []interface{}:
|
||||
rawItems = typed
|
||||
case []string:
|
||||
rawItems = make([]interface{}, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
rawItems = append(rawItems, item)
|
||||
}
|
||||
default:
|
||||
return nil, common.FlagErrorf(opts.typeError)
|
||||
}
|
||||
if len(rawItems) == 0 {
|
||||
if opts.allowEmpty {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, common.FlagErrorf(opts.emptyError)
|
||||
}
|
||||
if opts.max > 0 && len(rawItems) > opts.max {
|
||||
return nil, common.FlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
|
||||
}
|
||||
seen := make(map[string]int, len(rawItems))
|
||||
result := make([]string, 0, len(rawItems))
|
||||
for index, value := range rawItems {
|
||||
item, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s %d must be a string", opts.itemName, index+1)
|
||||
}
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
return nil, common.FlagErrorf("%s %d must not be empty", opts.itemName, index+1)
|
||||
}
|
||||
if first, exists := seen[item]; exists {
|
||||
return nil, common.FlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
|
||||
}
|
||||
seen[item] = index + 1
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func recordGetBatchBody(selection recordSelection) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"record_id_list": selection.recordIDs,
|
||||
}
|
||||
if len(selection.selectFields) > 0 {
|
||||
body["select_fields"] = selection.selectFields
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
offset := runtime.Int("offset")
|
||||
if offset < 0 {
|
||||
@@ -34,11 +218,15 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
|
||||
}
|
||||
|
||||
func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
selection, err := resolveRecordSelection(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_get").
|
||||
Body(recordGetBatchBody(selection)).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime)).
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -90,11 +278,15 @@ func dryRunRecordBatchUpdate(_ context.Context, runtime *common.RuntimeContext)
|
||||
}
|
||||
|
||||
func dryRunRecordDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
selection, err := resolveRecordSelection(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_delete").
|
||||
Body(map[string]interface{}{"record_id_list": selection.recordIDs}).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime)).
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -173,6 +365,9 @@ func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
}
|
||||
|
||||
func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
offset := runtime.Int("offset")
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
@@ -190,15 +385,29 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("format") == "markdown" {
|
||||
return outputRecordMarkdown(runtime, data)
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordGet(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil)
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
selection, err := resolveRecordSelection(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_get"), nil, recordGetBatchBody(selection))
|
||||
data, err := handleBaseAPIResult(result, err, "batch get records")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("format") == "markdown" {
|
||||
return outputRecordGetMarkdown(runtime, data)
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
@@ -213,6 +422,9 @@ func executeRecordSearch(runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("format") == "markdown" {
|
||||
return outputRecordMarkdown(runtime, data)
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
@@ -272,10 +484,17 @@ func executeRecordBatchUpdate(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeRecordDelete(runtime *common.RuntimeContext) error {
|
||||
_, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil)
|
||||
selection, err := resolveRecordSelection(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"deleted": true, "record_id": runtime.Str("record-id")}, nil)
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_delete"), nil, map[string]interface{}{
|
||||
"record_id_list": selection.recordIDs,
|
||||
})
|
||||
data, err := handleBaseAPIResult(result, err, "batch delete records")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var BaseRecordSearch = common.Shortcut{
|
||||
@@ -19,16 +20,28 @@ var BaseRecordSearch = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "record search JSON object", Required: true},
|
||||
{Name: "json", Desc: `record search JSON object; requires keyword/search_fields, optional select_fields/view_id/offset/limit`, Required: true},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
|
||||
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
|
||||
`Example: lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json '{"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}'`,
|
||||
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"],"select_fields":["<field_id_or_name>"],"view_id":"<view_id_or_name>","offset":0,"limit":10}.`,
|
||||
"JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.",
|
||||
"view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.",
|
||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||
"Use +record-search only for keyword search; use a filtered view plus +record-list for structured conditions.",
|
||||
"Agent hint: follow the lark-base record read SOP for record read routing and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordSearch,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
preserveFlagOrder(cmd)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordSearch(runtime)
|
||||
},
|
||||
|
||||
@@ -155,6 +155,19 @@ func (ctx *RuntimeContext) LarkSDK() *lark.Client {
|
||||
return ctx.larkSDK
|
||||
}
|
||||
|
||||
// EnsureScopes runs the same pre-flight scope check used by the framework
|
||||
// before Validate, but on a caller-supplied set of scopes. Use it from a
|
||||
// shortcut's Validate to enforce conditional scope requirements that depend
|
||||
// on flag values (e.g. --delete-remote needing space:document:delete) so a
|
||||
// destructive operation never starts on a token that can't finish it.
|
||||
//
|
||||
// Behavior matches checkShortcutScopes: when no token is available or the
|
||||
// resolver doesn't expose scope metadata, this is a silent no-op — the
|
||||
// downstream API call still surfaces missing_scope at runtime.
|
||||
func (ctx *RuntimeContext) EnsureScopes(scopes []string) error {
|
||||
return checkShortcutScopes(ctx.Factory, ctx.ctx, ctx.As(), ctx.Config, scopes)
|
||||
}
|
||||
|
||||
// ── Flag accessors ──
|
||||
|
||||
// Str returns a string flag value.
|
||||
@@ -882,17 +895,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
if path == "" {
|
||||
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
|
||||
}
|
||||
f, err := rctx.FileIO().Open(path)
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return FlagErrorf("--%s: invalid file path %q: %v", fl.Name, path, err)
|
||||
}
|
||||
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
|
||||
}
|
||||
data, err := io.ReadAll(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
|
||||
return FlagErrorf("--%s: %v", fl.Name, err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
||||
continue
|
||||
|
||||
@@ -5,7 +5,6 @@ package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -60,13 +59,12 @@ func TestResolveInputFlags_Stdin(t *testing.T) {
|
||||
|
||||
func TestResolveInputFlags_File(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
content := "## Hello\n\nThis is **markdown** from a file.\n"
|
||||
fpath := filepath.Join(dir, "test.md")
|
||||
os.WriteFile(fpath, []byte(content), 0644)
|
||||
if err := os.WriteFile("test.md", []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@test.md"}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
@@ -79,6 +77,25 @@ func TestResolveInputFlags_File(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_EmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
if err := os.WriteFile("empty.md", nil, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@empty.md"}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
|
||||
if err := resolveInputFlags(rctx, flags); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := rctx.Str("markdown"); got != "" {
|
||||
t.Errorf("expected empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_EmptyInput(t *testing.T) {
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": ""}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
@@ -132,9 +149,7 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
|
||||
|
||||
func TestResolveInputFlags_FileNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@nonexistent.md"}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
@@ -156,7 +171,7 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty file path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty") {
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
// Flag describes a CLI flag for a shortcut.
|
||||
type Flag struct {
|
||||
Name string // flag name (e.g. "calendar-id")
|
||||
Type string // "string" (default) | "bool" | "int" | "string_array"
|
||||
Type string // "string" (default) | "bool" | "int" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
|
||||
@@ -43,6 +43,7 @@ var DocsCreate = common.Shortcut{
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"docx:document:create"},
|
||||
Tips: docsVersionSelectionTips,
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
@@ -110,6 +111,11 @@ func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
|
||||
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
warnDeprecatedV1(runtime, "+create")
|
||||
// Surface callout type= hint so users know to switch to background-color/
|
||||
// border-color when they want a colored callout. Non-blocking, advisory.
|
||||
if md := runtime.Str("markdown"); md != "" {
|
||||
WarnCalloutType(md, runtime.IO().ErrOut)
|
||||
}
|
||||
args := buildCreateArgsV1(runtime)
|
||||
result, err := common.CallMCPTool(runtime, "create-doc", args)
|
||||
if err != nil {
|
||||
@@ -122,8 +128,9 @@ func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
md := runtime.Str("markdown")
|
||||
args := map[string]interface{}{
|
||||
"markdown": runtime.Str("markdown"),
|
||||
"markdown": md,
|
||||
}
|
||||
if v := runtime.Str("title"); v != "" {
|
||||
args["title"] = v
|
||||
|
||||
@@ -49,6 +49,7 @@ var DocsFetch = common.Shortcut{
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Tips: docsVersionSelectionTips,
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
|
||||
@@ -22,7 +22,7 @@ func v2FetchFlags() []common.Flag {
|
||||
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
|
||||
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
|
||||
{Name: "keyword", Desc: "keyword mode: search string (case-insensitive); use '|' to match multiple keywords, e.g. 'foo|bar|baz'"},
|
||||
{Name: "keyword", Desc: "keyword mode: substring + regex match (case-insensitive); use '|' for OR branches, e.g. 'foo|bar' or 'bug|缺陷'"},
|
||||
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
|
||||
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
|
||||
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},
|
||||
|
||||
@@ -64,6 +64,7 @@ var DocsUpdate = common.Shortcut{
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Tips: docsVersionSelectionTips,
|
||||
Flags: concatFlags(
|
||||
[]common.Flag{
|
||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
||||
@@ -159,6 +160,12 @@ func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
|
||||
}
|
||||
|
||||
// Surface callout type= hint so users know to switch to background-color/
|
||||
// border-color when they want a colored callout. Non-blocking, advisory.
|
||||
if md := runtime.Str("markdown"); md != "" {
|
||||
WarnCalloutType(md, runtime.IO().ErrOut)
|
||||
}
|
||||
|
||||
args := buildUpdateArgsV1(runtime)
|
||||
|
||||
result, err := common.CallMCPTool(runtime, "update-doc", args)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -306,6 +308,113 @@ func fixSetextAmbiguity(md string) string {
|
||||
return setextRe.ReplaceAllString(md, "$1\n\n$2")
|
||||
}
|
||||
|
||||
// calloutTypeColors maps the semantic type= shorthand to a recommended
|
||||
// [background-color, border-color] pair for Feishu callout blocks.
|
||||
// Used only for hint messages — the Markdown itself is never rewritten.
|
||||
var calloutTypeColors = map[string][2]string{
|
||||
"warning": {"light-yellow", "yellow"},
|
||||
"caution": {"light-orange", "orange"},
|
||||
"note": {"light-blue", "blue"},
|
||||
"info": {"light-blue", "blue"},
|
||||
"tip": {"light-green", "green"},
|
||||
"success": {"light-green", "green"},
|
||||
"check": {"light-green", "green"},
|
||||
"error": {"light-red", "red"},
|
||||
"danger": {"light-red", "red"},
|
||||
"important": {"light-purple", "purple"},
|
||||
}
|
||||
|
||||
// calloutOpenTagRe matches a <callout …> opening tag.
|
||||
var calloutOpenTagRe = regexp.MustCompile(`<callout(\s[^>]*)?>`)
|
||||
|
||||
// calloutTypeAttrRe extracts the value of a type= attribute (single or
|
||||
// double quoted) from a callout opening tag's attribute string. The
|
||||
// (?:^|\s) anchor instead of \b is intentional: \b sits at any
|
||||
// word/non-word boundary, and `-` is a non-word character, so
|
||||
// `\btype=` would also match the suffix of `data-type=` and yield a
|
||||
// bogus type lookup. Anchoring on start-of-string-or-whitespace
|
||||
// requires a real attribute separator before the name.
|
||||
var calloutTypeAttrRe = regexp.MustCompile(`(?:^|\s)type=(?:"([^"]*)"|'([^']*)')`)
|
||||
|
||||
// calloutBackgroundColorAttrRe matches a background-color= attribute
|
||||
// name with optional whitespace around the equals sign, so forms like
|
||||
// `background-color="..."` and `background-color = "..."` are both
|
||||
// accepted. Same (?:^|\s) anchor as calloutTypeAttrRe, for the same
|
||||
// reason: `data-background-color="..."` must not look like a present
|
||||
// background-color and silently suppress the hint.
|
||||
var calloutBackgroundColorAttrRe = regexp.MustCompile(`(?:^|\s)background-color\s*=`)
|
||||
|
||||
// WarnCalloutType scans md for callout tags that carry a type= attribute but
|
||||
// no background-color= attribute, then writes a hint line to w for each one
|
||||
// suggesting the explicit Feishu color attributes to use instead.
|
||||
//
|
||||
// Callout tags inside fenced code blocks (``` or ~~~) are skipped — they
|
||||
// are documentation samples, not real callouts the user wants Feishu to
|
||||
// render. Fence detection uses the shared codeFenceOpenMarker /
|
||||
// isCodeFenceClose helpers so both backtick and tilde fences are handled
|
||||
// (matching CommonMark §4.5).
|
||||
//
|
||||
// The Markdown is not modified — the caller is responsible for acting on
|
||||
// the hints or ignoring them. This keeps the create/update path
|
||||
// transparent: user input reaches create-doc exactly as written.
|
||||
func WarnCalloutType(md string, w io.Writer) {
|
||||
fenceMarker := ""
|
||||
for _, line := range strings.Split(md, "\n") {
|
||||
if fenceMarker != "" {
|
||||
// Inside a fenced block — skip everything until the matching
|
||||
// closer. Code samples that show literal <callout type=...>
|
||||
// must not produce a phantom hint.
|
||||
if isCodeFenceClose(line, fenceMarker) {
|
||||
fenceMarker = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
if marker := codeFenceOpenMarker(line); marker != "" {
|
||||
fenceMarker = marker
|
||||
continue
|
||||
}
|
||||
scanCalloutTagsForWarning(line, w)
|
||||
}
|
||||
}
|
||||
|
||||
// scanCalloutTagsForWarning emits a hint to w for every <callout type="...">
|
||||
// tag in s that lacks an explicit background-color= attribute. Pulled out
|
||||
// of WarnCalloutType so the line walker only handles fence state and the
|
||||
// per-tag scan is its own readable unit.
|
||||
//
|
||||
// The previous implementation routed the tag iteration through
|
||||
// calloutOpenTagRe.ReplaceAllStringFunc with a callback that always
|
||||
// returned the original tag and threw the rebuilt string away — using a
|
||||
// rewrite primitive purely for its iteration side-effect, plus a second
|
||||
// regex execution to recover the capture groups inside the callback.
|
||||
// FindAllStringSubmatch hands us both the iteration and the groups in one
|
||||
// pass, no allocation thrown away.
|
||||
func scanCalloutTagsForWarning(s string, w io.Writer) {
|
||||
for _, m := range calloutOpenTagRe.FindAllStringSubmatch(s, -1) {
|
||||
attrs := m[1]
|
||||
// Skip tags that already carry an explicit background-color.
|
||||
if calloutBackgroundColorAttrRe.MatchString(attrs) {
|
||||
continue
|
||||
}
|
||||
parts := calloutTypeAttrRe.FindStringSubmatch(attrs)
|
||||
if len(parts) < 3 {
|
||||
continue // no type= attribute
|
||||
}
|
||||
// parts[1] is the double-quoted capture, parts[2] is single-quoted.
|
||||
typeName := parts[1]
|
||||
if typeName == "" {
|
||||
typeName = parts[2]
|
||||
}
|
||||
colors, ok := calloutTypeColors[typeName]
|
||||
if !ok {
|
||||
continue // unknown type — no hint to give
|
||||
}
|
||||
fmt.Fprintf(w,
|
||||
"hint: callout type=%q has no background-color; consider: background-color=%q border-color=%q\n",
|
||||
typeName, colors[0], colors[1])
|
||||
}
|
||||
}
|
||||
|
||||
// calloutEmojiAliases maps named emoji strings that fetch-doc emits to actual
|
||||
// Unicode emoji characters that create-doc accepts.
|
||||
var calloutEmojiAliases = map[string]string{
|
||||
|
||||
@@ -359,6 +359,135 @@ func TestFixExportedMarkdown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnCalloutType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantHint bool // whether a hint line is expected
|
||||
hintContains string // substring the hint must contain
|
||||
}{
|
||||
{
|
||||
name: "warning type without background-color emits hint",
|
||||
input: `<callout type="warning" emoji="📝">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-yellow"`,
|
||||
},
|
||||
{
|
||||
name: "info type without background-color emits hint",
|
||||
input: `<callout type="info" emoji="ℹ️">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-blue"`,
|
||||
},
|
||||
{
|
||||
name: "single-quoted type attribute emits hint",
|
||||
input: `<callout type='warning' emoji="📝">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-yellow"`,
|
||||
},
|
||||
{
|
||||
name: "explicit background-color suppresses hint",
|
||||
input: `<callout type="warning" emoji="📝" background-color="light-red">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "whitespace around equals is tolerated in background-color",
|
||||
input: `<callout type="warning" emoji="📝" background-color = "light-red">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "unknown type emits no hint",
|
||||
input: `<callout type="custom" emoji="🔥">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "no type attribute emits no hint",
|
||||
input: `<callout emoji="💡" background-color="light-green">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "non-callout tag emits no hint",
|
||||
input: `<div type="warning">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "hint includes border-color suggestion",
|
||||
input: `<callout type="error" emoji="❌">`,
|
||||
wantHint: true,
|
||||
hintContains: `border-color="red"`,
|
||||
},
|
||||
{
|
||||
// Regression: the old `\btype=` regex matched the suffix of
|
||||
// `data-type=` because `-` is a non-word character, so a tag
|
||||
// carrying only data-attrs would silently get a bogus hint.
|
||||
// The (?:^|\s) anchor requires a real attribute separator.
|
||||
name: "data-type attribute does not trigger hint",
|
||||
input: `<callout data-type="warning" emoji="📝">`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// Symmetric guard for the background-color regex: a future
|
||||
// `data-background-color=` attribute must not be mistaken
|
||||
// for a present background-color and silently suppress the
|
||||
// hint that the real type= would otherwise produce.
|
||||
name: "data-background-color does not suppress hint",
|
||||
input: `<callout type="warning" data-background-color="anything">`,
|
||||
wantHint: true,
|
||||
hintContains: `background-color="light-yellow"`,
|
||||
},
|
||||
{
|
||||
// Regression for the code-fence skip: a documentation sample
|
||||
// inside a ``` fence is NOT a real callout the user wants
|
||||
// rendered, so it must produce no stderr noise.
|
||||
name: "callout inside backtick fence emits no hint",
|
||||
input: "```markdown\n" +
|
||||
`<callout type="warning" emoji="📝">` + "\n" +
|
||||
"```\n",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// Same skip works for tilde fences (CommonMark §4.5 makes
|
||||
// `~~~` an equivalent fence character).
|
||||
name: "callout inside tilde fence emits no hint",
|
||||
input: "~~~markdown\n" +
|
||||
`<callout type="info" emoji="ℹ️">` + "\n" +
|
||||
"~~~\n",
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
// Closing the fence must restore normal scanning: a real
|
||||
// callout that follows a documentation block still gets a
|
||||
// hint. Pins that fenceMarker is reset, not stuck.
|
||||
name: "callout after fence close still emits hint",
|
||||
input: "```markdown\n" +
|
||||
`<callout type="warning">sample</callout>` + "\n" +
|
||||
"```\n" +
|
||||
`<callout type="error" emoji="❌">real</callout>` + "\n",
|
||||
wantHint: true,
|
||||
hintContains: `border-color="red"`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
WarnCalloutType(tt.input, &buf)
|
||||
got := buf.String()
|
||||
if tt.wantHint {
|
||||
if got == "" {
|
||||
t.Errorf("WarnCalloutType(%q): expected hint, got no output", tt.input)
|
||||
return
|
||||
}
|
||||
if tt.hintContains != "" && !strings.Contains(got, tt.hintContains) {
|
||||
t.Errorf("WarnCalloutType(%q): hint %q missing %q", tt.input, got, tt.hintContains)
|
||||
}
|
||||
} else {
|
||||
if got != "" {
|
||||
t.Errorf("WarnCalloutType(%q): expected no output, got %q", tt.input, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixCalloutEmoji(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -3,7 +3,24 @@
|
||||
|
||||
package doc
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const docsServiceHelpDefault = `Document and content operations.`
|
||||
|
||||
const docsServiceHelpV2 = `Document and content operations (v2).`
|
||||
|
||||
var docsVersionSelectionTips = []string{
|
||||
"Agent version rule: use --api-version v2 only when the installed lark-doc skill explicitly instructs docs +create, docs +fetch, or docs +update to use v2; otherwise use the default v1 flags.",
|
||||
"Do not mix versions: if the skill does not mention v2, follow its legacy v1 examples and flags.",
|
||||
}
|
||||
|
||||
// Shortcuts returns all docs shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
@@ -18,3 +35,48 @@ func Shortcuts() []common.Shortcut {
|
||||
DocMediaDownload,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command.
|
||||
// The shortcut-level help remains compatible with legacy v1 skills; this parent
|
||||
// help gives agents enough context to choose v2 only when their installed skill
|
||||
// explicitly asks for `--api-version v2`.
|
||||
func ConfigureServiceHelp(cmd *cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
serviceCmd := cmd
|
||||
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
|
||||
if cmd.Flags().Lookup("api-version") == nil {
|
||||
cmd.Flags().String("api-version", "", "show docs help for API version (v1|v2)")
|
||||
cmdutil.RegisterFlagCompletion(cmd, "api-version", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"v1", "v2"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
|
||||
defaultHelp := cmd.HelpFunc()
|
||||
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd != serviceCmd {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
|
||||
apiVersion, _ := cmd.Flags().GetString("api-version")
|
||||
previousLong := cmd.Long
|
||||
if apiVersion == "v2" {
|
||||
cmd.Long = strings.TrimSpace(docsServiceHelpV2)
|
||||
} else {
|
||||
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
|
||||
}
|
||||
defer func() {
|
||||
cmd.Long = previousLong
|
||||
}()
|
||||
|
||||
defaultHelp(cmd, args)
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Tips:")
|
||||
for _, tip := range docsVersionSelectionTips {
|
||||
fmt.Fprintf(out, " • %s\n", tip)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,13 +30,6 @@ func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersion
|
||||
}
|
||||
})
|
||||
origHelp(cmd, args)
|
||||
if ver == "v1" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(),
|
||||
"\n[NOTE] v1 API is deprecated and will be removed in a future release.\n"+
|
||||
" Use --api-version v2 for the latest API:\n"+
|
||||
" %s %s --api-version v2 --help\n",
|
||||
cmd.Parent().Name(), cmd.Name())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,35 @@ import (
|
||||
|
||||
const defaultLocateDocLimit = 10
|
||||
|
||||
// maxCommentTotalRunes is the cap on the combined character (rune) count
|
||||
// across all `reply_elements[].text` fields in a single
|
||||
// `drive +add-comment` request.
|
||||
//
|
||||
// The open-platform `/open-apis/drive/v1/files/{token}/new_comments`
|
||||
// endpoint returns an opaque `[1069302] Invalid or missing parameters`
|
||||
// when this is exceeded — no indication that length is the cause or
|
||||
// which element is at fault.
|
||||
//
|
||||
// Empirically (probing the live API):
|
||||
//
|
||||
// - 10000 runes in a single text element: OK (10000 ASCII / 30000
|
||||
// bytes for Chinese / 40000 bytes if all '<' — server counts the
|
||||
// raw rune count, not byte width and not the post-escape form)
|
||||
// - 10001 runes in a single text element: [1069302]
|
||||
// - 5000 + 5000 across two elements (total 10000): OK
|
||||
// - 5000 + 5001 across two elements (total 10001): [1069302]
|
||||
//
|
||||
// So the cap is applied to the *total* across all reply_elements, not
|
||||
// per element. Splitting an over-the-cap message into multiple text
|
||||
// elements does NOT help — the server enforces the same limit on the
|
||||
// sum.
|
||||
//
|
||||
// The schema doc currently advertises a 1-1000 character limit, but
|
||||
// the live API accepts up to 10000 runes; the schema is out of date.
|
||||
// If this constant ever needs to track a server-side change, re-probe
|
||||
// with `drive file.comments create_v2` against a fresh docx.
|
||||
const maxCommentTotalRunes = 10000
|
||||
|
||||
type commentDocRef struct {
|
||||
Kind string
|
||||
Token string
|
||||
@@ -604,6 +633,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
replyElements := make([]map[string]interface{}, 0, len(inputs))
|
||||
totalRunes := 0
|
||||
for i, input := range inputs {
|
||||
index := i + 1
|
||||
elementType := strings.TrimSpace(input.Type)
|
||||
@@ -612,9 +642,27 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
if strings.TrimSpace(input.Text) == "" {
|
||||
return nil, output.ErrValidation("--content element #%d type=text requires non-empty text", index)
|
||||
}
|
||||
if utf8.RuneCountInString(input.Text) > 1000 {
|
||||
return nil, output.ErrValidation("--content element #%d text exceeds 1000 characters", index)
|
||||
// Measure the raw rune count of the user input — that is what
|
||||
// the server actually counts. byte width and post-escape form
|
||||
// don't matter (10000 '<' chars succeed even though they
|
||||
// expand to 40000 bytes when escaped, and 10000 Chinese chars
|
||||
// succeed even though they encode as 30000 UTF-8 bytes).
|
||||
runes := utf8.RuneCountInString(input.Text)
|
||||
totalRunes += runes
|
||||
if totalRunes > maxCommentTotalRunes {
|
||||
return nil, output.ErrWithHint(
|
||||
output.ExitValidation,
|
||||
"text_too_long",
|
||||
fmt.Sprintf("--content reply_elements text totals %d characters at element #%d (this element: %d); the server caps the combined length at %d characters across ALL reply_elements",
|
||||
totalRunes, index, runes, maxCommentTotalRunes),
|
||||
fmt.Sprintf("shorten the comment so the combined text across all reply_elements fits within %d characters. The server enforces this cap on the TOTAL — splitting one long element into multiple smaller text elements does NOT help (they all add up against the same %d-rune budget). Server returns an opaque [1069302] on overflow, so this check is pre-flight; no escape transform changes the count (server reads raw runes).", maxCommentTotalRunes, maxCommentTotalRunes),
|
||||
)
|
||||
}
|
||||
// Escape '<' and '>' so the rendered comment displays them as
|
||||
// literal characters instead of being interpreted as markup
|
||||
// by Lark's comment renderer. This is independent of the
|
||||
// length check — the server sees the escaped form, but
|
||||
// counts characters by the raw input length above.
|
||||
replyElements = append(replyElements, map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": escapeCommentText(input.Text),
|
||||
|
||||
@@ -5,11 +5,13 @@ package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func decodeJSONMap(t *testing.T, raw string) map[string]interface{} {
|
||||
@@ -292,6 +294,186 @@ func TestParseCommentReplyElementsEscapesAngleBrackets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentReplyElementsTextLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Cap is 10000 runes total across all reply_elements text fields,
|
||||
// empirically derived from the live API. See the comment on
|
||||
// maxCommentTotalRunes for the probe results.
|
||||
exactCapASCII := strings.Repeat("a", 10000)
|
||||
overCapASCII := strings.Repeat("a", 10001)
|
||||
|
||||
// Chinese chars cost 3 bytes each in UTF-8 but the server counts
|
||||
// runes, not bytes — so the cap is the same 10000 here.
|
||||
exactCapCJK := strings.Repeat("文", 10000)
|
||||
overCapCJK := strings.Repeat("文", 10001)
|
||||
|
||||
// '<' would expand to '<' (4 bytes) under escapeCommentText, but
|
||||
// since the server counts raw runes the cap is still 10000 chars,
|
||||
// not 2500. This pins that distinction.
|
||||
exactCapAngle := strings.Repeat("<", 10000)
|
||||
overCapAngle := strings.Repeat("<", 10001)
|
||||
|
||||
// Two-element split exactly hitting the cap together.
|
||||
splitFiveK := strings.Repeat("a", 5000)
|
||||
splitFiveKPlusOne := strings.Repeat("a", 5001)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
wantHint string // substring of the hint portion; "" means don't check hint
|
||||
wantCount int // expected parsed element count when no error expected
|
||||
}{
|
||||
{
|
||||
name: "single element exactly at 10000 ASCII chars accepted",
|
||||
input: `[{"type":"text","text":"` + exactCapASCII + `"}]`,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "single element at 10001 ASCII chars rejected",
|
||||
input: `[{"type":"text","text":"` + overCapASCII + `"}]`,
|
||||
wantErr: "totals 10001 characters at element #1",
|
||||
wantHint: "splitting one long element into multiple smaller text elements does NOT help",
|
||||
},
|
||||
{
|
||||
name: "single element exactly at 10000 chinese chars accepted (server counts runes, not bytes)",
|
||||
input: `[{"type":"text","text":"` + exactCapCJK + `"}]`,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "single element at 10001 chinese chars rejected",
|
||||
input: `[{"type":"text","text":"` + overCapCJK + `"}]`,
|
||||
wantErr: "totals 10001 characters at element #1",
|
||||
},
|
||||
{
|
||||
name: "10000 angle brackets accepted (server counts raw runes, not escaped form)",
|
||||
input: `[{"type":"text","text":"` + exactCapAngle + `"}]`,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "10001 angle brackets rejected (escape state irrelevant to cap)",
|
||||
input: `[{"type":"text","text":"` + overCapAngle + `"}]`,
|
||||
wantErr: "totals 10001 characters at element #1",
|
||||
},
|
||||
{
|
||||
// Pins the multi-element TOTAL cap: two 5000-char elements
|
||||
// fit together exactly (10000 sum). This is the boundary the
|
||||
// previous PR's "split into multiple elements" advice
|
||||
// implied was a workaround — it's actually only valid if
|
||||
// the sum still fits.
|
||||
name: "two elements totalling exactly 10000 accepted",
|
||||
input: `[{"type":"text","text":"` + splitFiveK + `"},{"type":"text","text":"` + splitFiveK + `"}]`,
|
||||
wantCount: 2,
|
||||
},
|
||||
{
|
||||
// Companion to the above and the headline reason the prior
|
||||
// "split into multiple elements" hint is wrong: 5000+5001
|
||||
// sums to 10001 which the server rejects with the same
|
||||
// opaque [1069302], regardless of how many elements it's
|
||||
// distributed across.
|
||||
name: "two elements totalling 10001 rejected with index pointing at offending element",
|
||||
input: `[{"type":"text","text":"` + splitFiveK + `"},{"type":"text","text":"` + splitFiveKPlusOne + `"}]`,
|
||||
wantErr: "totals 10001 characters at element #2",
|
||||
wantHint: "splitting one long element into multiple smaller text elements does NOT help",
|
||||
},
|
||||
{
|
||||
// Streaming-cap correctness: when an EARLY element by itself
|
||||
// already overshoots, the index reported is that early
|
||||
// element (not the last one in the array).
|
||||
name: "first element over the cap reports index 1",
|
||||
input: `[{"type":"text","text":"` + overCapASCII + `"},{"type":"text","text":"trailing"}]`,
|
||||
wantErr: "totals 10001 characters at element #1",
|
||||
},
|
||||
{
|
||||
// mention_user / link elements don't count toward the
|
||||
// rune cap (their content is ID / URL, not user-visible
|
||||
// running text). Pin that a moderate text plus a mention
|
||||
// stays accepted even though the mention adds bytes.
|
||||
name: "text plus mention_user does not double-count toward cap",
|
||||
input: `[{"type":"text","text":"` + exactCapASCII + `"},{"type":"mention_user","text":"ou_1234567890abcdef"}]`,
|
||||
wantCount: 2,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := parseCommentReplyElements(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil (parsed %d elements)", tt.wantErr, len(got))
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
|
||||
}
|
||||
if tt.wantHint != "" {
|
||||
// Hint lives on ExitError.Detail.Hint, not err.Error().
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
|
||||
t.Errorf("expected hint substring %q, got %q", tt.wantHint, exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != tt.wantCount {
|
||||
t.Fatalf("expected %d reply elements, got %d", tt.wantCount, len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCommentReplyElementsHintForbidsSplitAdvice pins that the
|
||||
// over-cap hint does NOT recommend splitting into multiple text
|
||||
// elements as a workaround. An earlier version of this PR shipped
|
||||
// that advice; live-API probing showed the cap is on the *total* run
|
||||
// of characters across all reply_elements, so splitting doesn't
|
||||
// bypass it. If the hint ever drifts back into recommending a split,
|
||||
// users will be sent down a dead end where their first attempt fails
|
||||
// pre-flight, their "fixed" attempt also fails server-side, and
|
||||
// they're stuck.
|
||||
func TestParseCommentReplyElementsHintForbidsSplitAdvice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseCommentReplyElements(`[{"type":"text","text":"` + strings.Repeat("a", 10001) + `"}]`)
|
||||
if err == nil {
|
||||
t.Fatal("expected over-cap error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
|
||||
}
|
||||
hint := exitErr.Detail.Hint
|
||||
|
||||
// The hint must explicitly call out that splitting does NOT help.
|
||||
if !strings.Contains(hint, "does NOT help") {
|
||||
t.Errorf("hint must explicitly say splitting does NOT help, got: %q", hint)
|
||||
}
|
||||
// Anti-pattern check: the hint must not phrase any "split into
|
||||
// multiple elements" recommendation as a workaround. Look for the
|
||||
// previous PR's exact phrasing variants.
|
||||
for _, banned := range []string{
|
||||
"split the content across multiple",
|
||||
"split into multiple text elements",
|
||||
"renders them as one contiguous comment",
|
||||
} {
|
||||
if strings.Contains(hint, banned) {
|
||||
t.Errorf("hint must not contain the discredited %q advice, got: %q", banned, hint)
|
||||
}
|
||||
}
|
||||
// And it should reference the actual number so callers know the
|
||||
// budget without having to read the source.
|
||||
if !strings.Contains(hint, "10000") {
|
||||
t.Errorf("hint should name the 10000-rune budget, got: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentReplyElementsInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "file-name", Desc: "preferred output filename (optional)"},
|
||||
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
@@ -54,14 +55,19 @@ var DriveExport = common.Shortcut{
|
||||
// Markdown export is a special case: docx markdown comes from docs content
|
||||
// directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
return common.NewDryRunAPI().
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
GET("/open-apis/docs/v1/content").
|
||||
Params(map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
})
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
@@ -73,10 +79,15 @@ var DriveExport = common.Shortcut{
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body)
|
||||
Body(body).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveExportSpec{
|
||||
@@ -86,6 +97,7 @@ var DriveExport = common.Shortcut{
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
@@ -106,14 +118,18 @@ var DriveExport = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName := ensureExportFileExtension(sanitizeExportFileName(title, spec.Token), spec.FileExtension)
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -166,7 +182,11 @@ var DriveExport = common.Shortcut{
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
fileName := ensureExportFileExtension(sanitizeExportFileName(status.FileName, spec.Token), spec.FileExtension)
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
@@ -227,7 +247,7 @@ var DriveExport = common.Shortcut{
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
runtime.Out(map[string]interface{}{
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
@@ -238,7 +258,11 @@ var DriveExport = common.Shortcut{
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}, nil)
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -130,6 +130,155 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# custom\n",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--file-name", "custom-notes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "# custom\n" {
|
||||
t.Fatalf("markdown content = %q", string(data))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_name": "custom-notes.md"`) {
|
||||
t.Fatalf("stdout missing provided file name: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
wantURL string
|
||||
wantFileName string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "markdown",
|
||||
wantURL: "/open-apis/docs/v1/content",
|
||||
wantFileName: `"file_name": "notes.md"`,
|
||||
args: []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--file-name", "notes",
|
||||
"--output-dir", "./exports",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "async export",
|
||||
wantURL: "/open-apis/drive/v1/export_tasks",
|
||||
wantFileName: `"file_name": "report.pdf"`,
|
||||
args: []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "report",
|
||||
"--output-dir", "./exports",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, tt.args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, tt.wantURL) {
|
||||
t.Fatalf("stdout missing URL %q: %s", tt.wantURL, out)
|
||||
}
|
||||
if !strings.Contains(out, tt.wantFileName) {
|
||||
t.Fatalf("stdout missing file_name metadata %q: %s", tt.wantFileName, out)
|
||||
}
|
||||
if !strings.Contains(out, `"output_dir": "./exports"`) {
|
||||
t.Fatalf("stdout missing output_dir metadata: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# fallback\n",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "metadata unavailable",
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "# fallback\n" {
|
||||
t.Fatalf("markdown content = %q", string(data))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_name": "docx123.md"`) {
|
||||
t.Fatalf("stdout missing fallback file name: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -200,6 +349,77 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_custom"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_custom",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0,
|
||||
"file_token": "box_custom",
|
||||
"file_name": "server-name",
|
||||
"file_extension": "pdf",
|
||||
"type": "docx",
|
||||
"file_size": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_custom/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="server-name.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "custom-report",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "pdf" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_name": "custom-report.pdf"`) {
|
||||
t.Fatalf("stdout missing provided file name: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
@@ -425,6 +645,51 @@ func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportTimeoutPreservesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_name"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_name",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "quarterly-report",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_name": "quarterly-report.pdf"`) {
|
||||
t.Fatalf("stdout missing preserved file name: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
337
shortcuts/drive/drive_pull.go
Normal file
337
shortcuts/drive/drive_pull.go
Normal file
@@ -0,0 +1,337 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
drivePullIfExistsOverwrite = "overwrite"
|
||||
drivePullIfExistsSkip = "skip"
|
||||
)
|
||||
|
||||
type drivePullItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DrivePull performs a one-way file-level mirror from a Drive folder onto
|
||||
// a local directory: recursively lists --folder-token, downloads each
|
||||
// type=file entry under --local-dir, and optionally deletes local files
|
||||
// absent from Drive (--delete-local --yes).
|
||||
//
|
||||
// Only Drive entries with type=file participate; online docs (docx, sheet,
|
||||
// bitable, mindnote, slides) and shortcuts are skipped because there is no
|
||||
// equivalent local binary to write back. Directories are reproduced when
|
||||
// remote folders contain downloadable files, but local directories that
|
||||
// become orphaned after a remote folder is removed are NOT pruned —
|
||||
// --delete-local only unlinks regular files.
|
||||
var DrivePull = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+pull",
|
||||
Description: "One-way file-level mirror of a Drive folder onto a local directory (Drive → local)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "source Drive folder token", Required: true},
|
||||
{Name: "if-exists", Desc: "policy when a local file already exists", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}},
|
||||
{Name: "delete-local", Type: "bool", Desc: "delete local regular files absent from Drive (file-level mirror; empty directories are NOT pruned); requires --yes"},
|
||||
{Name: "yes", Type: "bool", Desc: "confirm --delete-local before deleting local files"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Only entries with type=file are downloaded; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
||||
"Subfolders recurse and are reproduced as local directories under --local-dir; missing parents are created automatically.",
|
||||
"--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
if localDir == "" {
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
}
|
||||
if runtime.Bool("delete-local") && !runtime.Bool("yes") {
|
||||
return output.ErrValidation("--delete-local requires --yes (high-risk: deletes local files absent from Drive)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Recursively list --folder-token, download each type=file entry into --local-dir, and (when --delete-local --yes is set) remove local files absent from Drive.").
|
||||
GET("/open-apis/drive/v1/files").
|
||||
Set("folder_token", runtime.Str("folder-token"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
ifExists := strings.TrimSpace(runtime.Str("if-exists"))
|
||||
if ifExists == "" {
|
||||
ifExists = drivePullIfExistsOverwrite
|
||||
}
|
||||
deleteLocal := runtime.Bool("delete-local")
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before we
|
||||
// touch the filesystem. SafeInputPath fully evaluates symlinks
|
||||
// across the entire path; this matters because filepath.Clean
|
||||
// alone shrinks "link/.." to "." while the kernel resolves it
|
||||
// through the symlink target's parent — meaning a raw walk on
|
||||
// the user-supplied string can land outside cwd. Walking the
|
||||
// canonical root sidesteps that, and using cwd canonical lets
|
||||
// us emit cwd-relative download targets that FileIO.Save's
|
||||
// SafeOutputPath check still accepts. The risk is much higher
|
||||
// here than in +status because --delete-local would otherwise
|
||||
// remove the wrong files outside cwd.
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return output.ErrValidation("could not resolve cwd: %s", err)
|
||||
}
|
||||
// rootRelToCwd is the localDir form FileIO.Save accepts (it
|
||||
// rejects absolute paths). For cwd itself it becomes ".", which
|
||||
// joins cleanly with the rel_paths returned by the lister.
|
||||
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Two views over the same listing:
|
||||
// - remoteFiles drives the download/skip loop (only type=file
|
||||
// has hashable bytes the local mirror can write back).
|
||||
// - remotePaths is the --delete-local guard: it carries every
|
||||
// rel_path Drive owns regardless of type, so a local file
|
||||
// shadowed by a remote folder / online doc / shortcut is NOT
|
||||
// treated as orphaned.
|
||||
remoteFiles := make(map[string]string, len(entries))
|
||||
remotePaths := make(map[string]struct{}, len(entries))
|
||||
for rel, entry := range entries {
|
||||
remotePaths[rel] = struct{}{}
|
||||
if entry.Type == driveTypeFile {
|
||||
remoteFiles[rel] = entry.FileToken
|
||||
}
|
||||
}
|
||||
|
||||
var downloaded, skipped, failed, deletedLocal int
|
||||
downloadFailed := 0
|
||||
items := make([]drivePullItem, 0)
|
||||
|
||||
// Deterministic iteration order for output stability.
|
||||
downloadablePaths := make([]string, 0, len(remoteFiles))
|
||||
for p := range remoteFiles {
|
||||
downloadablePaths = append(downloadablePaths, p)
|
||||
}
|
||||
sort.Strings(downloadablePaths)
|
||||
|
||||
for _, rel := range downloadablePaths {
|
||||
token := remoteFiles[rel]
|
||||
target := filepath.Join(rootRelToCwd, rel)
|
||||
|
||||
if info, statErr := runtime.FileIO().Stat(target); statErr == nil {
|
||||
// Mirror conflict: remote is a regular file but local
|
||||
// has a directory at the same rel_path. Neither
|
||||
// "skipped" nor "downloaded" describes reality —
|
||||
// SafeOutputPath would refuse to write a file over a
|
||||
// directory, and pretending the directory is a
|
||||
// pre-existing file under --if-exists=skip silently
|
||||
// hides the conflict. Surface as a failure.
|
||||
if info.IsDir() {
|
||||
items = append(items, drivePullItem{
|
||||
RelPath: rel,
|
||||
FileToken: token,
|
||||
Action: "failed",
|
||||
Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target),
|
||||
})
|
||||
failed++
|
||||
downloadFailed++
|
||||
continue
|
||||
}
|
||||
if ifExists == drivePullIfExistsSkip {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "skipped"})
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := drivePullDownload(ctx, runtime, token, target); err != nil {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "failed", Error: err.Error()})
|
||||
failed++
|
||||
downloadFailed++
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "downloaded"})
|
||||
downloaded++
|
||||
}
|
||||
|
||||
// Gate --delete-local on a clean download pass. With download
|
||||
// failures still in items[], proceeding to the delete walk would
|
||||
// leave the mirror in a half-synced state where some files Drive
|
||||
// owns are missing locally AND some local-only files have been
|
||||
// removed. Surface the failure first; the operator can re-run
|
||||
// after fixing whatever caused the download error.
|
||||
if deleteLocal && downloadFailed == 0 {
|
||||
// Walk the canonical absolute root, build the list of
|
||||
// rel_paths, then delete via the absolute path. Both
|
||||
// values come from the validated safeRoot, so kernel
|
||||
// path resolution cannot redirect the delete to a file
|
||||
// outside the canonical subtree.
|
||||
localAbsPaths, err := drivePullWalkLocal(safeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, absPath := range localAbsPaths {
|
||||
rel, relErr := filepath.Rel(safeRoot, absPath)
|
||||
if relErr != nil {
|
||||
items = append(items, drivePullItem{RelPath: absPath, Action: "delete_failed", Error: relErr.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
// Consult remotePaths (every Drive entry, regardless of
|
||||
// type) rather than remoteFiles (downloadable subset
|
||||
// only). Otherwise an online doc / shortcut at e.g.
|
||||
// "notes.docx" would leave a same-named local file
|
||||
// looking orphaned and get unlinked even though Drive
|
||||
// still knows about that path.
|
||||
if _, ok := remotePaths[rel]; ok {
|
||||
continue
|
||||
}
|
||||
// FileIO has no Remove(); the absolute path comes from
|
||||
// walking safeRoot, which validate.SafeInputPath has
|
||||
// already bounded inside cwd, so a bare os.Remove is
|
||||
// acceptable here. Shortcuts cannot import internal/vfs
|
||||
// directly (depguard rule shortcuts-no-vfs).
|
||||
if err := os.Remove(absPath); err != nil { //nolint:forbidigo // see comment above
|
||||
items = append(items, drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePullItem{RelPath: rel, Action: "deleted_local"})
|
||||
deletedLocal++
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"downloaded": downloaded,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_local": deletedLocal,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
|
||||
// Item-level failures (download error, dir/file conflict, delete
|
||||
// error) must surface as a non-zero exit so AI / script callers
|
||||
// don't have to reach into summary.failed to detect a partial
|
||||
// sync. The same structured payload rides along in error.detail
|
||||
// so forensics aren't lost. When --delete-local was skipped
|
||||
// because of an earlier download failure, callers see
|
||||
// deleted_local=0 plus the download failure that aborted it,
|
||||
// which is what makes the partial state self-explanatory.
|
||||
if failed > 0 {
|
||||
msg := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed)
|
||||
if deleteLocal && downloadFailed > 0 {
|
||||
msg += " (--delete-local was skipped because the download pass had failures)"
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "partial_failure",
|
||||
Message: msg,
|
||||
Detail: payload,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(payload, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target string) error {
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: "GET",
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body); err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drivePullWalkLocal walks the canonical absolute root and returns the
|
||||
// absolute paths of every regular file underneath it. The caller deletes
|
||||
// some of these paths, so it is critical that they are produced by
|
||||
// walking a canonical root (no symlinks in the path) — otherwise OS path
|
||||
// resolution could redirect a delete to a file outside cwd. Same threat
|
||||
// model as drive_status.go.
|
||||
func drivePullWalkLocal(root string) ([]string, error) {
|
||||
var paths []string
|
||||
// FileIO has no walker today; shortcuts cannot import internal/vfs
|
||||
// (depguard rule shortcuts-no-vfs). The root passed in is the
|
||||
// canonical absolute path returned by validate.SafeInputPath, so
|
||||
// WalkDir's default "do not follow child symlinks" policy keeps the
|
||||
// traversal inside the validated subtree.
|
||||
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
paths = append(paths, absPath)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
1026
shortcuts/drive/drive_pull_test.go
Normal file
1026
shortcuts/drive/drive_pull_test.go
Normal file
File diff suppressed because it is too large
Load Diff
717
shortcuts/drive/drive_push.go
Normal file
717
shortcuts/drive/drive_push.go
Normal file
@@ -0,0 +1,717 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
drivePushIfExistsOverwrite = "overwrite"
|
||||
drivePushIfExistsSkip = "skip"
|
||||
)
|
||||
|
||||
type drivePushItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Version string `json:"version,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
||||
// Drive folder: walks --local-dir, recursively lists --folder-token, and for
|
||||
// each rel_path uploads (or overwrites) the corresponding Drive file. With
|
||||
// --delete-remote --yes, any type=file entry on Drive that has no local
|
||||
// counterpart is removed; online docs (docx/sheet/bitable/...), shortcuts
|
||||
// and folders are never deleted, so this is "file-level" mirror — the
|
||||
// command does not attempt to remove remote-only directories or close gaps
|
||||
// in directory structure that exists on Drive but not locally.
|
||||
//
|
||||
// Only Drive entries with type=file participate in upload/overwrite/delete;
|
||||
// online documents have no equivalent local binary. Sub-folders are created
|
||||
// on Drive on demand via /open-apis/drive/v1/files/create_folder so the
|
||||
// remote tree mirrors the local tree.
|
||||
//
|
||||
// The overwrite path passes the existing file_token as a form field on
|
||||
// /open-apis/drive/v1/files/upload_all, mirroring the markdown +overwrite
|
||||
// contract in shortcuts/markdown. The Drive backend exposing that field is
|
||||
// being rolled out; until rollout completes, --if-exists defaults to "skip"
|
||||
// so the safe path (do not touch existing remote files) is the default and
|
||||
// callers must opt into "overwrite" explicitly.
|
||||
var DrivePush = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+push",
|
||||
Description: "File-level mirror of a local directory onto a Drive folder (local → Drive; remote-only directories are not removed)",
|
||||
Risk: "write",
|
||||
// Narrowed scopes follow the precedent set by drive +status / +pull:
|
||||
// drive:drive is policy-disabled in some tenants, so this shortcut sticks
|
||||
// to the smallest set the *core* path needs. space:folder:create is
|
||||
// always declared because mirroring a non-flat tree calls
|
||||
// /open-apis/drive/v1/files/create_folder on demand and we want the
|
||||
// framework's pre-flight scope check to catch missing grants before any
|
||||
// upload — otherwise a partial push could land top-level files and then
|
||||
// trip on a missing folder grant for a sub-tree, leaving a half-synced
|
||||
// state.
|
||||
//
|
||||
// space:document:delete is intentionally NOT in the default set even
|
||||
// though --delete-remote needs it. The framework pre-check (runner.go
|
||||
// checkShortcutScopes) runs unconditionally before Validate / dry-run,
|
||||
// so declaring it here would make every plain push (and every
|
||||
// --dry-run) fail for callers that only granted upload scopes.
|
||||
//
|
||||
// Instead, Validate runs a *conditional* pre-flight via
|
||||
// runtime.EnsureScopes when both --delete-remote and --yes are on, so
|
||||
// the missing grant fails the run upfront — before any upload —
|
||||
// rather than landing files first and tripping on missing_scope when
|
||||
// the cleanup pass tries to delete. That avoids the half-synced state
|
||||
// (files uploaded, orphans never cleaned up) that the unconditional
|
||||
// pre-check would otherwise prevent only by also blocking plain
|
||||
// pushes.
|
||||
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:upload", "space:folder:create"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "target Drive folder token", Required: true},
|
||||
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (default: skip — safe; opt into overwrite explicitly while the backend version field is rolling out)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSkip}},
|
||||
{Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"},
|
||||
{Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"},
|
||||
},
|
||||
Tips: []string{
|
||||
"This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.",
|
||||
"Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.",
|
||||
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero.",
|
||||
"--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
|
||||
"--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.",
|
||||
"Item-level failures (upload, overwrite, folder, delete) bump summary.failed and the run exits non-zero. If any upload or folder step fails, the --delete-remote phase is skipped entirely so a partial upload never triggers remote deletion.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
if localDir == "" {
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
}
|
||||
if runtime.Bool("delete-remote") && !runtime.Bool("yes") {
|
||||
return output.ErrValidation("--delete-remote requires --yes (high-risk: deletes Drive files absent locally)")
|
||||
}
|
||||
// Conditional scope pre-check: when --delete-remote --yes is set, the
|
||||
// run will issue DELETE /open-apis/drive/v1/files/<token> after the
|
||||
// upload phase. The default Scopes list intentionally omits
|
||||
// space:document:delete so plain pushes don't get blocked on a grant
|
||||
// they don't need (see the Scopes block above), but at this point we
|
||||
// know the run will need it — pre-flight here so a missing grant
|
||||
// fails before any upload, instead of after, which would otherwise
|
||||
// leave the tenant in a half-synced state (files uploaded, remote
|
||||
// orphans never cleaned up). EnsureScopes is a silent no-op when no
|
||||
// token / scope metadata is available, so test envs and tenants
|
||||
// where the resolver doesn't expose scopes still proceed and rely on
|
||||
// the API-level missing_scope error.
|
||||
if runtime.Bool("delete-remote") && runtime.Bool("yes") {
|
||||
if err := runtime.EnsureScopes([]string{"space:document:delete"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Walk --local-dir, recursively list --folder-token, then upload new files, overwrite (when --if-exists=overwrite) or skip existing, and (when --delete-remote --yes is set) delete Drive files absent locally.").
|
||||
GET("/open-apis/drive/v1/files").
|
||||
Set("folder_token", runtime.Str("folder-token"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
ifExists := strings.TrimSpace(runtime.Str("if-exists"))
|
||||
if ifExists == "" {
|
||||
// Default to the safe "skip" policy: do not touch already-present
|
||||
// remote files. Callers must pass --if-exists=overwrite to opt
|
||||
// into the overwrite-with-version path that depends on the
|
||||
// rolling-out upload_all `file_token`/`version` protocol field.
|
||||
ifExists = drivePushIfExistsSkip
|
||||
}
|
||||
deleteRemote := runtime.Bool("delete-remote")
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before walking.
|
||||
// SafeInputPath fully evaluates symlinks across the entire path,
|
||||
// which closes the kernel-level escape route that filepath.Clean
|
||||
// alone misses (e.g. "link/.." string-cleans to "." but the kernel
|
||||
// resolves through link's target's parent). Walking the canonical
|
||||
// root sidesteps that, and the matching cwd canonical lets each
|
||||
// absolute walk hit be converted to a cwd-relative path that
|
||||
// FileIO.Open's SafeInputPath check still accepts.
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return output.ErrValidation("could not resolve cwd: %s", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
|
||||
localFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Two views over the same listing:
|
||||
// - remoteFiles drives upload / overwrite / orphan-delete
|
||||
// decisions (only type=file entries are upload candidates;
|
||||
// online docs / shortcuts are intentionally never overwritten
|
||||
// or deleted by --delete-remote).
|
||||
// - remoteFolders is the create_folder cache: lets the upload
|
||||
// path skip create_folder when an intermediate folder already
|
||||
// exists, and keeps directory recreation idempotent across
|
||||
// reruns.
|
||||
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
|
||||
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
|
||||
for rel, entry := range entries {
|
||||
switch entry.Type {
|
||||
case driveTypeFile:
|
||||
remoteFiles[rel] = entry
|
||||
case driveTypeFolder:
|
||||
remoteFolders[rel] = entry
|
||||
}
|
||||
}
|
||||
|
||||
var uploaded, skipped, failed, deletedRemote int
|
||||
items := make([]drivePushItem, 0)
|
||||
// uploadFailed tracks whether any folder-creation, upload or
|
||||
// overwrite step failed. The --delete-remote phase only runs when
|
||||
// this stays false: a partial upload that then proceeds to delete
|
||||
// remote orphans would leave the tenant half-synced (files missing
|
||||
// locally and now on Drive too), which is the worst-of-both-worlds
|
||||
// outcome the review flagged.
|
||||
uploadFailed := false
|
||||
|
||||
// folderCache holds rel_path → folder_token. Seeded from the remote
|
||||
// listing (so we don't recreate folders that already exist) and
|
||||
// extended in-place as drivePushEnsureFolder mints new ones.
|
||||
folderCache := map[string]string{"": folderToken}
|
||||
for relDir, entry := range remoteFolders {
|
||||
folderCache[relDir] = entry.FileToken
|
||||
}
|
||||
|
||||
// Mirror local directory structure first, so empty directories
|
||||
// are not silently dropped. Pre-creating also frees the upload
|
||||
// loop from doing on-demand mkdir for every file's parent chain
|
||||
// (the cache makes both paths idempotent, but pre-creation keeps
|
||||
// items[] in a tidy "folders, then files" shape).
|
||||
for _, relDir := range localDirs {
|
||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||
// Folder already exists on Drive — nothing to do; staying
|
||||
// silent (no items[] entry) avoids noise on reruns.
|
||||
continue
|
||||
}
|
||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||
items = append(items, drivePushItem{RelPath: relDir, Action: "failed", Error: ensureErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created"})
|
||||
}
|
||||
|
||||
// Upload local-only and overwrite/skip already-present files in a
|
||||
// stable order so output is reproducible.
|
||||
localPaths := make([]string, 0, len(localFiles))
|
||||
for p := range localFiles {
|
||||
localPaths = append(localPaths, p)
|
||||
}
|
||||
sort.Strings(localPaths)
|
||||
|
||||
for _, rel := range localPaths {
|
||||
localFile := localFiles[rel]
|
||||
|
||||
if entry, ok := remoteFiles[rel]; ok {
|
||||
if ifExists == drivePushIfExistsSkip {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "skipped", SizeBytes: localFile.Size})
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, folderToken)
|
||||
if upErr != nil {
|
||||
// Token contract on overwrite failure: an in-place
|
||||
// overwrite preserves the file's token, so the
|
||||
// existing entry.FileToken is normally still the
|
||||
// authoritative pointer to the (possibly already
|
||||
// rewritten) Drive file. But the protocol does not
|
||||
// strictly forbid the backend from minting a new
|
||||
// token, and a partial-success response can return a
|
||||
// non-empty file_token alongside an error (the
|
||||
// missing-version case below is the immediate
|
||||
// concern: bytes hit the disk, version field
|
||||
// missing, so we surface a structured error). Prefer
|
||||
// the freshly returned token when one was produced,
|
||||
// fall back to entry.FileToken otherwise — that way
|
||||
// callers still have a usable handle to whatever
|
||||
// state Drive ended up in.
|
||||
failedToken := token
|
||||
if failedToken == "" {
|
||||
failedToken = entry.FileToken
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: failedToken, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "overwritten", Version: version, SizeBytes: localFile.Size})
|
||||
uploaded++
|
||||
continue
|
||||
}
|
||||
|
||||
parentRel := drivePushParentRel(rel)
|
||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||
if ensureErr != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: ensureErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||
if upErr != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "uploaded", SizeBytes: localFile.Size})
|
||||
uploaded++
|
||||
}
|
||||
|
||||
// Skip the delete phase entirely on any upstream failure. The orphan
|
||||
// loop deletes by remote token and is unrecoverable; running it
|
||||
// after a failed upload risks deleting a file the partial upload
|
||||
// would have replaced on a successful re-run, leaving the tenant
|
||||
// in a worse state than where we started. Surface the skipped
|
||||
// delete as a hint in stderr so operators know the cleanup pass
|
||||
// is pending and can re-run after fixing the upload.
|
||||
if deleteRemote && uploadFailed {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"Skipping --delete-remote: %d earlier failure(s) — re-run after resolving them.\n",
|
||||
failed)
|
||||
}
|
||||
if deleteRemote && !uploadFailed {
|
||||
// Stable iteration order so failures (and tests) are deterministic.
|
||||
remoteRelPaths := make([]string, 0, len(remoteFiles))
|
||||
for p := range remoteFiles {
|
||||
remoteRelPaths = append(remoteRelPaths, p)
|
||||
}
|
||||
sort.Strings(remoteRelPaths)
|
||||
|
||||
for _, rel := range remoteRelPaths {
|
||||
if _, ok := localFiles[rel]; ok {
|
||||
continue
|
||||
}
|
||||
entry := remoteFiles[rel]
|
||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
|
||||
deletedRemote++
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"uploaded": uploaded,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_remote": deletedRemote,
|
||||
},
|
||||
"items": items,
|
||||
}, nil)
|
||||
// Bump the exit code on any item-level failure (upload, overwrite,
|
||||
// folder, or delete) so callers / scripts / agents can react. The
|
||||
// summary + items[] envelope was just written to stdout via Out(),
|
||||
// so ErrBare here only affects the exit code — the structured
|
||||
// per-item context is still in the stdout JSON.
|
||||
if failed > 0 {
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// drivePushLocalFile records what we need to upload a local regular file:
|
||||
// a rel_path used for output and Drive layout, the cwd-relative path that
|
||||
// FileIO.Open accepts, the file size (drives single/multipart selection),
|
||||
// and the basename used as Drive's file_name.
|
||||
type drivePushLocalFile struct {
|
||||
RelPath string
|
||||
OpenPath string
|
||||
FileName string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// drivePushWalkLocal walks the canonical absolute root produced by
|
||||
// SafeInputPath. Same threat model as +pull/+status: the validated root
|
||||
// is not a symlink itself, and WalkDir's default policy (do not follow
|
||||
// child symlinks) keeps the traversal inside that canonical subtree, so
|
||||
// the OpenPath we hand to FileIO.Open stays inside cwd.
|
||||
//
|
||||
// Returns two views:
|
||||
// - files: rel_path → file metadata; drives the upload/skip/overwrite loop.
|
||||
// - dirs: every non-root directory rel_path encountered. Used to mirror
|
||||
// empty directories (which would otherwise be silently dropped because
|
||||
// the upload loop only iterates files); non-empty directories appear
|
||||
// here too but are harmless because drivePushEnsureFolder is cached.
|
||||
func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFile, []string, error) {
|
||||
files := make(map[string]drivePushLocalFile)
|
||||
dirsSet := make(map[string]struct{})
|
||||
// FileIO has no walker today and shortcuts can't import internal/vfs
|
||||
// (depguard rule shortcuts-no-vfs). The walk root is the canonical
|
||||
// absolute path returned by validate.SafeInputPath, so it is no
|
||||
// longer a symlink itself, and WalkDir's default child-symlink
|
||||
// policy keeps the traversal inside the validated subtree.
|
||||
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(root, absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
if d.IsDir() {
|
||||
// Skip the root itself ("."): that is --folder-token, already
|
||||
// the parent we mirror into, not a sub-folder we need to
|
||||
// create.
|
||||
if relSlash != "." {
|
||||
dirsSet[relSlash] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
relToCwd, err := filepath.Rel(cwdCanonical, absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[relSlash] = drivePushLocalFile{
|
||||
RelPath: relSlash,
|
||||
OpenPath: relToCwd,
|
||||
FileName: filepath.Base(rel),
|
||||
Size: info.Size(),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
|
||||
}
|
||||
dirs := make([]string, 0, len(dirsSet))
|
||||
for d := range dirsSet {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
// Shallow-first ordering ensures parents are created before children;
|
||||
// drivePushEnsureFolder also handles parent recursion on its own, but
|
||||
// emitting items[] in shallow-first order matches what users expect.
|
||||
sort.Slice(dirs, func(i, j int) bool {
|
||||
di, dj := strings.Count(dirs[i], "/"), strings.Count(dirs[j], "/")
|
||||
if di != dj {
|
||||
return di < dj
|
||||
}
|
||||
return dirs[i] < dirs[j]
|
||||
})
|
||||
return files, dirs, nil
|
||||
}
|
||||
|
||||
// drivePushEnsureFolder ensures a folder chain (rel_dir relative to the root
|
||||
// folder identified by rootFolderToken) exists on Drive, creating any
|
||||
// missing segments via /open-apis/drive/v1/files/create_folder. Returns the
|
||||
// token of the deepest folder, suitable as parent_node for the upload.
|
||||
//
|
||||
// folderCache is shared with the caller so each segment is only created
|
||||
// once per push, and so subsequent uploads under the same sub-tree reuse
|
||||
// the freshly minted folder token without an extra round trip.
|
||||
func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext, rootFolderToken, relDir string, folderCache map[string]string) (string, error) {
|
||||
if token, ok := folderCache[relDir]; ok {
|
||||
return token, nil
|
||||
}
|
||||
parentRel, name := drivePushSplitRel(relDir)
|
||||
parentToken, err := drivePushEnsureFolder(ctx, runtime, rootFolderToken, parentRel, folderCache)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/files/create_folder",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"name": name,
|
||||
"folder_token": parentToken,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := common.GetString(data, "token")
|
||||
if token == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "create_folder for %q returned no folder token", relDir)
|
||||
}
|
||||
folderCache[relDir] = token
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// drivePushUploadFile uploads (or overwrites) a single local file. When
|
||||
// existingToken is non-empty, the request adds the file_token form field to
|
||||
// trigger overwrite-with-version semantics on the backend; the response is
|
||||
// expected to carry a non-empty `version`, which is propagated to the
|
||||
// caller for the items[].version field. When existingToken is empty, this
|
||||
// is a fresh upload under parentToken.
|
||||
//
|
||||
// Files larger than common.MaxDriveMediaUploadSinglePartSize fall back to
|
||||
// the three-step prepare/part/finish flow, which mirrors drive +upload's
|
||||
// existing multipart logic.
|
||||
func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
|
||||
if file.Size > common.MaxDriveMediaUploadSinglePartSize {
|
||||
token, err := drivePushUploadMultipart(ctx, runtime, file, existingToken, parentToken)
|
||||
// Multipart finish does not return version on the existing
|
||||
// /open-apis/drive/v1/files/upload_finish contract; surface an
|
||||
// empty version in that case rather than fabricating one. The
|
||||
// markdown +overwrite path has the same gap and is tracked for a
|
||||
// follow-up once the multipart endpoint exposes the field.
|
||||
return token, "", err
|
||||
}
|
||||
return drivePushUploadAll(ctx, runtime, file, existingToken, parentToken)
|
||||
}
|
||||
|
||||
func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
|
||||
f, err := runtime.FileIO().Open(file.OpenPath)
|
||||
if err != nil {
|
||||
return "", "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", file.FileName)
|
||||
fd.AddField("parent_type", driveUploadParentTypeExplorer)
|
||||
fd.AddField("parent_node", parentToken)
|
||||
fd.AddField("size", fmt.Sprintf("%d", file.Size))
|
||||
if existingToken != "" {
|
||||
// Overwrite mode: the backend interprets a non-empty file_token on
|
||||
// upload_all as "replace this file's content and bump its version",
|
||||
// matching the markdown +overwrite contract.
|
||||
fd.AddField("file_token", existingToken)
|
||||
}
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", "", err
|
||||
}
|
||||
return "", "", output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
|
||||
}
|
||||
// Extract the token before the larkCode check: the backend can produce
|
||||
// a partial-success response (code != 0 alongside a non-empty
|
||||
// data.file_token) where bytes have already landed under that token.
|
||||
// Returning "" here would force the caller to fall back to
|
||||
// entry.FileToken and silently lose the token Drive actually used,
|
||||
// defeating the overwrite-error token-stability handling in Execute.
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
token := common.GetString(data, "file_token")
|
||||
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return token, "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
|
||||
}
|
||||
if token == "" {
|
||||
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
}
|
||||
version := common.GetString(data, "version")
|
||||
if version == "" {
|
||||
// Some backends return the version under data_version; accept either
|
||||
// per the markdown +overwrite contract.
|
||||
version = common.GetString(data, "data_version")
|
||||
}
|
||||
if existingToken != "" && version == "" {
|
||||
// The protocol guarantees a non-empty version on overwrite. If the
|
||||
// deployed backend hasn't shipped the field yet we surface the gap
|
||||
// rather than report a phantom success — callers can downgrade to
|
||||
// --if-exists=skip in the meantime.
|
||||
return token, "", output.Errorf(output.ExitAPI, "api_error", "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
|
||||
}
|
||||
return token, version, nil
|
||||
}
|
||||
|
||||
func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, error) {
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": file.FileName,
|
||||
"parent_type": driveUploadParentTypeExplorer,
|
||||
"parent_node": parentToken,
|
||||
"size": file.Size,
|
||||
}
|
||||
if existingToken != "" {
|
||||
prepareBody["file_token"] = existingToken
|
||||
}
|
||||
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
uploadID := common.GetString(prepareResult, "upload_id")
|
||||
blockSize := int64(common.GetFloat(prepareResult, "block_size"))
|
||||
blockNum := int(common.GetFloat(prepareResult, "block_num"))
|
||||
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error",
|
||||
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
|
||||
uploadID, blockSize, blockNum)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload: %s, block size %s, %d block(s)\n",
|
||||
common.FormatSize(file.Size), common.FormatSize(blockSize), blockNum)
|
||||
|
||||
// Open the local file ONCE for the whole multipart loop. fileio.File
|
||||
// implements io.ReaderAt, so each block is a fresh
|
||||
// io.NewSectionReader over a shared fd — no need to reopen N times
|
||||
// (which is what drive +upload's existing multipart helper does and
|
||||
// what the original drive_push copy inherited; that pattern wastes
|
||||
// one Open + Close + path-validation per block).
|
||||
partFile, err := runtime.FileIO().Open(file.OpenPath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer partFile.Close()
|
||||
|
||||
for seq := 0; seq < blockNum; seq++ {
|
||||
offset := int64(seq) * blockSize
|
||||
partSize := blockSize
|
||||
if remaining := file.Size - offset; partSize > remaining {
|
||||
partSize = remaining
|
||||
}
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", uploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", partSize))
|
||||
fd.AddFile("file", io.NewSectionReader(partFile, offset, partSize))
|
||||
|
||||
apiResp, doErr := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if doErr != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(doErr, &exitErr) {
|
||||
return "", doErr
|
||||
}
|
||||
return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, doErr)
|
||||
}
|
||||
|
||||
var partResult map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
|
||||
}
|
||||
if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 {
|
||||
msg, _ := partResult["msg"].(string)
|
||||
return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
|
||||
}
|
||||
|
||||
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": uploadID,
|
||||
"block_num": blockNum,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := common.GetString(finishResult, "file_token")
|
||||
if token == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// drivePushDeleteFile deletes a single Drive file (type=file). Folders are
|
||||
// never reached here because --delete-remote only iterates the type=file
|
||||
// subset of the remote listing.
|
||||
func drivePushDeleteFile(_ context.Context, runtime *common.RuntimeContext, fileToken string) error {
|
||||
_, err := runtime.CallAPI(
|
||||
"DELETE",
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(fileToken)),
|
||||
map[string]interface{}{"type": driveTypeFile},
|
||||
nil,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// drivePushParentRel returns the parent rel_path of rel ("" when the file
|
||||
// lives at the root). The local walker emits forward-slash rel_paths so
|
||||
// path.Dir is the right primitive here, not filepath.Dir.
|
||||
func drivePushParentRel(rel string) string {
|
||||
dir := path.Dir(rel)
|
||||
if dir == "." || dir == "/" {
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// drivePushSplitRel splits a non-empty rel into (parent, basename), both
|
||||
// using forward slashes.
|
||||
func drivePushSplitRel(rel string) (string, string) {
|
||||
idx := strings.LastIndex(rel, "/")
|
||||
if idx < 0 {
|
||||
return "", rel
|
||||
}
|
||||
return rel[:idx], rel[idx+1:]
|
||||
}
|
||||
1197
shortcuts/drive/drive_push_test.go
Normal file
1197
shortcuts/drive/drive_push_test.go
Normal file
File diff suppressed because it is too large
Load Diff
265
shortcuts/drive/drive_status.go
Normal file
265
shortcuts/drive/drive_status.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveStatusEntry struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
}
|
||||
|
||||
// DriveStatus walks --local-dir, recursively lists --folder-token, and reports
|
||||
// four buckets (new_local, new_remote, modified, unchanged) by SHA-256 hash.
|
||||
//
|
||||
// Only Drive entries with type=file are compared; online docs (docx, sheet,
|
||||
// bitable, mindnote, slides) and shortcuts are skipped because there is no
|
||||
// equivalent local binary to hash against.
|
||||
//
|
||||
// SafeInputPath (applied by runtime.FileIO()) rejects absolute paths and any
|
||||
// path that resolves outside cwd, which keeps the local side bounded to the
|
||||
// caller's working directory.
|
||||
var DriveStatus = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+status",
|
||||
Description: "Compare a local directory with a Drive folder by content hash",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "Drive folder token", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
||||
"Files present on both sides are downloaded and SHA-256 hashed in memory to decide modified vs unchanged; expect noticeable I/O on large folders.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
if localDir == "" {
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
// Path safety (absolute paths, traversal, symlink escape) is enforced
|
||||
// upfront by the framework helper so the error message references the
|
||||
// correct flag name; FileIO().Stat below would do the same check, but
|
||||
// surface --file in its hint.
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256.").
|
||||
GET("/open-apis/drive/v1/files").
|
||||
Set("folder_token", runtime.Str("folder-token"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before walking.
|
||||
// SafeInputPath fully evaluates symlinks across the entire path,
|
||||
// which closes the kernel-level escape route that filepath.Clean
|
||||
// alone misses: e.g. "link/.." string-cleans to "." but the kernel
|
||||
// resolves through link's target's parent, so a raw walk on the
|
||||
// user-supplied string can land outside cwd. Walking the canonical
|
||||
// root sidesteps that — and the matching cwd canonical lets each
|
||||
// absolute walk hit be converted to a cwd-relative path that
|
||||
// FileIO.Open's SafeInputPath check still accepts.
|
||||
//
|
||||
// Validate already ran SafeLocalFlagPath (with the proper flag
|
||||
// name in the error message), so a failure here is unexpected and
|
||||
// only possible under a Validate↔Execute race.
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return output.ErrValidation("could not resolve cwd: %s", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
|
||||
localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
|
||||
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// +status only diffs binary content, so collapse the unified
|
||||
// listing to type=file. Online docs / shortcuts have no
|
||||
// hashable bytes and are intentionally absent from the diff
|
||||
// view (a docx living next to a same-named local file is a
|
||||
// known no-op).
|
||||
remoteFiles := make(map[string]string, len(entries))
|
||||
for rel, entry := range entries {
|
||||
if entry.Type == driveTypeFile {
|
||||
remoteFiles[rel] = entry.FileToken
|
||||
}
|
||||
}
|
||||
|
||||
paths := mergeStatusPaths(localHashes, remoteFiles)
|
||||
|
||||
var newLocal, newRemote, modified, unchanged []driveStatusEntry
|
||||
for _, relPath := range paths {
|
||||
localHash, hasLocal := localHashes[relPath]
|
||||
remoteToken, hasRemote := remoteFiles[relPath]
|
||||
switch {
|
||||
case hasLocal && !hasRemote:
|
||||
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
|
||||
case !hasLocal && hasRemote:
|
||||
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken})
|
||||
default:
|
||||
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken}
|
||||
if localHash == remoteHash {
|
||||
unchanged = append(unchanged, entry)
|
||||
} else {
|
||||
modified = append(modified, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"new_local": emptyIfNil(newLocal),
|
||||
"new_remote": emptyIfNil(newRemote),
|
||||
"modified": emptyIfNil(modified),
|
||||
"unchanged": emptyIfNil(unchanged),
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// walkLocalForStatus walks the canonical absolute root produced by
|
||||
// SafeInputPath. Using the canonical root keeps the kernel from
|
||||
// following any symlink hidden inside the user-supplied --local-dir
|
||||
// (e.g. "link/..", which filepath.Clean shrinks to "." but which OS
|
||||
// path resolution would resolve through the symlink target). For each
|
||||
// hit, we report rel_path relative to root for the JSON output, and
|
||||
// convert the absolute path to a cwd-relative form so FileIO.Open's
|
||||
// SafeInputPath check (which rejects absolute paths) still applies.
|
||||
func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical string) (map[string]string, error) {
|
||||
files := make(map[string]string)
|
||||
// FileIO has no walker today and shortcuts can't import internal/vfs.
|
||||
// The walk root is the canonical absolute path returned by
|
||||
// validate.SafeInputPath, so it is no longer a symlink itself, and
|
||||
// WalkDir's default policy (do not follow child symlinks) keeps the
|
||||
// traversal inside that canonical subtree.
|
||||
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relToCwd, err := filepath.Rel(cwdCanonical, absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum, err := hashLocalForStatus(runtime, relToCwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[filepath.ToSlash(rel)] = sum
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
|
||||
f, err := runtime.FileIO().Open(path)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "io", "hash %s: %s", path, err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (string, error) {
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: "GET",
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return "", output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, resp.Body); err != nil {
|
||||
return "", output.ErrNetwork("hash remote %s: %s", common.MaskToken(fileToken), err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func mergeStatusPaths(local, remote map[string]string) []string {
|
||||
seen := make(map[string]struct{}, len(local)+len(remote))
|
||||
for p := range local {
|
||||
seen[p] = struct{}{}
|
||||
}
|
||||
for p := range remote {
|
||||
seen[p] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for p := range seen {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func emptyIfNil(s []driveStatusEntry) []driveStatusEntry {
|
||||
if s == nil {
|
||||
return []driveStatusEntry{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
498
shortcuts/drive/drive_status_test.go
Normal file
498
shortcuts/drive/drive_status_test.go
Normal file
@@ -0,0 +1,498 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestDriveStatusCategorizesByHash exercises the four-bucket classification
|
||||
// against a real walk of the temp dir and a mocked Drive listing.
|
||||
func TestDriveStatusCategorizesByHash(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
// Local layout:
|
||||
// local/a.txt — also on remote with different content → modified
|
||||
// local/b.txt — only local → new_local
|
||||
// local/sub/c.txt — also on remote with same content → unchanged
|
||||
// Remote-only:
|
||||
// d.txt → new_remote
|
||||
if err := os.MkdirAll("local/sub", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/a.txt", []byte("aaa"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a.txt: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/b.txt", []byte("bbb"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile b.txt: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/sub/c.txt", []byte("ccc"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile sub/c.txt: %v", err)
|
||||
}
|
||||
|
||||
// Root folder list — order matters: stubs match in registration order.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
|
||||
map[string]interface{}{"token": "tok_sub", "name": "sub", "type": "folder"},
|
||||
map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file"},
|
||||
// noise: an online doc and a shortcut should be ignored
|
||||
map[string]interface{}{"token": "tok_doc", "name": "ignored.docx", "type": "docx"},
|
||||
map[string]interface{}{"token": "tok_sc", "name": "ignored.lnk", "type": "shortcut"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Subfolder list
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=tok_sub",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_c", "name": "c.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Download a.txt: remote content differs from local "aaa" → modified.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_a/download",
|
||||
Status: 200,
|
||||
Body: []byte("AAA"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
// Download c.txt: remote content matches local "ccc" → unchanged.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_c/download",
|
||||
Status: 200,
|
||||
Body: []byte("ccc"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
checks := []struct {
|
||||
bucket string
|
||||
path string
|
||||
token string
|
||||
}{
|
||||
{"new_local", "b.txt", ""},
|
||||
{"new_remote", "d.txt", "tok_d"},
|
||||
{"modified", "a.txt", "tok_a"},
|
||||
{"unchanged", "sub/c.txt", "tok_c"},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(out, `"`+c.bucket+`":`) {
|
||||
t.Errorf("output missing bucket %q\noutput: %s", c.bucket, out)
|
||||
}
|
||||
if !strings.Contains(out, `"rel_path": "`+c.path+`"`) {
|
||||
t.Errorf("output missing rel_path %q (expected in %s)\noutput: %s", c.path, c.bucket, out)
|
||||
}
|
||||
if c.token != "" && !strings.Contains(out, `"file_token": "`+c.token+`"`) {
|
||||
t.Errorf("output missing file_token %q (expected in %s)\noutput: %s", c.token, c.bucket, out)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(out, "ignored.docx") || strings.Contains(out, "ignored.lnk") {
|
||||
t.Errorf("output should skip docx/shortcut entries\noutput: %s", out)
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
// TestDriveStatusPaginatesRemoteListing pins multi-page handling end-to-end
|
||||
// AND the dual-field tolerance of common.PaginationMeta. Page 1 surfaces
|
||||
// `next_page_token` (Drive's historical name); page 2 surfaces `page_token`
|
||||
// (what the shared helper also accepts). If the shortcut had hard-coded
|
||||
// either field name, one of the two pages' files would be silently dropped
|
||||
// from the comparison and would land in the wrong bucket. Stub order is
|
||||
// significant: httpmock matches in registration order, and both stubs key on
|
||||
// the GET .../files URL — they pop in turn, so page 1's response (with the
|
||||
// continuation token) must be registered before page 2's terminator.
|
||||
func TestDriveStatusPaginatesRemoteListing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
// Page 1: returns one file plus a continuation token via
|
||||
// next_page_token (the field Drive currently emits).
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_p1", "name": "page1.txt", "type": "file"},
|
||||
},
|
||||
"has_more": true,
|
||||
"next_page_token": "cursor-page-2",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Page 2: returns the second file with has_more=false. This stub uses
|
||||
// page_token (the alternate spelling) to lock in that the shared
|
||||
// PaginationMeta helper accepts BOTH field names.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_p2", "name": "page2.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// Both pages contributed to new_remote (local is empty).
|
||||
for _, want := range []string{
|
||||
`"rel_path": "page1.txt"`,
|
||||
`"file_token": "tok_p1"`,
|
||||
`"rel_path": "page2.txt"`,
|
||||
`"file_token": "tok_p2"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q (a page must have been silently dropped)\noutput: %s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDriveStatusRejectsMissingLocalDir(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "does-not-exist",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing local dir, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveStatusRejectsLocalFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("not-a-dir.txt", []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "not-a-dir.txt",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error when --local-dir is a file, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a directory") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveStatusRejectsAbsoluteLocalDir(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "/etc",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for absolute --local-dir, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusRejectsEmptyFolderToken covers the Validate-stage required
|
||||
// check that runs before ResourceName: an empty --folder-token must surface
|
||||
// a structured FlagError referencing the flag name.
|
||||
func TestDriveStatusRejectsEmptyFolderToken(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty --folder-token, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--folder-token") {
|
||||
t.Fatalf("error must reference --folder-token, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusDoesNotEscapeViaSymlinkParentRef is the regression for the
|
||||
// "link/.." escape: filepath.Clean string-shrinks "link/.." to ".", so a
|
||||
// raw walk on the user-supplied input can land on the kernel-resolved
|
||||
// path through link's target's parent — outside cwd. The fix is to walk
|
||||
// SafeInputPath's canonical absolute root instead of the raw input.
|
||||
//
|
||||
// Setup: an "escape" sibling directory contains a sentinel file; cwd
|
||||
// contains a "link" symlink pointing into that escape directory.
|
||||
// Calling +status with --local-dir "link/.." must not surface the
|
||||
// sentinel — the walk must stay inside cwd.
|
||||
func TestDriveStatusDoesNotEscapeViaSymlinkParentRef(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
// Sentinel lives outside cwd; the agent must never see it.
|
||||
escapeDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(escapeDir, "secret.txt"), []byte("S3CRET"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile secret: %v", err)
|
||||
}
|
||||
|
||||
// cwd has a symlink that points into the sentinel's parent.
|
||||
cwdDir := t.TempDir()
|
||||
withDriveWorkingDir(t, cwdDir)
|
||||
if err := os.Symlink(escapeDir, filepath.Join(cwdDir, "link")); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
// A normal file inside cwd just to make the walk non-trivial.
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "ok.txt"), []byte("ok"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile ok: %v", err)
|
||||
}
|
||||
|
||||
// Empty remote folder so any path that surfaces in the output
|
||||
// must have come from the local walk.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "link/..",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "secret.txt") || strings.Contains(out, "S3CRET") {
|
||||
t.Fatalf("walk escaped via link/..: secret.txt leaked into output\noutput:\n%s", out)
|
||||
}
|
||||
// ok.txt is in cwd and must classify as new_local (no remote stub for it).
|
||||
if !strings.Contains(out, `"rel_path": "ok.txt"`) {
|
||||
t.Fatalf("expected ok.txt in new_local, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusSkipsSymlinkInsideRoot pins down WalkDir's default policy
|
||||
// for symlinks discovered as child entries: they are reported with a
|
||||
// non-regular file mode and the callback skips them, so a symlink inside
|
||||
// the validated root pointing into an out-of-tree directory cannot leak
|
||||
// the target's contents.
|
||||
func TestDriveStatusSkipsSymlinkInsideRoot(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
// Sentinel sits outside cwd; a child symlink inside the walked root
|
||||
// points there. If the walker followed child symlinks (it must not),
|
||||
// the sentinel's name would surface in new_local.
|
||||
escapeDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(escapeDir, "secret.txt"), []byte("S3CRET"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile secret: %v", err)
|
||||
}
|
||||
|
||||
cwdDir := t.TempDir()
|
||||
withDriveWorkingDir(t, cwdDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "ok.txt"), []byte("ok"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile ok: %v", err)
|
||||
}
|
||||
// Child-of-root symlink that resolves out of the validated subtree.
|
||||
if err := os.Symlink(escapeDir, filepath.Join("local", "sub", "escape")); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "secret.txt") || strings.Contains(out, "S3CRET") {
|
||||
t.Fatalf("walk followed child symlink and leaked sentinel:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"rel_path": "ok.txt"`) {
|
||||
t.Fatalf("expected ok.txt in new_local; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusSurvivesCircularSymlinkInsideRoot makes sure WalkDir
|
||||
// terminates even when a child symlink points back at one of its
|
||||
// ancestors. WalkDir's default policy already declines to follow child
|
||||
// symlinks; this test pins that contract for our caller.
|
||||
func TestDriveStatusSurvivesCircularSymlinkInsideRoot(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
cwdDir := t.TempDir()
|
||||
withDriveWorkingDir(t, cwdDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "sub", "real.txt"), []byte("real"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
// loop symlink: cwd/local/sub/loop -> cwd/local (an ancestor).
|
||||
loopTarget, err := filepath.Abs(filepath.Join("local"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs: %v", err)
|
||||
}
|
||||
if err := os.Symlink(loopTarget, filepath.Join("local", "sub", "loop")); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// If WalkDir followed the loop, this test would never finish; the
|
||||
// test runner's per-test timeout would surface that as a failure.
|
||||
err = mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"rel_path": "sub/real.txt"`) {
|
||||
t.Fatalf("expected sub/real.txt in new_local; got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveStatusRejectsMalformedFolderToken covers the ResourceName format
|
||||
// guard: a token with control characters (newline) must be rejected before
|
||||
// any API call is made.
|
||||
func TestDriveStatusRejectsMalformedFolderToken(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "tok\nwithnewline",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for malformed --folder-token, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--folder-token") {
|
||||
t.Fatalf("error must reference --folder-token, got: %v", err)
|
||||
}
|
||||
}
|
||||
116
shortcuts/drive/list_remote.go
Normal file
116
shortcuts/drive/list_remote.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
driveListRemotePageSize = 200
|
||||
driveTypeFile = "file"
|
||||
driveTypeFolder = "folder"
|
||||
)
|
||||
|
||||
// driveRemoteEntry is one Drive entry returned by listRemoteFolder. It
|
||||
// carries enough metadata for every shortcut that consumes the listing
|
||||
// to build its own per-shortcut view by filtering on Type.
|
||||
type driveRemoteEntry struct {
|
||||
// FileToken is the Drive token for this entry. For type=folder this
|
||||
// is the folder_token; for everything else it is the file_token.
|
||||
FileToken string
|
||||
// Type is the Drive entry kind verbatim from the API:
|
||||
// "file" | "folder" | "docx" | "doc" | "sheet" | "bitable" |
|
||||
// "mindnote" | "slides" | "shortcut" | …
|
||||
Type string
|
||||
// RelPath is the entry's path relative to the listing root. Encoded
|
||||
// with "/" separators on every platform so it matches the rel_paths
|
||||
// produced by the shortcuts' local walkers.
|
||||
RelPath string
|
||||
}
|
||||
|
||||
// listRemoteFolder recursively lists folderToken under relBase and
|
||||
// returns one entry per Drive item, keyed by rel_path. Subfolders are
|
||||
// descended into and the folder's own entry is also recorded — callers
|
||||
// can reason about "this rel_path is occupied by a folder" without
|
||||
// re-listing.
|
||||
//
|
||||
// This is the shared backbone for the three sync-disk shortcuts. None
|
||||
// of them need every field at every call site, so each one filters
|
||||
// on Type:
|
||||
//
|
||||
// - +status (drive_status.go) keeps Type=="file" and uses FileToken
|
||||
// to drive content-hash diffs against the local tree.
|
||||
// - +pull (drive_pull.go) keeps Type=="file" + FileToken for the
|
||||
// download set, and the full key set (every rel_path) as the
|
||||
// guard for --delete-local.
|
||||
// - +push (drive_push.go) keeps Type=="file" + FileToken for upload /
|
||||
// overwrite / orphan-delete decisions, and Type=="folder" + FileToken
|
||||
// for the create_folder cache.
|
||||
//
|
||||
// Pagination uses common.PaginationMeta, which accepts both
|
||||
// page_token and next_page_token — the Drive list endpoint has
|
||||
// historically returned the latter, but the helper future-proofs
|
||||
// against a backend rename.
|
||||
func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]driveRemoteEntry, error) {
|
||||
out := make(map[string]driveRemoteEntry)
|
||||
pageToken := ""
|
||||
for {
|
||||
params := map[string]interface{}{
|
||||
"folder_token": folderToken,
|
||||
"page_size": fmt.Sprint(driveListRemotePageSize),
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawFiles, _ := result["files"].([]interface{})
|
||||
for _, item := range rawFiles {
|
||||
f, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fType := common.GetString(f, "type")
|
||||
fName := common.GetString(f, "name")
|
||||
fToken := common.GetString(f, "token")
|
||||
if fName == "" || fToken == "" {
|
||||
continue
|
||||
}
|
||||
rel := joinRelDrive(relBase, fName)
|
||||
out[rel] = driveRemoteEntry{FileToken: fToken, Type: fType, RelPath: rel}
|
||||
if fType == driveTypeFolder {
|
||||
sub, err := listRemoteFolder(ctx, runtime, fToken, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range sub {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
hasMore, nextToken := common.PaginationMeta(result)
|
||||
if !hasMore || nextToken == "" {
|
||||
break
|
||||
}
|
||||
pageToken = nextToken
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// joinRelDrive joins a rel_path base with an entry name using "/".
|
||||
// Empty base means the entry sits at the listing root. Mirrors the
|
||||
// behavior the per-shortcut helpers used to ship and keeps rel_paths
|
||||
// stable across +status / +pull / +push.
|
||||
func joinRelDrive(base, name string) string {
|
||||
if base == "" {
|
||||
return name
|
||||
}
|
||||
return base + "/" + name
|
||||
}
|
||||
@@ -18,6 +18,9 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveImport,
|
||||
DriveMove,
|
||||
DriveDelete,
|
||||
DriveStatus,
|
||||
DrivePush,
|
||||
DrivePull,
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveSearch,
|
||||
|
||||
@@ -21,6 +21,9 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+import",
|
||||
"+move",
|
||||
"+delete",
|
||||
"+status",
|
||||
"+push",
|
||||
"+pull",
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+search",
|
||||
|
||||
533
shortcuts/markdown/helpers.go
Normal file
533
shortcuts/markdown/helpers.go
Normal file
@@ -0,0 +1,533 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const markdownSinglePartSizeLimit = common.MaxDriveMediaUploadSinglePartSize
|
||||
const markdownEmptyContentError = "empty markdown content is not supported; cannot create or overwrite an empty file"
|
||||
|
||||
type markdownUploadSpec struct {
|
||||
FileToken string
|
||||
FileName string
|
||||
FolderToken string
|
||||
FilePath string
|
||||
Content string
|
||||
ContentSet bool
|
||||
FileSet bool
|
||||
}
|
||||
|
||||
type markdownUploadResult struct {
|
||||
FileToken string
|
||||
Version string
|
||||
}
|
||||
|
||||
type markdownMultipartSession struct {
|
||||
UploadID string
|
||||
BlockSize int64
|
||||
BlockNum int
|
||||
}
|
||||
|
||||
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
|
||||
switch {
|
||||
case spec.ContentSet && spec.FileSet:
|
||||
return common.FlagErrorf("--content and --file are mutually exclusive")
|
||||
case !spec.ContentSet && !spec.FileSet:
|
||||
return common.FlagErrorf("specify exactly one of --content or --file")
|
||||
}
|
||||
|
||||
if runtime.Changed("folder-token") && strings.TrimSpace(spec.FolderToken) == "" {
|
||||
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
|
||||
}
|
||||
if spec.FolderToken != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if requireName && spec.ContentSet {
|
||||
if strings.TrimSpace(spec.FileName) == "" {
|
||||
return common.FlagErrorf("--name is required when using --content")
|
||||
}
|
||||
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if spec.FileSet {
|
||||
if strings.TrimSpace(spec.FilePath) == "" {
|
||||
return common.FlagErrorf("--file cannot be empty")
|
||||
}
|
||||
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
|
||||
return output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
if err := validateMarkdownFileName(filepath.Base(spec.FilePath), "--file"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if spec.FileName != "" {
|
||||
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMarkdownFileName(name, flagName string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return common.FlagErrorf("%s cannot be empty", flagName)
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(trimmed), ".md") {
|
||||
return common.FlagErrorf("%s must end with .md", flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func finalMarkdownFileName(spec markdownUploadSpec) string {
|
||||
if strings.TrimSpace(spec.FileName) != "" {
|
||||
return strings.TrimSpace(spec.FileName)
|
||||
}
|
||||
if strings.TrimSpace(spec.FilePath) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(spec.FilePath)
|
||||
}
|
||||
|
||||
func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec) (int64, error) {
|
||||
var size int64
|
||||
if spec.ContentSet {
|
||||
size = int64(len(spec.Content))
|
||||
} else {
|
||||
if strings.TrimSpace(spec.FilePath) == "" {
|
||||
return 0, common.FlagErrorf("--file cannot be empty")
|
||||
}
|
||||
|
||||
info, err := runtime.FileIO().Stat(spec.FilePath)
|
||||
if err != nil {
|
||||
return 0, common.WrapInputStatError(err)
|
||||
}
|
||||
size = info.Size()
|
||||
}
|
||||
if size == 0 {
|
||||
return 0, output.ErrValidation("%s", markdownEmptyContentError)
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func markdownDryRunFileField(spec markdownUploadSpec) string {
|
||||
if spec.FilePath != "" {
|
||||
return "@" + spec.FilePath
|
||||
}
|
||||
return "<markdown content>"
|
||||
}
|
||||
|
||||
func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
|
||||
if !multipart {
|
||||
body := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
"file": markdownDryRunFileField(spec),
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
body["file_token"] = spec.FileToken
|
||||
}
|
||||
|
||||
desc := "multipart/form-data upload"
|
||||
if spec.FileToken != "" {
|
||||
desc = "multipart/form-data overwrite upload"
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/upload_all").
|
||||
Body(body)
|
||||
}
|
||||
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
prepareBody["file_token"] = spec.FileToken
|
||||
}
|
||||
|
||||
desc := "3-step multipart upload"
|
||||
if spec.FileToken != "" {
|
||||
desc = "3-step multipart overwrite upload"
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/upload_prepare").
|
||||
Desc("[1] Initialize multipart upload").
|
||||
Body(prepareBody).
|
||||
POST("/open-apis/drive/v1/files/upload_part").
|
||||
Desc("[2] Upload file parts (repeated)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/files/upload_finish").
|
||||
Desc("[3] Finalize upload and get file_token/version").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
}
|
||||
|
||||
func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
|
||||
fileName := strings.TrimSpace(spec.FileName)
|
||||
if fileName == "" && spec.FileSet {
|
||||
fileName = finalMarkdownFileName(spec)
|
||||
}
|
||||
if fileName != "" {
|
||||
spec.FileName = fileName
|
||||
return markdownUploadDryRun(spec, fileSize, multipart)
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI().Desc("Fetch the existing file name, then overwrite the file content")
|
||||
dry.POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("[1] Read current file metadata to preserve the existing file name").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": spec.FileToken,
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
spec.FileName = "<existing_remote_name_or_" + spec.FileToken + ".md>"
|
||||
if !multipart {
|
||||
dry.POST("/open-apis/drive/v1/files/upload_all").
|
||||
Desc("[2] Overwrite file contents with multipart/form-data upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.FileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
"file": markdownDryRunFileField(spec),
|
||||
"file_token": spec.FileToken,
|
||||
})
|
||||
return dry
|
||||
}
|
||||
|
||||
dry.POST("/open-apis/drive/v1/files/upload_prepare").
|
||||
Desc("[2] Initialize multipart overwrite upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.FileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
"file_token": spec.FileToken,
|
||||
}).
|
||||
POST("/open-apis/drive/v1/files/upload_part").
|
||||
Desc("[3] Upload file parts (repeated)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/files/upload_finish").
|
||||
Desc("[4] Finalize upload and get file_token/version").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
return dry
|
||||
}
|
||||
|
||||
func uploadMarkdownContent(runtime *common.RuntimeContext, spec markdownUploadSpec, payload []byte) (markdownUploadResult, error) {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
fileSize := int64(len(payload))
|
||||
if fileSize > markdownSinglePartSizeLimit {
|
||||
return uploadMarkdownFileMultipart(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
|
||||
}
|
||||
return uploadMarkdownFileAll(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
|
||||
}
|
||||
|
||||
func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUploadSpec, fileSize int64) (markdownUploadResult, error) {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
f, err := runtime.FileIO().Open(spec.FilePath)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if fileSize > markdownSinglePartSizeLimit {
|
||||
return uploadMarkdownFileMultipart(runtime, spec, f, fileName, fileSize)
|
||||
}
|
||||
return uploadMarkdownFileAll(runtime, spec, f, fileName, fileSize)
|
||||
}
|
||||
|
||||
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", "explorer")
|
||||
fd.AddField("parent_node", spec.FolderToken)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
if spec.FileToken != "" {
|
||||
fd.AddField("file_token", spec.FileToken)
|
||||
}
|
||||
fd.AddFile("file", fileReader)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
return markdownUploadResult{}, output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
|
||||
data, err := common.ParseDriveMediaUploadResponse(apiResp, "upload failed")
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
return parseMarkdownUploadResult(data, spec.FileToken != "")
|
||||
}
|
||||
|
||||
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
prepareBody["file_token"] = spec.FileToken
|
||||
}
|
||||
|
||||
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
session, err := parseMarkdownMultipartSession(prepareResult)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, common.FormatSize(session.BlockSize))
|
||||
|
||||
if err := uploadMarkdownMultipartParts(runtime, fileReader, fileSize, session); err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": session.UploadID,
|
||||
"block_num": session.BlockNum,
|
||||
})
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
return parseMarkdownUploadResult(finishResult, spec.FileToken != "")
|
||||
}
|
||||
|
||||
func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipartSession, error) {
|
||||
session := markdownMultipartSession{
|
||||
UploadID: common.GetString(data, "upload_id"),
|
||||
BlockSize: int64(common.GetFloat(data, "block_size")),
|
||||
BlockNum: int(common.GetFloat(data, "block_num")),
|
||||
}
|
||||
if session.UploadID == "" || session.BlockSize <= 0 || session.BlockNum <= 0 {
|
||||
return markdownMultipartSession{}, output.Errorf(output.ExitAPI, "api_error",
|
||||
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
|
||||
session.UploadID, session.BlockSize, session.BlockNum)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.Reader, payloadSize int64, session markdownMultipartSession) error {
|
||||
expectedBlocks := int((payloadSize + session.BlockSize - 1) / session.BlockSize)
|
||||
if session.BlockNum != expectedBlocks {
|
||||
return output.Errorf(
|
||||
output.ExitAPI,
|
||||
"api_error",
|
||||
"upload_prepare returned inconsistent chunk plan: block_size=%d, block_num=%d, expected_block_num=%d, payload_size=%d",
|
||||
session.BlockSize,
|
||||
session.BlockNum,
|
||||
expectedBlocks,
|
||||
payloadSize,
|
||||
)
|
||||
}
|
||||
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
if session.BlockSize > maxInt {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
|
||||
buffer := make([]byte, int(session.BlockSize))
|
||||
remaining := payloadSize
|
||||
|
||||
for seq := 0; seq < session.BlockNum; seq++ {
|
||||
chunkSize := session.BlockSize
|
||||
if remaining > 0 && chunkSize > remaining {
|
||||
chunkSize = remaining
|
||||
}
|
||||
|
||||
n, readErr := io.ReadFull(fileReader, buffer[:int(chunkSize)])
|
||||
if readErr != nil {
|
||||
return output.ErrValidation("cannot read file: %s", readErr)
|
||||
}
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", session.UploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", n))
|
||||
fd.AddFile("file", bytes.NewReader(buffer[:n]))
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("upload part %d/%d failed: %v", seq+1, session.BlockNum, err)
|
||||
}
|
||||
|
||||
if _, err := common.ParseDriveMediaUploadResponse(apiResp, fmt.Sprintf("upload part %d/%d failed", seq+1, session.BlockNum)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, common.FormatSize(int64(n)))
|
||||
remaining -= int64(n)
|
||||
}
|
||||
if remaining != 0 {
|
||||
return output.Errorf(
|
||||
output.ExitAPI,
|
||||
"api_error",
|
||||
"upload_prepare returned inconsistent chunk plan: %d bytes remain after %d blocks",
|
||||
remaining,
|
||||
session.BlockNum,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMarkdownUploadResult(data map[string]interface{}, requireVersion bool) (markdownUploadResult, error) {
|
||||
result := markdownUploadResult{
|
||||
FileToken: common.GetString(data, "file_token"),
|
||||
Version: common.GetString(data, "version"),
|
||||
}
|
||||
if result.Version == "" {
|
||||
result.Version = common.GetString(data, "data_version")
|
||||
}
|
||||
if result.FileToken == "" {
|
||||
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
}
|
||||
if requireVersion && result.Version == "" {
|
||||
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "overwrite failed: no version returned")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": fileToken,
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
metas := common.GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
meta, _ := metas[0].(map[string]interface{})
|
||||
return common.GetString(meta, "title"), nil
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownWrite(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
|
||||
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
|
||||
if url := common.GetString(data, "url"); url != "" {
|
||||
fmt.Fprintf(w, "url: %s\n", url)
|
||||
}
|
||||
version := common.GetString(data, "version")
|
||||
if version == "" {
|
||||
version = common.GetString(data, "data_version")
|
||||
}
|
||||
if version != "" {
|
||||
fmt.Fprintf(w, "version: %s\n", version)
|
||||
}
|
||||
fmt.Fprintf(w, "size_bytes: %d\n", int64(common.GetFloat(data, "size_bytes")))
|
||||
if grant := common.GetMap(data, "permission_grant"); grant != nil {
|
||||
fmt.Fprintf(w, "permission_grant.status: %s\n", common.GetString(grant, "status"))
|
||||
fmt.Fprintf(w, "permission_grant.perm: %s\n", common.GetString(grant, "perm"))
|
||||
}
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownSavedFile(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
|
||||
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
|
||||
fmt.Fprintf(w, "saved_path: %s\n", common.GetString(data, "saved_path"))
|
||||
fmt.Fprintf(w, "size_bytes: %d\n", int64(common.GetFloat(data, "size_bytes")))
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownContent(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprint(w, common.GetString(data, "content"))
|
||||
}
|
||||
|
||||
func fileNameFromDownloadHeader(header http.Header, fallback string) string {
|
||||
name := fallback
|
||||
if header != nil {
|
||||
if headerName := larkcore.FileNameByHeader(header); strings.TrimSpace(headerName) != "" {
|
||||
name = headerName
|
||||
}
|
||||
}
|
||||
name = strings.ReplaceAll(strings.TrimSpace(name), "\\", "/")
|
||||
name = path.Base(name)
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return fallback
|
||||
}
|
||||
return name
|
||||
}
|
||||
94
shortcuts/markdown/markdown_create.go
Normal file
94
shortcuts/markdown/markdown_create.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var MarkdownCreate = common.Shortcut{
|
||||
Service: "markdown",
|
||||
Command: "+create",
|
||||
Description: "Create a Markdown file in Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:file:upload"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "folder-token", Desc: "target Drive folder token (default: root folder)"},
|
||||
{Name: "name", Desc: "file name with .md suffix; required with --content, optional with --file"},
|
||||
{Name: "content", Desc: "Markdown content", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "file", Desc: "local .md file path"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}, true)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return markdownUploadDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result markdownUploadResult
|
||||
if spec.FileSet {
|
||||
result, err = uploadMarkdownLocalFile(runtime, spec, fileSize)
|
||||
} else {
|
||||
result, err = uploadMarkdownContent(runtime, spec, []byte(spec.Content))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"file_token": result.FileToken,
|
||||
"file_name": finalMarkdownFileName(spec),
|
||||
"size_bytes": fileSize,
|
||||
}
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "file", result.FileToken); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, result.FileToken, "file"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownWrite(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
131
shortcuts/markdown/markdown_fetch.go
Normal file
131
shortcuts/markdown/markdown_fetch.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var MarkdownFetch = common.Shortcut{
|
||||
Service: "markdown",
|
||||
Command: "+fetch",
|
||||
Description: "Fetch a Markdown file from Drive",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "Markdown file token", Required: true},
|
||||
{Name: "output", Desc: "local save path or directory; omit to return content directly"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing local output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
if outputPath == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := validate.SafeOutputPath(outputPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("download markdown file bytes; when --output is omitted the CLI returns content as UTF-8 text").
|
||||
GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Set("file_token", runtime.Str("file-token"))
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
dry.Set("output", outputPath)
|
||||
} else {
|
||||
dry.Set("output", "<stdout>")
|
||||
}
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
fileName := fileNameFromDownloadHeader(resp.Header, fileToken+".md")
|
||||
if outputPath == "" {
|
||||
payload, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": fileName,
|
||||
"content": string(payload),
|
||||
"size_bytes": len(payload),
|
||||
}
|
||||
runtime.OutFormatRaw(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownContent(w, out)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
if markdownFetchOutputIsDirectory(runtime, outputPath) {
|
||||
outputPath = filepath.Join(outputPath, fileName)
|
||||
}
|
||||
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !runtime.Bool("overwrite") {
|
||||
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
|
||||
savedPath, _ := runtime.ResolveSavePath(outputPath)
|
||||
if savedPath == "" {
|
||||
savedPath = outputPath
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": fileName,
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": result.Size(),
|
||||
}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownSavedFile(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func markdownFetchOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool {
|
||||
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") {
|
||||
return true
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(outputPath)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
113
shortcuts/markdown/markdown_overwrite.go
Normal file
113
shortcuts/markdown/markdown_overwrite.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var MarkdownOverwrite = common.Shortcut{
|
||||
Service: "markdown",
|
||||
Command: "+overwrite",
|
||||
Description: "Overwrite an existing Markdown file in Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "target Markdown file token", Required: true},
|
||||
{Name: "name", Desc: "optional file name with .md suffix; overrides the existing/local file name"},
|
||||
{Name: "content", Desc: "new Markdown content", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "file", Desc: "local .md file path"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileToken: fileToken,
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}, false)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := markdownUploadSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return markdownOverwriteDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
spec := markdownUploadSpec{
|
||||
FileToken: fileToken,
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(spec.FileName)
|
||||
if fileName == "" && spec.FileSet {
|
||||
fileName = filepath.Base(spec.FilePath)
|
||||
}
|
||||
if fileName == "" {
|
||||
remoteName, err := fetchMarkdownFileName(runtime, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileName = strings.TrimSpace(remoteName)
|
||||
}
|
||||
if fileName == "" {
|
||||
fileName = fileToken + ".md"
|
||||
}
|
||||
spec.FileName = fileName
|
||||
|
||||
var result markdownUploadResult
|
||||
if spec.FileSet {
|
||||
result, err = uploadMarkdownLocalFile(runtime, spec, fileSize)
|
||||
} else {
|
||||
result, err = uploadMarkdownContent(runtime, spec, []byte(spec.Content))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"file_token": result.FileToken,
|
||||
"file_name": fileName,
|
||||
"version": result.Version,
|
||||
"size_bytes": fileSize,
|
||||
}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownWrite(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
1350
shortcuts/markdown/markdown_test.go
Normal file
1350
shortcuts/markdown/markdown_test.go
Normal file
File diff suppressed because it is too large
Load Diff
15
shortcuts/markdown/shortcuts.go
Normal file
15
shortcuts/markdown/shortcuts.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all markdown shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MarkdownCreate,
|
||||
MarkdownFetch,
|
||||
MarkdownOverwrite,
|
||||
}
|
||||
}
|
||||
72
shortcuts/minutes/minutes_upload.go
Normal file
72
shortcuts/minutes/minutes_upload.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
minutesUploadSupportedFormatsTip = "Supported audio formats: wav, mp3, m4a, aac, ogg, wma, amr; supported video formats: avi, wmv, mov, mp4, m4v, mpeg, ogg, flv."
|
||||
minutesUploadLimitsTip = "The original uploaded media must be no larger than 6GB and no longer than 6 hours."
|
||||
)
|
||||
|
||||
// MinutesUpload uploads a media file token to generate a minute.
|
||||
var MinutesUpload = common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+upload",
|
||||
Description: "Upload a media file token to generate a minute",
|
||||
Risk: "write",
|
||||
Scopes: []string{"minutes:minutes.upload:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "file_token of a supported audio/video file already uploaded to Drive", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"This shortcut only accepts --file-token. Upload the local media file to Drive first with `lark-cli drive +upload`.",
|
||||
minutesUploadSupportedFormatsTip,
|
||||
minutesUploadLimitsTip,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
if fileToken == "" {
|
||||
return output.ErrValidation("--file-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/minutes/v1/minutes/upload").
|
||||
Body(map[string]interface{}{"file_token": runtime.Str("file-token")})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
|
||||
body := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/minutes/v1/minutes/upload", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
minuteURL := common.GetString(data, "minute_url")
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"minute_url": minuteURL,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
119
shortcuts/minutes/minutes_upload_test.go
Normal file
119
shortcuts/minutes/minutes_upload_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestMinutesUpload_Validate(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing file token",
|
||||
args: []string{"+upload", "--as", "user"},
|
||||
wantErr: "required flag(s) \"file-token\" not set",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesUpload.Mount(parent, f)
|
||||
parent.SetArgs(tt.args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
err := parent.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpload_HelpMetadata(t *testing.T) {
|
||||
if len(MinutesUpload.Flags) == 0 {
|
||||
t.Fatal("expected file-token flag metadata")
|
||||
}
|
||||
if got := MinutesUpload.Flags[0].Desc; !strings.Contains(got, "supported audio/video file") {
|
||||
t.Fatalf("file-token description = %q, want supported media guidance", got)
|
||||
}
|
||||
|
||||
joinedTips := strings.Join(MinutesUpload.Tips, "\n")
|
||||
for _, want := range []string{
|
||||
"drive +upload",
|
||||
"wav, mp3, m4a, aac, ogg, wma, amr",
|
||||
"avi, wmv, mov, mp4, m4v, mpeg, ogg, flv",
|
||||
"6GB",
|
||||
"6 hours",
|
||||
} {
|
||||
if !strings.Contains(joinedTips, want) {
|
||||
t.Fatalf("tips should contain %q, got:\n%s", want, joinedTips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpload_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
err := mountAndRun(t, MinutesUpload, []string{"+upload", "--file-token", "boxcn123456", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "POST") || !strings.Contains(out, "/open-apis/minutes/v1/minutes/upload") {
|
||||
t.Errorf("expected POST /open-apis/minutes/v1/minutes/upload, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "boxcn123456") {
|
||||
t.Errorf("expected file token in body, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpload_Execute(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodPost,
|
||||
URL: "/open-apis/minutes/v1/minutes/upload",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"minute_url": "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesUpload, []string{"+upload", "--file-token", "boxcn123456", "--format", "json", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var res map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
|
||||
dataMap, _ := res["data"].(map[string]interface{})
|
||||
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
|
||||
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,6 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MinutesSearch,
|
||||
MinutesDownload,
|
||||
MinutesUpload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/event"
|
||||
"github.com/larksuite/cli/shortcuts/im"
|
||||
"github.com/larksuite/cli/shortcuts/mail"
|
||||
"github.com/larksuite/cli/shortcuts/markdown"
|
||||
"github.com/larksuite/cli/shortcuts/minutes"
|
||||
"github.com/larksuite/cli/shortcuts/sheets"
|
||||
"github.com/larksuite/cli/shortcuts/slides"
|
||||
@@ -42,6 +43,7 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, base.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, event.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, task.Shortcuts()...)
|
||||
@@ -90,6 +92,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
}
|
||||
program.AddCommand(svc)
|
||||
}
|
||||
if service == "docs" {
|
||||
doc.ConfigureServiceHelp(svc)
|
||||
}
|
||||
|
||||
for _, shortcut := range shortcuts {
|
||||
shortcut.MountWithContext(ctx, svc, f)
|
||||
|
||||
30
shortcuts/register_markdown_test.go
Normal file
30
shortcuts/register_markdown_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, &cmdutil.Factory{})
|
||||
|
||||
for _, path := range [][]string{
|
||||
{"markdown", "+create"},
|
||||
{"markdown", "+fetch"},
|
||||
{"markdown", "+overwrite"},
|
||||
} {
|
||||
cmd, _, err := program.Find(path)
|
||||
if err != nil {
|
||||
t.Fatalf("find markdown shortcut %v: %v", path, err)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != path[1] {
|
||||
t.Fatalf("markdown shortcut not mounted: %#v", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,45 @@
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newRegisterTestFactory(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{})
|
||||
return f
|
||||
}
|
||||
|
||||
func newRegisterTestProgramWithTipsHelp() *cobra.Command {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
defaultHelp := program.HelpFunc()
|
||||
program.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
defaultHelp(cmd, args)
|
||||
tips := cmdutil.GetTips(cmd)
|
||||
if len(tips) == 0 {
|
||||
return
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Tips:")
|
||||
for _, tip := range tips {
|
||||
fmt.Fprintf(out, " • %s\n", tip)
|
||||
}
|
||||
})
|
||||
return program
|
||||
}
|
||||
|
||||
func TestAllShortcutsScopesNotNil(t *testing.T) {
|
||||
for _, s := range allShortcuts {
|
||||
hasScopes := s.Scopes != nil || s.UserScopes != nil || s.BotScopes != nil
|
||||
@@ -48,7 +77,7 @@ func TestAllShortcutsReturnsCopyAndIncludesBase(t *testing.T) {
|
||||
|
||||
func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, &cmdutil.Factory{})
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
baseCmd, _, err := program.Find([]string{"base"})
|
||||
if err != nil {
|
||||
@@ -69,7 +98,7 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
|
||||
|
||||
func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, &cmdutil.Factory{})
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
previewCmd, _, err := program.Find([]string{"docs", "+media-preview"})
|
||||
if err != nil {
|
||||
@@ -80,12 +109,182 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndLegacyTips(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
docsCmd, _, err := program.Find([]string{"docs"})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs command: %v", err)
|
||||
}
|
||||
if docsCmd == nil || docsCmd.Name() != "docs" {
|
||||
t.Fatalf("docs command not mounted: %#v", docsCmd)
|
||||
}
|
||||
if docsCmd.Flags().Lookup("api-version") == nil {
|
||||
t.Fatal("docs command should expose --api-version for versioned help")
|
||||
}
|
||||
|
||||
if !strings.Contains(docsCmd.Long, "Document and content operations.") {
|
||||
t.Fatalf("docs long help missing default description:\n%s", docsCmd.Long)
|
||||
}
|
||||
|
||||
var defaultHelp bytes.Buffer
|
||||
docsCmd.SetOut(&defaultHelp)
|
||||
if err := docsCmd.Help(); err != nil {
|
||||
t.Fatalf("docs help failed: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Tips:",
|
||||
"Agent version rule",
|
||||
"use --api-version v2 only when the installed lark-doc skill explicitly instructs",
|
||||
"otherwise use the default v1 flags",
|
||||
"if the skill does not mention v2",
|
||||
"legacy v1 examples and flags",
|
||||
} {
|
||||
if !strings.Contains(defaultHelp.String(), want) {
|
||||
t.Fatalf("docs default help missing %q:\n%s", want, defaultHelp.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsV2HelpUsesV2Description(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
docsCmd, _, err := program.Find([]string{"docs"})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs command: %v", err)
|
||||
}
|
||||
if err := docsCmd.Flags().Set("api-version", "v2"); err != nil {
|
||||
t.Fatalf("set docs api-version: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
docsCmd.SetOut(&out)
|
||||
if err := docsCmd.Help(); err != nil {
|
||||
t.Fatalf("docs v2 help failed: %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"Document and content operations (v2).",
|
||||
"Tips:",
|
||||
"Agent version rule",
|
||||
"otherwise use the default v1 flags",
|
||||
"if the skill does not mention v2",
|
||||
"legacy v1 examples and flags",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("docs v2 help missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut string
|
||||
apiVersion string
|
||||
shortcutHelp string
|
||||
versionedFlag string
|
||||
}{
|
||||
{
|
||||
name: "create v1",
|
||||
shortcut: "+create",
|
||||
apiVersion: "v1",
|
||||
shortcutHelp: "Create a Lark document",
|
||||
versionedFlag: "--markdown",
|
||||
},
|
||||
{
|
||||
name: "create v2",
|
||||
shortcut: "+create",
|
||||
apiVersion: "v2",
|
||||
shortcutHelp: "Create a Lark document",
|
||||
versionedFlag: "--content",
|
||||
},
|
||||
{
|
||||
name: "fetch v1",
|
||||
shortcut: "+fetch",
|
||||
apiVersion: "v1",
|
||||
shortcutHelp: "Fetch Lark document content",
|
||||
versionedFlag: "--offset",
|
||||
},
|
||||
{
|
||||
name: "fetch v2",
|
||||
shortcut: "+fetch",
|
||||
apiVersion: "v2",
|
||||
shortcutHelp: "Fetch Lark document content",
|
||||
versionedFlag: "partial read scope",
|
||||
},
|
||||
{
|
||||
name: "update v1",
|
||||
shortcut: "+update",
|
||||
apiVersion: "v1",
|
||||
shortcutHelp: "Update a Lark document",
|
||||
versionedFlag: "--mode",
|
||||
},
|
||||
{
|
||||
name: "update v2",
|
||||
shortcut: "+update",
|
||||
apiVersion: "v2",
|
||||
shortcutHelp: "Update a Lark document",
|
||||
versionedFlag: "--command",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
program := newRegisterTestProgramWithTipsHelp()
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
cmd, _, err := program.Find([]string{"docs", tt.shortcut})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs %s command: %v", tt.shortcut, err)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != tt.shortcut {
|
||||
t.Fatalf("docs %s shortcut not mounted: %#v", tt.shortcut, cmd)
|
||||
}
|
||||
if err := cmd.Flags().Set("api-version", tt.apiVersion); err != nil {
|
||||
t.Fatalf("set docs %s api-version: %v", tt.shortcut, err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
if err := cmd.Help(); err != nil {
|
||||
t.Fatalf("docs %s help failed: %v", tt.shortcut, err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
tt.shortcutHelp,
|
||||
tt.versionedFlag,
|
||||
"Tips:",
|
||||
"Agent version rule",
|
||||
"use --api-version v2 only when the installed lark-doc skill explicitly instructs",
|
||||
"otherwise use the default v1 flags",
|
||||
"if the skill does not mention v2",
|
||||
"legacy v1 examples and flags",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"[NOTE]",
|
||||
"Use --api-version v2 for the latest API",
|
||||
} {
|
||||
if strings.Contains(out.String(), unwanted) {
|
||||
t.Fatalf("docs %s %s help should not include %q:\n%s", tt.shortcut, tt.apiVersion, unwanted, out.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
existingBase := &cobra.Command{Use: "base", Short: "existing base service"}
|
||||
program.AddCommand(existingBase)
|
||||
|
||||
RegisterShortcuts(program, &cmdutil.Factory{})
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
baseCount := 0
|
||||
for _, command := range program.Commands() {
|
||||
|
||||
421
shortcuts/sheets/lark_sheets_cell_data.go
Normal file
421
shortcuts/sheets/lark_sheets_cell_data.go
Normal file
@@ -0,0 +1,421 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func parseValues2DJSON(raw string) ([][]interface{}, error) {
|
||||
var rows [][]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &rows); err != nil {
|
||||
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
|
||||
}
|
||||
if rows == nil {
|
||||
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
var SheetRead = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+read",
|
||||
Description: "Read spreadsheet cell values",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "read range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID"},
|
||||
{Name: "value-render-option", Desc: "render option: ToString|FormattedValue|Formula|UnformattedValue"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
if r := runtime.Str("range"); r != "" {
|
||||
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
|
||||
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
readRange := runtime.Str("range")
|
||||
if readRange == "" && runtime.Str("sheet-id") != "" {
|
||||
readRange = runtime.Str("sheet-id")
|
||||
}
|
||||
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v2/spreadsheets/:token/values/:range").
|
||||
Set("token", token).Set("range", readRange)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
readRange := runtime.Str("range")
|
||||
if readRange == "" && runtime.Str("sheet-id") != "" {
|
||||
readRange = runtime.Str("sheet-id")
|
||||
}
|
||||
|
||||
if readRange == "" {
|
||||
var err error
|
||||
readRange, err = getFirstSheetID(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
|
||||
|
||||
params := map[string]interface{}{}
|
||||
renderOption := runtime.Str("value-render-option")
|
||||
if renderOption != "" {
|
||||
params["valueRenderOption"] = renderOption
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetWrite = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+write",
|
||||
Description: "Write to spreadsheet cells (overwrite mode)",
|
||||
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: "range", Desc: "write range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID"},
|
||||
{Name: "values", Desc: "2D array JSON", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := parseValues2DJSON(runtime.Str("values")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
writeRange := runtime.Str("range")
|
||||
if writeRange == "" && runtime.Str("sheet-id") != "" {
|
||||
writeRange = runtime.Str("sheet-id")
|
||||
}
|
||||
values, _ := parseValues2DJSON(runtime.Str("values"))
|
||||
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/values").
|
||||
Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": writeRange, "values": values}}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
values, err := parseValues2DJSON(runtime.Str("values"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writeRange := runtime.Str("range")
|
||||
if writeRange == "" && runtime.Str("sheet-id") != "" {
|
||||
writeRange = runtime.Str("sheet-id")
|
||||
}
|
||||
|
||||
if writeRange == "" {
|
||||
var err error
|
||||
writeRange, err = getFirstSheetID(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
|
||||
|
||||
data, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
"valueRange": map[string]interface{}{
|
||||
"range": writeRange,
|
||||
"values": values,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetAppend = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+append",
|
||||
Description: "Append rows to a spreadsheet",
|
||||
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: "range", Desc: "append range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID"},
|
||||
{Name: "values", Desc: "2D array JSON", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := parseValues2DJSON(runtime.Str("values")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
appendRange := runtime.Str("range")
|
||||
if appendRange == "" && runtime.Str("sheet-id") != "" {
|
||||
appendRange = runtime.Str("sheet-id")
|
||||
}
|
||||
values, _ := parseValues2DJSON(runtime.Str("values"))
|
||||
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/values_append").
|
||||
Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": appendRange, "values": values}}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
values, err := parseValues2DJSON(runtime.Str("values"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appendRange := runtime.Str("range")
|
||||
if appendRange == "" && runtime.Str("sheet-id") != "" {
|
||||
appendRange = runtime.Str("sheet-id")
|
||||
}
|
||||
|
||||
if appendRange == "" {
|
||||
var err error
|
||||
appendRange, err = getFirstSheetID(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
|
||||
|
||||
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
"valueRange": map[string]interface{}{
|
||||
"range": appendRange,
|
||||
"values": values,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetFind = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+find",
|
||||
Description: "Find cells in a spreadsheet",
|
||||
Risk: "read",
|
||||
Scopes: []string{"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: "sheet ID", Required: true},
|
||||
{Name: "find", Desc: "search text", Required: true},
|
||||
{Name: "range", Desc: "search range (<sheetId>!A1:D10, or A1:D10 / C2 with --sheet-id)"},
|
||||
{Name: "ignore-case", Type: "bool", Desc: "case-insensitive search"},
|
||||
{Name: "match-entire-cell", Type: "bool", Desc: "match entire cell"},
|
||||
{Name: "search-by-regex", Type: "bool", Desc: "regex search"},
|
||||
{Name: "include-formulas", Type: "bool", Desc: "search formulas"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
if r := runtime.Str("range"); r != "" {
|
||||
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
|
||||
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
findCondition := map[string]interface{}{
|
||||
"range": sheetID,
|
||||
"match_case": !runtime.Bool("ignore-case"),
|
||||
"match_entire_cell": runtime.Bool("match-entire-cell"),
|
||||
"search_by_regex": runtime.Bool("search-by-regex"),
|
||||
"include_formulas": runtime.Bool("include-formulas"),
|
||||
}
|
||||
if runtime.Str("range") != "" {
|
||||
findCondition["range"] = normalizePointRange(sheetID, runtime.Str("range"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/find").
|
||||
Body(map[string]interface{}{
|
||||
"find": runtime.Str("find"),
|
||||
"find_condition": findCondition,
|
||||
}).
|
||||
Set("token", token).Set("sheet_id", sheetID).Set("find", runtime.Str("find"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
findText := runtime.Str("find")
|
||||
|
||||
findCondition := map[string]interface{}{
|
||||
"range": sheetID,
|
||||
"match_case": !runtime.Bool("ignore-case"),
|
||||
"match_entire_cell": runtime.Bool("match-entire-cell"),
|
||||
"search_by_regex": runtime.Bool("search-by-regex"),
|
||||
"include_formulas": runtime.Bool("include-formulas"),
|
||||
}
|
||||
if runtime.Str("range") != "" {
|
||||
findCondition["range"] = normalizePointRange(sheetID, runtime.Str("range"))
|
||||
}
|
||||
|
||||
reqData := map[string]interface{}{
|
||||
"find_condition": findCondition,
|
||||
"find": findText,
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetReplace = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+replace",
|
||||
Description: "Find and replace cell values in a spreadsheet",
|
||||
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: "sheet ID", Required: true},
|
||||
{Name: "find", Desc: "search text or regex pattern", Required: true},
|
||||
{Name: "replacement", Desc: "replacement text", Required: true},
|
||||
{Name: "range", Desc: "search range (<sheetId>!A1:D10, or A1:D10 with --sheet-id)"},
|
||||
{Name: "match-case", Type: "bool", Desc: "case-sensitive search"},
|
||||
{Name: "match-entire-cell", Type: "bool", Desc: "match entire cell content"},
|
||||
{Name: "search-by-regex", Type: "bool", Desc: "use regex search"},
|
||||
{Name: "include-formulas", Type: "bool", Desc: "search in formulas"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
if r := runtime.Str("range"); r != "" {
|
||||
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
|
||||
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
findCondition := map[string]interface{}{
|
||||
"range": sheetID,
|
||||
"match_case": runtime.Bool("match-case"),
|
||||
"match_entire_cell": runtime.Bool("match-entire-cell"),
|
||||
"search_by_regex": runtime.Bool("search-by-regex"),
|
||||
"include_formulas": runtime.Bool("include-formulas"),
|
||||
}
|
||||
if runtime.Str("range") != "" {
|
||||
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/replace").
|
||||
Body(map[string]interface{}{
|
||||
"find_condition": findCondition,
|
||||
"find": runtime.Str("find"),
|
||||
"replacement": runtime.Str("replacement"),
|
||||
}).
|
||||
Set("token", token).Set("sheet_id", sheetID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
findCondition := map[string]interface{}{
|
||||
"range": sheetID,
|
||||
"match_case": runtime.Bool("match-case"),
|
||||
"match_entire_cell": runtime.Bool("match-entire-cell"),
|
||||
"search_by_regex": runtime.Bool("search-by-regex"),
|
||||
"include_formulas": runtime.Bool("include-formulas"),
|
||||
}
|
||||
if runtime.Str("range") != "" {
|
||||
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace",
|
||||
validate.EncodePathSegment(token),
|
||||
validate.EncodePathSegment(sheetID),
|
||||
),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"find_condition": findCondition,
|
||||
"find": runtime.Str("find"),
|
||||
"replacement": runtime.Str("replacement"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package sheets
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -43,6 +44,10 @@ var SheetWriteImage = common.Shortcut{
|
||||
if err := validateSingleCellRange(runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := validateSheetWriteImageFile(runtime.Str("image"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -71,25 +76,12 @@ var SheetWriteImage = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
// Resolve the target cell range (--range is required).
|
||||
pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
// Resolve image file.
|
||||
imagePath := runtime.Str("image")
|
||||
safePath, err := validate.SafeInputPath(imagePath)
|
||||
safePath, stat, err := validateSheetWriteImageFile(imagePath)
|
||||
if err != nil {
|
||||
return output.ErrValidation("unsafe image path: %s", err)
|
||||
}
|
||||
stat, err := vfs.Stat(safePath)
|
||||
if err != nil {
|
||||
return output.ErrValidation("image file not found: %s", imagePath)
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("image must be a regular file: %s", imagePath)
|
||||
}
|
||||
const maxImageSize int64 = 20 * 1024 * 1024 // 20 MB
|
||||
if stat.Size() > maxImageSize {
|
||||
return output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
|
||||
return err
|
||||
}
|
||||
|
||||
imageBytes, err := vfs.ReadFile(safePath)
|
||||
@@ -104,8 +96,6 @@ var SheetWriteImage = common.Shortcut{
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Writing image: %s (%d bytes) → %s\n", imageName, stat.Size(), pointRange)
|
||||
|
||||
// The sheets v2 values_image API expects a JSON body with the image
|
||||
// as an inline byte array, not multipart/form-data.
|
||||
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
"range": pointRange,
|
||||
"image": imageBytes,
|
||||
@@ -118,3 +108,22 @@ var SheetWriteImage = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func validateSheetWriteImageFile(imagePath string) (string, fs.FileInfo, error) {
|
||||
safePath, err := validate.SafeInputPath(imagePath)
|
||||
if err != nil {
|
||||
return "", nil, output.ErrValidation("unsafe image path: %s", err)
|
||||
}
|
||||
stat, err := vfs.Stat(safePath)
|
||||
if err != nil {
|
||||
return "", nil, output.ErrValidation("image file not found: %s", imagePath)
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return "", nil, output.ErrValidation("image must be a regular file: %s", imagePath)
|
||||
}
|
||||
const maxImageSize int64 = 20 * 1024 * 1024
|
||||
if stat.Size() > maxImageSize {
|
||||
return "", nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
|
||||
}
|
||||
return safePath, stat, nil
|
||||
}
|
||||
350
shortcuts/sheets/lark_sheets_cell_style_and_merge.go
Normal file
350
shortcuts/sheets/lark_sheets_cell_style_and_merge.go
Normal file
@@ -0,0 +1,350 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func validateBatchStyleData(raw string) error {
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &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")
|
||||
}
|
||||
for i, item := range arr {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("--data[%d] must be an object with ranges and style", i)
|
||||
}
|
||||
rangesRaw, ok := entry["ranges"]
|
||||
if !ok {
|
||||
return common.FlagErrorf("--data[%d].ranges is required", i)
|
||||
}
|
||||
ranges, ok := rangesRaw.([]interface{})
|
||||
if !ok || len(ranges) == 0 {
|
||||
return common.FlagErrorf("--data[%d].ranges must be a non-empty array of strings", i)
|
||||
}
|
||||
for j, r := range ranges {
|
||||
s, ok := r.(string)
|
||||
if !ok || s == "" {
|
||||
return common.FlagErrorf("--data[%d].ranges[%d] must be a non-empty string", i, j)
|
||||
}
|
||||
if _, _, ok := splitSheetRange(s); !ok {
|
||||
return common.FlagErrorf("--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s)
|
||||
}
|
||||
}
|
||||
styleRaw, ok := entry["style"]
|
||||
if !ok {
|
||||
return common.FlagErrorf("--data[%d].style is required", i)
|
||||
}
|
||||
if _, ok := styleRaw.(map[string]interface{}); !ok {
|
||||
return common.FlagErrorf("--data[%d].style must be a JSON object", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var SheetSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+set-style",
|
||||
Description: "Set cell style for a range",
|
||||
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: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
|
||||
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
|
||||
{Name: "style", Desc: "style JSON object (e.g. {\"font\":{\"bold\":true},\"backColor\":\"#ff0000\"})", 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 style interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
}
|
||||
if _, ok := style.(map[string]interface{}); !ok {
|
||||
return common.FlagErrorf("--style must be a JSON object, got %T", style)
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
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"))
|
||||
}
|
||||
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("style")), &style)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/style").
|
||||
Body(map[string]interface{}{
|
||||
"appendStyle": map[string]interface{}{
|
||||
"range": r,
|
||||
"style": style,
|
||||
},
|
||||
}).
|
||||
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"))
|
||||
}
|
||||
|
||||
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"appendStyle": map[string]interface{}{
|
||||
"range": r,
|
||||
"style": style,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
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; each range must carry a sheetId! prefix (e.g. sheet1!A1)", 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")
|
||||
}
|
||||
return validateBatchStyleData(runtime.Str("data"))
|
||||
},
|
||||
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)
|
||||
normalizeBatchStyleRanges(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)
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
func normalizeBatchStyleRanges(data interface{}) {
|
||||
items, ok := data.([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range items {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ranges, ok := entry["ranges"].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, r := range ranges {
|
||||
if s, ok := r.(string); ok {
|
||||
ranges[i] = normalizePointRange("", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var SheetMergeCells = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+merge-cells",
|
||||
Description: "Merge cells in a spreadsheet",
|
||||
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: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
|
||||
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
|
||||
{Name: "merge-type", Desc: "merge method", Required: true, Enum: []string{"MERGE_ALL", "MERGE_ROWS", "MERGE_COLUMNS"}},
|
||||
},
|
||||
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 err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
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"))
|
||||
}
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/merge_cells").
|
||||
Body(map[string]interface{}{
|
||||
"range": r,
|
||||
"mergeType": runtime.Str("merge-type"),
|
||||
}).
|
||||
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"))
|
||||
}
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"range": r,
|
||||
"mergeType": runtime.Str("merge-type"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetUnmergeCells = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+unmerge-cells",
|
||||
Description: "Unmerge (split) cells in a spreadsheet",
|
||||
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: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
|
||||
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
|
||||
},
|
||||
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 err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
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"))
|
||||
}
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/unmerge_cells").
|
||||
Body(map[string]interface{}{
|
||||
"range": r,
|
||||
}).
|
||||
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"))
|
||||
}
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"range": r,
|
||||
},
|
||||
)
|
||||
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