mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29a98966a0 | ||
|
|
a81d07ca4f | ||
|
|
e754b3bc1b | ||
|
|
a6de8360f0 | ||
|
|
88d7ec8ee7 | ||
|
|
90757887b2 | ||
|
|
88d4e3bd90 | ||
|
|
7c68639b31 | ||
|
|
8b80810fa0 | ||
|
|
eed802c814 | ||
|
|
8f410ab140 | ||
|
|
d9b9f094cf | ||
|
|
b65147f208 | ||
|
|
c3756f3642 | ||
|
|
27a2f2758b | ||
|
|
15ae1fabec | ||
|
|
d317493e49 | ||
|
|
a8f078478e | ||
|
|
06275415b1 | ||
|
|
b4c9c09de0 | ||
|
|
7fb71c6947 | ||
|
|
020aeb87ad | ||
|
|
686c91dc71 | ||
|
|
cfd89e0e28 | ||
|
|
ac4c34f2ad | ||
|
|
3ed691b25c | ||
|
|
30ad38d4b6 | ||
|
|
4fab062219 | ||
|
|
f27b8fdf40 | ||
|
|
c100ca049e | ||
|
|
4d68e09537 | ||
|
|
a3bbe00ee0 | ||
|
|
0250054a90 | ||
|
|
d7ee5b5769 |
16
AGENTS.md
16
AGENTS.md
@@ -15,6 +15,22 @@ make unit-test # Required before PR (runs with -race)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## Notification Opt-Outs
|
||||
|
||||
`lark-cli` emits two notice types into JSON envelope `_notice` to nudge AI agents toward fixes:
|
||||
|
||||
- `_notice.update` — a newer binary is available on npm
|
||||
- `_notice.skills` — locally installed skills are out of sync with the running binary
|
||||
|
||||
To suppress them in non-CI scripts (CI envs are auto-skipped):
|
||||
|
||||
| Env var | Effect |
|
||||
|---------|--------|
|
||||
| `LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1` | Suppress `_notice.update` |
|
||||
| `LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1` | Suppress `_notice.skills` |
|
||||
|
||||
Both notices recommend the same fix command: `lark-cli update`. The skills notice's `current` field is `""` when skills have never been synced (cold start) and a version string when synced for an older binary (drift).
|
||||
|
||||
## Pre-PR Checks (match CI gates)
|
||||
|
||||
1. `make unit-test`
|
||||
|
||||
74
CHANGELOG.md
74
CHANGELOG.md
@@ -2,6 +2,76 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.26] - 2026-05-08
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add `message_app_link` to message outputs (#668)
|
||||
- **auth**: Add scope hint for missing authorization errors (#776)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Clean error detail output (#783)
|
||||
- **whiteboard**: Reclassify `+update` as `write` risk (#775)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Add data integrity and write-confirmation rules (#749)
|
||||
|
||||
## [v1.0.25] - 2026-05-07
|
||||
|
||||
### Features
|
||||
|
||||
- Add skills version drift notice and unify update flow (#723)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove misleading default value from `--as` flag help text (#769)
|
||||
- Handle negative truncate lengths (#744)
|
||||
- Reject invalid JSON pointer escapes (#741)
|
||||
- Migrate task shortcut errors to structured `output.Errorf`/`ErrValidation` (#740)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Clarify base `user_open_id` guidance (#763)
|
||||
|
||||
## [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 +630,10 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.26]: https://github.com/larksuite/cli/releases/tag/v1.0.26
|
||||
[v1.0.25]: https://github.com/larksuite/cli/releases/tag/v1.0.25
|
||||
[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{
|
||||
|
||||
@@ -109,6 +109,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
f.CurrentCommand = cmd
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
175
cmd/error_auth_hint.go
Normal file
175
cmd/error_auth_hint.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// enrichMissingScopeError preserves the original need_user_authorization
|
||||
// message and appends a scope hint when the current command declares the
|
||||
// required scopes locally.
|
||||
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if exitErr == nil || exitErr.Detail == nil {
|
||||
return
|
||||
}
|
||||
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
|
||||
return
|
||||
}
|
||||
|
||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
||||
if len(scopes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
|
||||
if exitErr.Detail.Hint == "" {
|
||||
exitErr.Detail.Hint = scopeHint
|
||||
return
|
||||
}
|
||||
exitErr.Detail.Hint += "\n" + scopeHint
|
||||
}
|
||||
|
||||
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
|
||||
// current command for the resolved identity, checking shortcuts first and then
|
||||
// service methods from local registry metadata.
|
||||
func resolveDeclaredScopesForCurrentCommand(f *cmdutil.Factory) []string {
|
||||
if f == nil || f.CurrentCommand == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
identity := string(f.ResolvedIdentity)
|
||||
if identity == "" {
|
||||
identity = string(core.AsUser)
|
||||
}
|
||||
if identity != string(core.AsUser) && identity != string(core.AsBot) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if scopes := resolveDeclaredShortcutScopes(f.CurrentCommand, identity); len(scopes) > 0 {
|
||||
return scopes
|
||||
}
|
||||
return resolveDeclaredServiceMethodScopes(f.CurrentCommand, identity)
|
||||
}
|
||||
|
||||
// resolveDeclaredShortcutScopes returns the scopes declared by a mounted
|
||||
// shortcut command for the given identity.
|
||||
func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string {
|
||||
if cmd == nil || cmd.Parent() == nil || !strings.HasPrefix(cmd.Name(), "+") {
|
||||
return nil
|
||||
}
|
||||
|
||||
service := cmd.Parent().Name()
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
continue
|
||||
}
|
||||
scopes := sc.ScopesForIdentity(identity)
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), scopes...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
|
||||
// service/resource/method command from the embedded from_meta registry.
|
||||
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
|
||||
// Service-method scope lookup only applies to commands mounted as
|
||||
// root -> service -> resource -> method. Non-resource/method commands
|
||||
// intentionally return no scopes here so auth-hint enrichment does not
|
||||
// change runtime semantics for other command shapes.
|
||||
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(cmd.Name(), "+") {
|
||||
return nil
|
||||
}
|
||||
|
||||
service := cmd.Parent().Parent().Name()
|
||||
resource := cmd.Parent().Name()
|
||||
method := cmd.Name()
|
||||
|
||||
spec := registry.LoadFromMeta(service)
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resMap, _ := resources[resource].(map[string]interface{})
|
||||
if resMap == nil {
|
||||
return nil
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methodMap, _ := methods[method].(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
return nil
|
||||
}
|
||||
return declaredScopesForMethod(methodMap, identity)
|
||||
}
|
||||
|
||||
// declaredScopesForMethod returns all requiredScopes when present; otherwise it
|
||||
// resolves the single recommended scope from the method's scopes list.
|
||||
func declaredScopesForMethod(method map[string]interface{}, identity string) []string {
|
||||
if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 {
|
||||
return interfaceStrings(requiredRaw)
|
||||
}
|
||||
|
||||
rawScopes, _ := method["scopes"].([]interface{})
|
||||
if len(rawScopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
recommended := registry.SelectRecommendedScope(rawScopes, identity)
|
||||
if recommended == "" {
|
||||
for _, raw := range rawScopes {
|
||||
if scope, ok := raw.(string); ok && scope != "" {
|
||||
recommended = scope
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if recommended == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{recommended}
|
||||
}
|
||||
|
||||
// interfaceStrings converts a []interface{} containing strings into a compact
|
||||
// []string, skipping empty or non-string values.
|
||||
func interfaceStrings(values []interface{}) []string {
|
||||
scopes := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
scope, ok := value.(string)
|
||||
if !ok || scope == "" {
|
||||
continue
|
||||
}
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||
// identity, applying the default user-only behavior when AuthTypes is empty.
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{string(core.AsUser)}
|
||||
}
|
||||
for _, authType := range authTypes {
|
||||
if authType == identity {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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)")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
52
cmd/root.go
52
cmd/root.go
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -47,7 +48,7 @@ EXAMPLES:
|
||||
FLAGS:
|
||||
--params <json> URL/query parameters JSON
|
||||
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
|
||||
--as <type> identity type: user | bot | auto (default: auto)
|
||||
--as <type> identity type: user | bot
|
||||
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
|
||||
--page-all automatically paginate through all pages
|
||||
--page-size <N> page size (0 = use API default)
|
||||
@@ -93,9 +94,9 @@ func Execute() int {
|
||||
HideProfile(isSingleAppMode()),
|
||||
)
|
||||
|
||||
// --- Update check (non-blocking) ---
|
||||
// --- Notices (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
setupUpdateNotice()
|
||||
setupNotices()
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
@@ -104,42 +105,54 @@ func Execute() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// setupUpdateNotice starts an async update check and wires the output decorator.
|
||||
func setupUpdateNotice() {
|
||||
// Sync: check cache immediately (no network, fast).
|
||||
// setupNotices wires both the binary update notice and the skills
|
||||
// staleness notice into output.PendingNotice as a composed function.
|
||||
// Each provider populates an independent key under _notice; either
|
||||
// or both may be present in any given envelope.
|
||||
func setupNotices() {
|
||||
// Binary update — synchronous cache check + async refresh
|
||||
if info := update.CheckCached(build.Version); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
|
||||
// Async: refresh cache for this run (and future runs).
|
||||
ver := build.Version
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
|
||||
}
|
||||
}()
|
||||
update.RefreshCache(build.Version)
|
||||
// If cache was just populated for the first time, set pending now.
|
||||
update.RefreshCache(ver)
|
||||
if update.GetPending() == nil {
|
||||
if info := update.CheckCached(build.Version); info != nil {
|
||||
if info := update.CheckCached(ver); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wire the output decorator so JSON envelopes include "_notice".
|
||||
// Skills check — synchronous, local-only (no network, no goroutine).
|
||||
skillscheck.Init(build.Version)
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
info := update.GetPending()
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"update": map[string]interface{}{
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
},
|
||||
}
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
}
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +192,7 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
if !exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command) preserve the original API
|
||||
// error detail; skip enrichment which would clear it.
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
enrichPermissionError(f, exitErr)
|
||||
}
|
||||
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,11 +15,14 @@ import (
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -343,11 +347,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 +372,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 +410,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 +429,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 +447,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 +466,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)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -490,3 +503,181 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestSetupNotices_ColdStart verifies that when no skills stamp exists,
|
||||
// the composed PendingNotice provider includes a "skills" key with an
|
||||
// empty Current and the cold-start message.
|
||||
func TestSetupNotices_ColdStart(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
origVersion := build.Version
|
||||
build.Version = "1.0.21"
|
||||
t.Cleanup(func() { build.Version = origVersion })
|
||||
|
||||
// Reset pending state to ensure a clean test.
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() {
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
t.Fatal("GetNotice() = nil, want non-nil for cold start")
|
||||
}
|
||||
skills, ok := notice["skills"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("notice.skills missing, got %+v", notice)
|
||||
}
|
||||
if skills["current"] != "" || skills["target"] != "1.0.21" {
|
||||
t.Errorf("notice.skills = %+v, want {current:\"\", target:\"1.0.21\"}", skills)
|
||||
}
|
||||
if msg, _ := skills["message"].(string); msg != "lark-cli skills not installed, run: lark-cli update" {
|
||||
t.Errorf("notice.skills.message = %q, want cold-start message", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_InSync verifies that a matching stamp produces no
|
||||
// skills key in the composed notice.
|
||||
func TestSetupNotices_InSync(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
origVersion := build.Version
|
||||
build.Version = "1.0.21"
|
||||
t.Cleanup(func() { build.Version = origVersion })
|
||||
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() {
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice != nil {
|
||||
if _, ok := notice["skills"]; ok {
|
||||
t.Errorf("notice.skills present in in-sync state: %+v", notice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_Drift verifies a mismatching stamp produces the
|
||||
// drift message with both current and target populated.
|
||||
func TestSetupNotices_Drift(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
origVersion := build.Version
|
||||
build.Version = "1.0.21"
|
||||
t.Cleanup(func() { build.Version = origVersion })
|
||||
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() {
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
t.Fatal("GetNotice() = nil, want non-nil for drift")
|
||||
}
|
||||
skills, ok := notice["skills"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("notice.skills missing, got %+v", notice)
|
||||
}
|
||||
if skills["current"] != "1.0.20" || skills["target"] != "1.0.21" {
|
||||
t.Errorf("notice.skills = %+v, want {current:\"1.0.20\", target:\"1.0.21\"}", skills)
|
||||
}
|
||||
want := "lark-cli skills 1.0.20 out of sync with binary 1.0.21, run: lark-cli update"
|
||||
if msg, _ := skills["message"].(string); msg != want {
|
||||
t.Errorf("notice.skills.message = %q, want %q", msg, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_BothUpdateAndSkills verifies the composed envelope
|
||||
// emits BOTH "_notice.update" and "_notice.skills" keys when each
|
||||
// pending value is set. Drives the skills key via setupNotices() (drift
|
||||
// state) and manually populates the update pending afterwards, since
|
||||
// clearNoticeEnv suppresses the update goroutine to avoid network
|
||||
// flakiness.
|
||||
func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
origVersion := build.Version
|
||||
build.Version = "1.0.21"
|
||||
t.Cleanup(func() { build.Version = origVersion })
|
||||
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() {
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
|
||||
// After setupNotices, skills pending is set (drift). Manually populate
|
||||
// the update side so the composed envelope has both keys — the update
|
||||
// goroutine is suppressed by clearNoticeEnv.
|
||||
update.SetPending(&update.UpdateInfo{Current: "1.0.21", Latest: "1.0.22"})
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
t.Fatal("GetNotice() = nil, want both keys")
|
||||
}
|
||||
if _, ok := notice["update"].(map[string]interface{}); !ok {
|
||||
t.Errorf("missing 'update' key: %+v", notice)
|
||||
}
|
||||
if _, ok := notice["skills"].(map[string]interface{}); !ok {
|
||||
t.Errorf("missing 'skills' key: %+v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// clearNoticeEnv unsets the env vars that affect either notice. We
|
||||
// proactively SUPPRESS the update notifier (LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1)
|
||||
// because setupNotices spawns a goroutine that hits the npm registry —
|
||||
// tests focused on the skills check should not depend on network state.
|
||||
func clearNoticeEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, key := range []string{
|
||||
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER",
|
||||
"CI", "BUILD_NUMBER", "RUN_ID",
|
||||
} {
|
||||
t.Setenv(key, "")
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
// Suppress the update goroutine's network call deterministically.
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
}
|
||||
|
||||
121
cmd/root_test.go
121
cmd/root_test.go
@@ -11,9 +11,12 @@ import (
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
|
||||
@@ -188,6 +191,124 @@ func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
var target registry.CommandEntry
|
||||
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
||||
if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" {
|
||||
target = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if target.Command == "" {
|
||||
t.Fatal("failed to locate a calendar create command in local registry metadata")
|
||||
}
|
||||
parts := strings.Split(target.Command, " ")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("expected resource/method command, got %q", target.Command)
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "calendar"}
|
||||
resourceCmd := &cobra.Command{Use: parts[0]}
|
||||
methodCmd := &cobra.Command{Use: parts[1]}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(resourceCmd)
|
||||
resourceCmd.AddCommand(methodCmd)
|
||||
f.CurrentCommand = methodCmd
|
||||
|
||||
exitErr := output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected exit code %d, got %d", output.ExitAPI, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
||||
t.Fatalf("expected api_error detail, got %+v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
|
||||
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): calendar:calendar.event:create") {
|
||||
t.Fatalf("expected scope guidance in hint, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
|
||||
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Detail != nil {
|
||||
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("expected exit code %d, got %d", output.ExitNetwork, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||
t.Fatalf("expected network detail, got %+v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
|
||||
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): docx:document:create") {
|
||||
t.Fatalf("expected shortcut scope hint, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
|
||||
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Detail != nil {
|
||||
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
exitErr.Detail.Hint = "existing hint"
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
|
||||
if exitErr.Detail.Hint != want {
|
||||
t.Fatalf("expected appended hint %q, got %q", want, exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -14,13 +14,15 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
const (
|
||||
repoURL = "https://github.com/larksuite/cli"
|
||||
maxNpmOutput = 2000
|
||||
osWindows = "windows"
|
||||
repoURL = "https://github.com/larksuite/cli"
|
||||
maxNpmOutput = 2000
|
||||
maxStderrDetail = 500
|
||||
osWindows = "windows"
|
||||
)
|
||||
|
||||
// Overridable for testing.
|
||||
@@ -33,6 +35,13 @@ var (
|
||||
|
||||
func isWindows() bool { return currentOS == osWindows }
|
||||
|
||||
// normalizeVersion canonicalizes a version string for stamp comparison.
|
||||
// Strips a leading "v" so versions written from Makefile (git describe →
|
||||
// "v1.0.0") and npm (no prefix → "1.0.0") compare equal.
|
||||
func normalizeVersion(s string) string {
|
||||
return strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||
}
|
||||
|
||||
func releaseURL(version string) string {
|
||||
return repoURL + "/releases/tag/v" + strings.TrimPrefix(version, "v")
|
||||
}
|
||||
@@ -127,16 +136,15 @@ func updateRun(opts *UpdateOptions) error {
|
||||
|
||||
// 3. Compare versions
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
"latest_version": latest, "action": "already_up_to_date",
|
||||
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
|
||||
})
|
||||
return nil
|
||||
// Run skills sync before returning — covers the case where the
|
||||
// binary is already current but skills were never synced.
|
||||
// Stamp dedup makes this a no-op if skills are already in sync.
|
||||
// Skip side-effects under --check (pure report path per spec §3.6).
|
||||
var skillsResult *selfupdate.NpmResult
|
||||
if !opts.Check {
|
||||
skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "%s lark-cli %s is already up to date\n", symOK(), cur)
|
||||
return nil
|
||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
|
||||
// 4. Detect installation method
|
||||
@@ -149,7 +157,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
|
||||
// 6. Execute update
|
||||
if !detect.CanAutoUpdate() {
|
||||
return doManualUpdate(opts, io, cur, latest, detect)
|
||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||
}
|
||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
||||
}
|
||||
@@ -169,13 +177,24 @@ func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errTy
|
||||
|
||||
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
out := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
"latest_version": latest, "action": "update_available",
|
||||
"auto_update": canAutoUpdate,
|
||||
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
})
|
||||
}
|
||||
// skills_status: pure report, no side effect, no stamp write.
|
||||
// ReadStamp errors are silently swallowed — if we can't read the
|
||||
// stamp we just omit the block rather than fail the --check.
|
||||
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||
out["skills_status"] = map[string]interface{}{
|
||||
"current": stamp,
|
||||
"target": cur,
|
||||
"in_sync": stamp == cur,
|
||||
}
|
||||
}
|
||||
output.PrintJson(io.Out, out)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "Update available: %s %s %s\n", cur, symArrow(), latest)
|
||||
@@ -189,15 +208,19 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
return nil
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult) error {
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
out := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "latest_version": latest,
|
||||
"action": "manual_required",
|
||||
"message": fmt.Sprintf("Automatic update unavailable: %s (path: %s)", reason, detect.ResolvedPath),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
})
|
||||
}
|
||||
applySkillsResult(out, skillsResult)
|
||||
output.PrintJson(io.Out, out)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "Automatic update unavailable: %s (path: %s).\n\n", reason, detect.ResolvedPath)
|
||||
@@ -205,7 +228,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
|
||||
fmt.Fprintf(io.ErrOut, "\nAfter updating, also update skills:\n npx -y skills add larksuite/cli -g -y\n")
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -264,8 +287,10 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
// Skills update (best-effort).
|
||||
skillsResult := updater.RunSkillsUpdate()
|
||||
// Skills update (best-effort) — uses runSkillsAndStamp so the
|
||||
// stamp gets persisted on success and dedup applies if a previous
|
||||
// run already stamped this version.
|
||||
skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force)
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
@@ -274,28 +299,17 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
"message": fmt.Sprintf("lark-cli updated from %s to %s", cur, latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
}
|
||||
if skillsResult.Err != nil {
|
||||
result["skills_warning"] = fmt.Sprintf("skills update failed: %s", skillsResult.Err)
|
||||
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
|
||||
result["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
|
||||
}
|
||||
}
|
||||
applySkillsResult(result, skillsResult)
|
||||
output.PrintJson(io.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
||||
if skillsResult.Err != nil {
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %s\n", symWarn(), skillsResult.Err)
|
||||
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, 500))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
|
||||
if skillsResult != nil {
|
||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
||||
}
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -312,3 +326,96 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
|
||||
}
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually: npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
|
||||
// stamp on success. Skips the npx invocation when the stamp already
|
||||
// matches stampVersion (unless force is true). The stamp write failure
|
||||
// emits a warning to io.ErrOut but does NOT fail the update command —
|
||||
// best-effort. ReadStamp errors are swallowed (fail-closed: treated as
|
||||
// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp
|
||||
// dedup; otherwise returns the underlying *NpmResult with Err semantics
|
||||
// from RunSkillsUpdate.
|
||||
func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult {
|
||||
if !force {
|
||||
if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
r := updater.RunSkillsUpdate()
|
||||
if r.Err == nil {
|
||||
if err := skillscheck.WriteStamp(stampVersion); err != nil {
|
||||
fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
||||
// already-up-to-date branch, including any skills_action / skills_warning
|
||||
// fields derived from skillsResult. When check is true, this is the pure
|
||||
// report path (spec §3.6): no side-effects, JSON envelope uses
|
||||
// skills_status (spec §4.2) instead of skills_action.
|
||||
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error {
|
||||
if opts.JSON {
|
||||
out := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
"latest_version": latest, "action": "already_up_to_date",
|
||||
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
|
||||
}
|
||||
if check {
|
||||
// Pure report — read stamp directly, emit skills_status block.
|
||||
// ReadStamp errors are silently swallowed — if we can't read
|
||||
// the stamp we just omit the block rather than fail the --check.
|
||||
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||
out["skills_status"] = map[string]interface{}{
|
||||
"current": stamp,
|
||||
"target": cur,
|
||||
"in_sync": stamp == cur,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
applySkillsResult(out, skillsResult)
|
||||
}
|
||||
output.PrintJson(io.Out, out)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "%s lark-cli %s is already up to date\n", symOK(), cur)
|
||||
if !check {
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySkillsResult mutates the JSON envelope to include skills_action
|
||||
// (and skills_warning when failed). nil result = "in_sync" (dedup hit).
|
||||
func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) {
|
||||
switch {
|
||||
case r == nil:
|
||||
env["skills_action"] = "in_sync"
|
||||
case r.Err != nil:
|
||||
env["skills_action"] = "failed"
|
||||
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
|
||||
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||
env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
|
||||
}
|
||||
default:
|
||||
env["skills_action"] = "synced"
|
||||
}
|
||||
}
|
||||
|
||||
// emitSkillsTextHints prints human-readable feedback about the skills
|
||||
// sync result for non-JSON output.
|
||||
func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) {
|
||||
switch {
|
||||
case r == nil:
|
||||
// dedup hit — silent (already up to date)
|
||||
case r.Err != nil:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err)
|
||||
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
|
||||
default:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ package cmdupdate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -14,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
)
|
||||
|
||||
// newTestFactory creates a test factory with minimal config.
|
||||
@@ -709,6 +713,7 @@ func TestUpdateWindows_Symbols(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
@@ -737,6 +742,7 @@ func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
@@ -789,6 +795,7 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
@@ -836,6 +843,98 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable
|
||||
// for direct calls to internals like runSkillsAndStamp that write to
|
||||
// io.ErrOut.
|
||||
func newTestIO() *cmdutil.IOStreams {
|
||||
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_DedupHit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
called = true
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
if got != nil {
|
||||
t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got)
|
||||
}
|
||||
if called {
|
||||
t.Error("SkillsUpdateOverride called, want skipped due to dedup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_DedupForceBypass(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
called = true
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true)
|
||||
if got == nil {
|
||||
t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil")
|
||||
}
|
||||
if !called {
|
||||
t.Error("SkillsUpdateOverride not called with force=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_SuccessWritesStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_FailureKeepsOldStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("npx failed")
|
||||
return r
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got)
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.20" {
|
||||
t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
long := strings.Repeat("x", 3000)
|
||||
got := selfupdate.Truncate(long, 2000)
|
||||
@@ -849,3 +948,272 @@ func TestTruncate(t *testing.T) {
|
||||
t.Errorf("expected 'hello', got %q", got2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur })
|
||||
fetchLatest = func() (string, error) { return "1.0.21", nil }
|
||||
currentVersion = func() string { return "1.0.21" }
|
||||
|
||||
skillsCalled := false
|
||||
origNew := newUpdater
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
f, _, _ := newTestFactory(t)
|
||||
opts := &UpdateOptions{Factory: f, JSON: true}
|
||||
if err := updateRun(opts); err != nil {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called")
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur })
|
||||
fetchLatest = func() (string, error) { return "1.0.22", nil }
|
||||
currentVersion = func() string { return "1.0.21" }
|
||||
|
||||
skillsCalled := false
|
||||
origNew := newUpdater
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{
|
||||
Method: selfupdate.InstallManual,
|
||||
ResolvedPath: "/usr/local/bin/lark-cli",
|
||||
}
|
||||
},
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
f, _, _ := newTestFactory(t)
|
||||
opts := &UpdateOptions{Factory: f, JSON: true}
|
||||
if err := updateRun(opts); err != nil {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("RunSkillsUpdate not called in manual branch, want called")
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur })
|
||||
fetchLatest = func() (string, error) { return "1.0.22", nil }
|
||||
currentVersion = func() string { return "1.0.21" }
|
||||
|
||||
skillsCalled := false
|
||||
origNew := newUpdater
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{
|
||||
Method: selfupdate.InstallNpm, NpmAvailable: true,
|
||||
ResolvedPath: "/usr/local/bin/lark-cli",
|
||||
}
|
||||
},
|
||||
NpmInstallOverride: func(version string) *selfupdate.NpmResult {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
VerifyOverride: func(expectedVersion string) error { return nil },
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
f, _, _ := newTestFactory(t)
|
||||
opts := &UpdateOptions{Factory: f, JSON: true}
|
||||
if err := updateRun(opts); err != nil {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("RunSkillsUpdate not called in npm branch")
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.22" {
|
||||
t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur })
|
||||
fetchLatest = func() (string, error) { return "1.0.22", nil }
|
||||
currentVersion = func() string { return "1.0.21" }
|
||||
|
||||
origNew := newUpdater
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
skillsCalled := false
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
||||
},
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
opts := &UpdateOptions{Factory: f, JSON: true, Check: true}
|
||||
if err := updateRun(opts); err != nil {
|
||||
t.Fatalf("updateRun(--check) err = %v, want nil", err)
|
||||
}
|
||||
if skillsCalled {
|
||||
t.Error("RunSkillsUpdate called under --check, want skipped (pure report)")
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
status, ok := env["skills_status"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("skills_status missing or wrong type in --check JSON: %s", stdout.String())
|
||||
}
|
||||
if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false {
|
||||
t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur })
|
||||
fetchLatest = func() (string, error) { return "1.0.21", nil }
|
||||
currentVersion = func() string { return "1.0.21" }
|
||||
|
||||
skillsCalled := false
|
||||
origNew := newUpdater
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
opts := &UpdateOptions{Factory: f, JSON: true, Check: true}
|
||||
if err := updateRun(opts); err != nil {
|
||||
t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err)
|
||||
}
|
||||
if skillsCalled {
|
||||
t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)")
|
||||
}
|
||||
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.20" {
|
||||
t.Errorf("stamp mutated to %q under --check, want \"1.0.20\" (pure report must not write stamp)", stamp)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal stdout: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env["action"] != "already_up_to_date" {
|
||||
t.Errorf("action = %v, want \"already_up_to_date\"", env["action"])
|
||||
}
|
||||
if _, has := env["skills_action"]; has {
|
||||
t.Errorf("skills_action present under --check, want absent: %+v", env)
|
||||
}
|
||||
status, ok := env["skills_status"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("skills_status missing under --check + already-latest: %s", stdout.String())
|
||||
}
|
||||
if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false {
|
||||
t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSkillsAndStamp_StampWriteFailureWarns verifies the stderr warning
|
||||
// emission when RunSkillsUpdate succeeds but WriteStamp fails.
|
||||
func TestRunSkillsAndStamp_StampWriteFailureWarns(t *testing.T) {
|
||||
// Force WriteStamp to fail by pointing config dir at a path that exists
|
||||
// as a regular file (so MkdirAll fails).
|
||||
tmp := t.TempDir()
|
||||
badPath := filepath.Join(tmp, "blocker")
|
||||
if err := os.WriteFile(badPath, []byte("not-a-dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath)
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
return &selfupdate.NpmResult{} // success
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, f.IOStreams, "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: skills synced but stamp not written") {
|
||||
t.Errorf("stderr does not contain warning: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitSkillsTextHints_Success verifies the "Skills updated" success
|
||||
// message is printed to ErrOut on a successful (Err == nil) result.
|
||||
func TestEmitSkillsTextHints_Success(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success
|
||||
if !strings.Contains(stderr.String(), "Skills updated") {
|
||||
t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
const (
|
||||
LarkErrBlockByPolicy = 21001 // access denied by access control policy
|
||||
LarkErrBlockByPolicyTryAuth = 21000 // access denied by access control policy; challenge is required to be completed by user in order to gain access
|
||||
needUserAuthorizationMarker = "need_user_authorization"
|
||||
)
|
||||
|
||||
// RefreshTokenRetryable contains error codes that allow one immediate retry.
|
||||
@@ -33,7 +36,26 @@ type NeedAuthorizationError struct {
|
||||
|
||||
// Error returns the error message for NeedAuthorizationError.
|
||||
func (e *NeedAuthorizationError) Error() string {
|
||||
return fmt.Sprintf("need_user_authorization (user: %s)", e.UserOpenId)
|
||||
return fmt.Sprintf("%s (user: %s)", needUserAuthorizationMarker, e.UserOpenId)
|
||||
}
|
||||
|
||||
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT
|
||||
// failure, either as the original auth error or as a wrapped ExitError.
|
||||
func IsNeedUserAuthorizationError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var needAuthErr *NeedAuthorizationError
|
||||
if errors.As(err, &needAuthErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return strings.Contains(exitErr.Detail.Message, needUserAuthorizationMarker)
|
||||
}
|
||||
return strings.Contains(err.Error(), needUserAuthorizationMarker)
|
||||
}
|
||||
|
||||
// SecurityPolicyError is returned when a request is blocked by access control policies.
|
||||
|
||||
38
internal/auth/errors_test.go
Normal file
38
internal/auth/errors_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestIsNeedUserAuthorizationError(t *testing.T) {
|
||||
t.Run("nil error", func(t *testing.T) {
|
||||
if IsNeedUserAuthorizationError(nil) {
|
||||
t.Fatal("expected nil error not to match")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct auth error", func(t *testing.T) {
|
||||
if !IsNeedUserAuthorizationError(&NeedAuthorizationError{UserOpenId: "u_1"}) {
|
||||
t.Fatal("expected direct NeedAuthorizationError to match")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrapped exit error", func(t *testing.T) {
|
||||
err := output.ErrNetwork("API call failed: %s", &NeedAuthorizationError{})
|
||||
if !IsNeedUserAuthorizationError(err) {
|
||||
t.Fatal("expected wrapped ExitError to match")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("other error", func(t *testing.T) {
|
||||
err := output.ErrNetwork("API call failed: timeout")
|
||||
if IsNeedUserAuthorizationError(err) {
|
||||
t.Fatal("expected unrelated error not to match")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -33,8 +33,10 @@ func ReadJSONPointer(data interface{}, pointer string) (interface{}, error) {
|
||||
|
||||
for i, raw := range segments {
|
||||
// RFC 6901 unescaping: ~1 → /, ~0 → ~ (order matters).
|
||||
key := strings.ReplaceAll(raw, "~1", "/")
|
||||
key = strings.ReplaceAll(key, "~0", "~")
|
||||
key, err := decodeJSONPointerSegment(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("json pointer %q: segment %q: %w", pointer, raw, err)
|
||||
}
|
||||
|
||||
m, ok := current.(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -53,3 +55,26 @@ func ReadJSONPointer(data interface{}, pointer string) (interface{}, error) {
|
||||
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func decodeJSONPointerSegment(raw string) (string, error) {
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(raw); i++ {
|
||||
if raw[i] != '~' {
|
||||
out.WriteByte(raw[i])
|
||||
continue
|
||||
}
|
||||
if i+1 >= len(raw) {
|
||||
return "", fmt.Errorf("invalid escape: ~ must be followed by 0 or 1")
|
||||
}
|
||||
switch raw[i+1] {
|
||||
case '0':
|
||||
out.WriteByte('~')
|
||||
case '1':
|
||||
out.WriteByte('/')
|
||||
default:
|
||||
return "", fmt.Errorf("invalid escape: ~%c must be ~0 or ~1", raw[i+1])
|
||||
}
|
||||
i++
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
@@ -98,6 +98,41 @@ func TestReadJSONPointer_RFC6901_Escaping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_InvalidEscape(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"a~2b": "literal",
|
||||
"a~": "literal",
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
pointer string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unsupported escape code",
|
||||
pointer: "/a~2b",
|
||||
want: `json pointer "/a~2b": segment "a~2b": invalid escape: ~2 must be ~0 or ~1`,
|
||||
},
|
||||
{
|
||||
name: "dangling tilde",
|
||||
pointer: "/a~",
|
||||
want: `json pointer "/a~": segment "a~": invalid escape: ~ must be followed by 0 or 1`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ReadJSONPointer(data, tt.pointer)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid escape, got nil")
|
||||
}
|
||||
if err.Error() != tt.want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_InvalidFormat(t *testing.T) {
|
||||
data := map[string]interface{}{"key": "val"}
|
||||
_, err := ReadJSONPointer(data, "no-leading-slash")
|
||||
|
||||
@@ -169,7 +169,7 @@ type ProviderConfig struct {
|
||||
const (
|
||||
DefaultFileTimeoutMs = 5000
|
||||
DefaultFileMaxBytes = 1024 * 1024 // 1 MiB
|
||||
DefaultExecTimeoutMs = 5000
|
||||
DefaultExecTimeoutMs = 10000
|
||||
DefaultExecMaxOutputBytes = 1024 * 1024 // 1 MiB
|
||||
)
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ type Factory struct {
|
||||
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
|
||||
IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected
|
||||
ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call
|
||||
CurrentCommand *cobra.Command // last matched command being executed; set during PersistentPreRun
|
||||
|
||||
Credential *credential.CredentialProvider
|
||||
|
||||
@@ -160,10 +161,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
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
// AddAPIIdentityFlag registers the standard --as flag shape used by api/service commands.
|
||||
func AddAPIIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, target *string) {
|
||||
addIdentityFlag(ctx, cmd, f, target, identityFlagConfig{
|
||||
defaultValue: "auto",
|
||||
usage: "identity type: user | bot | auto (default)",
|
||||
defaultValue: "",
|
||||
usage: "identity type: user | bot",
|
||||
completionValues: []string{"user", "bot"},
|
||||
})
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func AddShortcutIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory
|
||||
authTypes = []string{"user"}
|
||||
}
|
||||
addIdentityFlag(ctx, cmd, f, nil, identityFlagConfig{
|
||||
defaultValue: authTypes[0],
|
||||
defaultValue: "",
|
||||
usage: "identity type: " + strings.Join(authTypes, " | "),
|
||||
completionValues: authTypes,
|
||||
})
|
||||
|
||||
@@ -24,8 +24,8 @@ func TestAddAPIIdentityFlag_NonStrictMode(t *testing.T) {
|
||||
if flag.Hidden {
|
||||
t.Fatal("expected --as flag to be visible outside strict mode")
|
||||
}
|
||||
if got := flag.DefValue; got != "auto" {
|
||||
t.Fatalf("default value = %q, want %q", got, "auto")
|
||||
if got := flag.DefValue; got != "" {
|
||||
t.Fatalf("default value = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestAddAPIIdentityFlag_StrictModeHidesFlagAndLocksDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddShortcutIdentityFlag_UsesAuthTypes(t *testing.T) {
|
||||
func TestAddShortcutIdentityFlag_NoDefault(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestAddShortcutIdentityFlag_UsesAuthTypes(t *testing.T) {
|
||||
if flag.Hidden {
|
||||
t.Fatal("expected --as flag to be visible outside strict mode")
|
||||
}
|
||||
if got := flag.DefValue; got != "bot" {
|
||||
t.Fatalf("default value = %q, want %q", got, "bot")
|
||||
if got := flag.DefValue; got != "" {
|
||||
t.Fatalf("default value = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,7 @@ type Endpoints struct {
|
||||
Open string // e.g. "https://open.feishu.cn"
|
||||
Accounts string // e.g. "https://accounts.feishu.cn"
|
||||
MCP string // e.g. "https://mcp.feishu.cn"
|
||||
AppLink string // e.g. "https://applink.feishu.cn"
|
||||
}
|
||||
|
||||
// ResolveEndpoints resolves endpoint URLs based on brand.
|
||||
@@ -37,12 +38,14 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
Open: "https://open.larksuite.com",
|
||||
Accounts: "https://accounts.larksuite.com",
|
||||
MCP: "https://mcp.larksuite.com",
|
||||
AppLink: "https://applink.larksuite.com",
|
||||
}
|
||||
default:
|
||||
return Endpoints{
|
||||
Open: "https://open.feishu.cn",
|
||||
Accounts: "https://accounts.feishu.cn",
|
||||
MCP: "https://mcp.feishu.cn",
|
||||
AppLink: "https://applink.feishu.cn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ func TestResolveEndpoints_Feishu(t *testing.T) {
|
||||
if ep.MCP != "https://mcp.feishu.cn" {
|
||||
t.Errorf("MCP = %q, want feishu.cn", ep.MCP)
|
||||
}
|
||||
if ep.AppLink != "https://applink.feishu.cn" {
|
||||
t.Errorf("AppLink = %q, want feishu.cn", ep.AppLink)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoints_Lark(t *testing.T) {
|
||||
@@ -29,6 +32,9 @@ func TestResolveEndpoints_Lark(t *testing.T) {
|
||||
if ep.MCP != "https://mcp.larksuite.com" {
|
||||
t.Errorf("MCP = %q, want larksuite.com", ep.MCP)
|
||||
}
|
||||
if ep.AppLink != "https://applink.larksuite.com" {
|
||||
t.Errorf("AppLink = %q, want larksuite.com", ep.AppLink)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
|
||||
|
||||
@@ -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": "妙记信息获取、内容查询" }
|
||||
|
||||
38
internal/skillscheck/check.go
Normal file
38
internal/skillscheck/check.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
// Init runs the synchronous skills version check. Stores a StaleNotice
|
||||
// when the local stamp does not match currentVersion. Safe to call
|
||||
// from cmd/root.go before rootCmd.Execute(); zero network, zero
|
||||
// subprocess — only a local stamp file read.
|
||||
//
|
||||
// Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver,
|
||||
// LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out).
|
||||
//
|
||||
// Failure modes (all → no notice, no nag):
|
||||
// - shouldSkip rule met
|
||||
// - ReadStamp returns an I/O error other than ENOENT
|
||||
// - Stamp matches currentVersion (in-sync)
|
||||
func Init(currentVersion string) {
|
||||
// Clear any stale notice from a prior call so early returns below
|
||||
// (skip rules / read errors / in-sync) leave pending == nil instead
|
||||
// of preserving a stale value from a previous Init invocation.
|
||||
SetPending(nil)
|
||||
if shouldSkip(currentVersion) {
|
||||
return
|
||||
}
|
||||
stamp, err := ReadStamp()
|
||||
if err != nil {
|
||||
// Fail closed — don't nag for a transient FS problem.
|
||||
return
|
||||
}
|
||||
if stamp == currentVersion {
|
||||
return
|
||||
}
|
||||
SetPending(&StaleNotice{
|
||||
Current: stamp, // "" when never synced
|
||||
Target: currentVersion,
|
||||
})
|
||||
}
|
||||
90
internal/skillscheck/check_test.go
Normal file
90
internal/skillscheck/check_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func resetPending(t *testing.T) {
|
||||
t.Helper()
|
||||
SetPending(nil)
|
||||
t.Cleanup(func() { SetPending(nil) })
|
||||
}
|
||||
|
||||
func TestInit_InSync_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("1.0.21")
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (in-sync)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_ColdStart_NoticeWithEmptyCurrent(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
Init("1.0.21")
|
||||
got := GetPending()
|
||||
if got == nil {
|
||||
t.Fatal("GetPending() = nil, want non-nil for cold start")
|
||||
}
|
||||
if got.Current != "" || got.Target != "1.0.21" {
|
||||
t.Errorf("notice = %+v, want {Current:\"\", Target:\"1.0.21\"}", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_Drift_NoticeWithStampVersion(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("1.0.21")
|
||||
got := GetPending()
|
||||
if got == nil {
|
||||
t.Fatal("GetPending() = nil, want non-nil for drift")
|
||||
}
|
||||
if got.Current != "1.0.20" || got.Target != "1.0.21" {
|
||||
t.Errorf("notice = %+v, want {Current:\"1.0.20\", Target:\"1.0.21\"}", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_Skipped_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
// Even with an empty config dir (no stamp), DEV version should skip
|
||||
// the check entirely and never emit a notice.
|
||||
Init("DEV")
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (skip rules met)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_ReadStampError_FailsClosed(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Make the stamp path a directory so vfs.ReadFile returns a
|
||||
// non-ENOENT I/O error.
|
||||
if err := os.MkdirAll(filepath.Join(dir, "skills.stamp"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("1.0.21")
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (fail closed on I/O error)", got)
|
||||
}
|
||||
}
|
||||
46
internal/skillscheck/notice.go
Normal file
46
internal/skillscheck/notice.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package skillscheck verifies that the locally installed lark-cli
|
||||
// skills are in sync with the running binary version, by comparing
|
||||
// the current binary version against a stamp file written when skills
|
||||
// are last synced (by `lark-cli update`). On mismatch it stores a
|
||||
// notice for injection into JSON envelopes via output.PendingNotice.
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// StaleNotice signals that the locally synced skills version does not
|
||||
// match the running binary. Current is the last successfully synced
|
||||
// version (or "" when never synced); Target is the running binary
|
||||
// version. Mirrors internal/update.UpdateInfo's pending-notice pattern.
|
||||
type StaleNotice struct {
|
||||
Current string `json:"current"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
// Message returns a single-line, AI-agent-parseable description of the
|
||||
// gap plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
|
||||
// in style ("..., run: lark-cli update" suffix).
|
||||
func (s *StaleNotice) Message() string {
|
||||
if s.Current == "" {
|
||||
return "lark-cli skills not installed, run: lark-cli update"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",
|
||||
s.Current, s.Target,
|
||||
)
|
||||
}
|
||||
|
||||
// pending stores the latest stale notice for the current process.
|
||||
var pending atomic.Pointer[StaleNotice]
|
||||
|
||||
// SetPending stores the stale notice for consumption by output decorators.
|
||||
// Pass nil to clear.
|
||||
func SetPending(n *StaleNotice) { pending.Store(n) }
|
||||
|
||||
// GetPending returns the pending stale notice, or nil.
|
||||
func GetPending() *StaleNotice { return pending.Load() }
|
||||
71
internal/skillscheck/notice_test.go
Normal file
71
internal/skillscheck/notice_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStaleNotice_Message(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
n StaleNotice
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"cold_start",
|
||||
StaleNotice{Current: "", Target: "1.0.21"},
|
||||
"lark-cli skills not installed, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
"drift",
|
||||
StaleNotice{Current: "1.0.20", Target: "1.0.21"},
|
||||
"lark-cli skills 1.0.20 out of sync with binary 1.0.21, run: lark-cli update",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.n.Message(); got != tt.want {
|
||||
t.Errorf("Message() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetPending(t *testing.T) {
|
||||
SetPending(nil)
|
||||
t.Cleanup(func() { SetPending(nil) })
|
||||
|
||||
if got := GetPending(); got != nil {
|
||||
t.Fatalf("initial GetPending() = %+v, want nil", got)
|
||||
}
|
||||
|
||||
want := &StaleNotice{Current: "1.0.20", Target: "1.0.21"}
|
||||
SetPending(want)
|
||||
got := GetPending()
|
||||
if got == nil || got.Current != "1.0.20" || got.Target != "1.0.21" {
|
||||
t.Errorf("GetPending() = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetPending_Concurrent(t *testing.T) {
|
||||
SetPending(nil)
|
||||
t.Cleanup(func() { SetPending(nil) })
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
SetPending(&StaleNotice{Current: "a", Target: "b"})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = GetPending()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
// Just verifying no race; -race flag enforces.
|
||||
}
|
||||
27
internal/skillscheck/skip.go
Normal file
27
internal/skillscheck/skip.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
// shouldSkip returns true when the skills check should be silently
|
||||
// suppressed. Mirrors internal/update.shouldSkip semantics but uses
|
||||
// a dedicated opt-out env var so users can disable the skills nag
|
||||
// without also disabling the binary update nag.
|
||||
func shouldSkip(version string) bool {
|
||||
if os.Getenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER") != "" {
|
||||
return true
|
||||
}
|
||||
if update.IsCIEnv() {
|
||||
return true
|
||||
}
|
||||
if version == "DEV" || version == "dev" || version == "" {
|
||||
return true
|
||||
}
|
||||
return !update.IsRelease(version)
|
||||
}
|
||||
68
internal/skillscheck/skip_test.go
Normal file
68
internal/skillscheck/skip_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// clearSkillsSkipEnv unsets the env vars shouldSkip checks so the
|
||||
// host environment cannot pollute test results.
|
||||
func clearSkillsSkipEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, key := range []string{"LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "CI", "BUILD_NUMBER", "RUN_ID"} {
|
||||
t.Setenv(key, "")
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T)
|
||||
version string
|
||||
want bool
|
||||
}{
|
||||
{"release_no_skip", clearSkillsSkipEnv, "1.0.21", false},
|
||||
{"dev_uppercase", clearSkillsSkipEnv, "DEV", true},
|
||||
{"dev_lowercase", clearSkillsSkipEnv, "dev", true},
|
||||
{"empty_version", clearSkillsSkipEnv, "", true},
|
||||
{"git_describe", clearSkillsSkipEnv, "1.0.0-12-g9b933f1-dirty", true},
|
||||
{"opt_out", func(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
|
||||
}, "1.0.21", true},
|
||||
{"ci_env", func(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
t.Setenv("CI", "true")
|
||||
}, "1.0.21", true},
|
||||
{"build_number_env", func(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
t.Setenv("BUILD_NUMBER", "42")
|
||||
}, "1.0.21", true},
|
||||
{"run_id_env", func(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
t.Setenv("RUN_ID", "abc")
|
||||
}, "1.0.21", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.setup(t)
|
||||
if got := shouldSkip(tt.version); got != tt.want {
|
||||
t.Errorf("shouldSkip(%q) = %v, want %v", tt.version, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Independent opt-out: LARKSUITE_CLI_NO_SKILLS_NOTIFIER must NOT be
|
||||
// affected by LARKSUITE_CLI_NO_UPDATE_NOTIFIER (different env vars).
|
||||
func TestShouldSkip_OptOutIsIndependent(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1") // update opt-out, not us
|
||||
if shouldSkip("1.0.21") {
|
||||
t.Error("shouldSkip(release) = true with only LARKSUITE_CLI_NO_UPDATE_NOTIFIER set, want false")
|
||||
}
|
||||
}
|
||||
49
internal/skillscheck/stamp.go
Normal file
49
internal/skillscheck/stamp.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const stampFile = "skills.stamp"
|
||||
|
||||
// stampPath returns ~/.lark-cli/skills.stamp.
|
||||
// Uses the BASE config dir (not workspace-aware) because skills install
|
||||
// globally via `npx -g`; per-workspace tracking would produce false
|
||||
// drift signals when switching workspaces.
|
||||
func stampPath() string {
|
||||
return filepath.Join(core.GetBaseConfigDir(), stampFile)
|
||||
}
|
||||
|
||||
// ReadStamp returns the version recorded in the stamp file. Returns
|
||||
// ("", nil) when the file does not exist (interpreted as "never synced").
|
||||
// Other I/O errors are returned as-is so callers can fail closed.
|
||||
func ReadStamp() (string, error) {
|
||||
data, err := vfs.ReadFile(stampPath())
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
// WriteStamp records `version` as the last successfully synced skills
|
||||
// version. Atomic via tmp + rename (validate.AtomicWrite). Creates
|
||||
// the base config directory if it does not exist.
|
||||
func WriteStamp(version string) error {
|
||||
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(stampPath(), []byte(version), 0o644)
|
||||
}
|
||||
113
internal/skillscheck/stamp_test.go
Normal file
113
internal/skillscheck/stamp_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadStamp_Missing(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
got, err := ReadStamp()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadStamp() err = %v, want nil for ENOENT", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("ReadStamp() = %q, want \"\" for missing file", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStamp_Normal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadStamp()
|
||||
if err != nil || got != "1.0.21" {
|
||||
t.Errorf("ReadStamp() = (%q, %v), want (\"1.0.21\", nil)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStamp_TrailingNewlineTolerated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := ReadStamp()
|
||||
if got != "1.0.21" {
|
||||
t.Errorf("ReadStamp() = %q, want \"1.0.21\" (newline trimmed)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStamp_EmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte(""), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadStamp()
|
||||
if err != nil || got != "" {
|
||||
t.Errorf("ReadStamp() = (%q, %v), want (\"\", nil)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStamp_CreatesDir(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatalf("WriteStamp() = %v, want nil", err)
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
|
||||
if string(got) != "1.0.21" {
|
||||
t.Errorf("file content = %q, want \"1.0.21\"", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStamp_OverwritesExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := ReadStamp()
|
||||
if got != "1.0.21" {
|
||||
t.Errorf("ReadStamp() after overwrite = %q, want \"1.0.21\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStamp_NoTrailingNewline(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
|
||||
if string(raw) != "1.0.21" {
|
||||
t.Errorf("raw file = %q, want exactly \"1.0.21\" (no newline)", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteStamp_MkdirAllFailure verifies WriteStamp returns the mkdir error
|
||||
// when the base config dir cannot be created (parent path is a regular file).
|
||||
func TestWriteStamp_MkdirAllFailure(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
blocker := filepath.Join(tmp, "blocker")
|
||||
// Create a regular file where MkdirAll wants to create a directory.
|
||||
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Point the config dir at a path UNDER the regular file — MkdirAll must fail.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(blocker, "child"))
|
||||
|
||||
if err := WriteStamp("1.0.21"); err == nil {
|
||||
t.Fatal("WriteStamp() = nil, want non-nil error from MkdirAll failure")
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,12 @@ type UpdateInfo struct {
|
||||
Latest string `json:"latest"`
|
||||
}
|
||||
|
||||
// Message returns a concise update notification.
|
||||
// Message returns a concise update notification including the canonical
|
||||
// fix command. Aligned with skillscheck.StaleNotice.Message style so
|
||||
// AI agents can parse a unified "run: lark-cli update" hint across
|
||||
// both notice types.
|
||||
func (u *UpdateInfo) Message() string {
|
||||
return fmt.Sprintf("lark-cli %s available, current %s", u.Latest, u.Current)
|
||||
return fmt.Sprintf("lark-cli %s available, current %s, run: lark-cli update", u.Latest, u.Current)
|
||||
}
|
||||
|
||||
// pending stores the latest update info for the current process.
|
||||
@@ -111,10 +114,8 @@ func shouldSkip(version string) bool {
|
||||
return true
|
||||
}
|
||||
// Suppress in CI environments.
|
||||
for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} {
|
||||
if os.Getenv(key) != "" {
|
||||
return true
|
||||
}
|
||||
if IsCIEnv() {
|
||||
return true
|
||||
}
|
||||
// No version info at all — can't compare.
|
||||
if version == "DEV" || version == "dev" || version == "" {
|
||||
@@ -141,6 +142,24 @@ func isRelease(version string) bool {
|
||||
return !gitDescribePattern.MatchString(v)
|
||||
}
|
||||
|
||||
// IsRelease reports whether version looks like a clean published release
|
||||
// (semver "1.0.0", or npm prerelease "1.0.0-beta.1") and not a git-describe
|
||||
// dev build like "1.0.0-12-g9b933f1-dirty". Exported so internal/skillscheck
|
||||
// can apply the same release-only gating without duplicating the regex.
|
||||
func IsRelease(version string) bool { return isRelease(version) }
|
||||
|
||||
// IsCIEnv returns true when any of the standard CI environment variables
|
||||
// is set. Exported for internal/skillscheck so its skip rules track the
|
||||
// same CI-suppression behavior as the update notifier.
|
||||
func IsCIEnv() bool {
|
||||
for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} {
|
||||
if os.Getenv(key) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- state file I/O ---
|
||||
|
||||
func statePath() string {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -143,28 +142,27 @@ func TestShouldSkip(t *testing.T) {
|
||||
|
||||
func TestIsRelease(t *testing.T) {
|
||||
tests := []struct {
|
||||
version string
|
||||
want bool
|
||||
name string
|
||||
ver string
|
||||
want bool
|
||||
}{
|
||||
{"1.0.0", true},
|
||||
{"v1.0.0", true},
|
||||
{"0.1.0", true},
|
||||
{"1.0.0-beta.1", true},
|
||||
{"1.0.0-rc.1", true},
|
||||
{"2.0.0-alpha.0", true},
|
||||
{"v1.0.0-12-g9b933f1", false}, // git describe
|
||||
{"v1.0.0-12-g9b933f1-dirty", false}, // git describe dirty
|
||||
{"v2.1.0-3-gabcdef0", false}, // git describe short
|
||||
{"9b933f1", false}, // bare commit hash
|
||||
{"DEV", false}, // dev marker
|
||||
{"", false}, // empty
|
||||
{"1.0", false}, // incomplete semver
|
||||
{"clean_semver", "1.0.0", true},
|
||||
{"v_prefix", "v1.0.0", true},
|
||||
{"prerelease", "1.0.0-beta.1", true},
|
||||
{"rc", "1.0.0-rc.1", true},
|
||||
{"alpha_prerelease", "2.0.0-alpha.0", true},
|
||||
{"git_describe_dirty", "1.0.0-12-g9b933f1-dirty", false},
|
||||
{"git_describe_clean", "1.0.0-12-g9b933f1", false},
|
||||
{"bare_commit_hash", "9b933f1", false},
|
||||
{"dev_marker", "DEV", false},
|
||||
{"incomplete_semver", "1.0", false},
|
||||
{"empty", "", false},
|
||||
{"invalid", "not-a-version", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.version, func(t *testing.T) {
|
||||
got := isRelease(tt.version)
|
||||
if got != tt.want {
|
||||
t.Errorf("isRelease(%q) = %v, want %v", tt.version, got, tt.want)
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsRelease(tt.ver); got != tt.want {
|
||||
t.Errorf("IsRelease(%q) = %v, want %v", tt.ver, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -172,13 +170,10 @@ func TestIsRelease(t *testing.T) {
|
||||
|
||||
func TestUpdateInfoMethods(t *testing.T) {
|
||||
info := &UpdateInfo{Current: "1.0.0", Latest: "2.0.0"}
|
||||
|
||||
msg := info.Message()
|
||||
if !strings.Contains(msg, "2.0.0") {
|
||||
t.Errorf("Message() missing latest version: %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "1.0.0") {
|
||||
t.Errorf("Message() missing current version: %s", msg)
|
||||
got := info.Message()
|
||||
want := "lark-cli 2.0.0 available, current 1.0.0, run: lark-cli update"
|
||||
if got != want {
|
||||
t.Errorf("Message() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,3 +259,19 @@ func TestPendingAtomicAccess(t *testing.T) {
|
||||
// Clean up for other tests
|
||||
SetPending(nil)
|
||||
}
|
||||
|
||||
func TestIsCIEnv(t *testing.T) {
|
||||
clearSkipEnv(t)
|
||||
if IsCIEnv() {
|
||||
t.Fatal("IsCIEnv() = true after clearSkipEnv, want false")
|
||||
}
|
||||
for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
clearSkipEnv(t)
|
||||
t.Setenv(key, "1")
|
||||
if !IsCIEnv() {
|
||||
t.Errorf("IsCIEnv() = false with %s=1, want true", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ package util
|
||||
|
||||
// TruncateStr truncates s to at most n runes, safe for multi-byte (e.g. CJK) characters.
|
||||
func TruncateStr(s string, n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
@@ -14,6 +17,9 @@ func TruncateStr(s string, n int) string {
|
||||
|
||||
// TruncateStrWithEllipsis truncates s to at most n runes (including "..." suffix).
|
||||
func TruncateStrWithEllipsis(s string, n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
|
||||
@@ -17,6 +17,7 @@ func TestTruncateStr(t *testing.T) {
|
||||
{"truncate", "hello world", 5, "hello"},
|
||||
{"empty", "", 5, ""},
|
||||
{"zero limit", "hello", 0, ""},
|
||||
{"negative limit", "hello", -1, ""},
|
||||
{"CJK characters", "你好世界测试", 4, "你好世界"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@@ -41,6 +42,8 @@ func TestTruncateStrWithEllipsis(t *testing.T) {
|
||||
{"limit less than 3", "hello", 2, "he"},
|
||||
{"limit equals 3", "hello world", 3, "..."},
|
||||
{"empty", "", 5, ""},
|
||||
{"zero limit", "hello", 0, ""},
|
||||
{"negative limit", "hello", -1, ""},
|
||||
{"CJK with ellipsis", "你好世界测试", 5, "你好..."},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.22",
|
||||
"version": "1.0.26",
|
||||
"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",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -20,6 +19,9 @@ func handleBaseAPIResult(result interface{}, err error, action string) (map[stri
|
||||
return dataMap, nil
|
||||
}
|
||||
|
||||
// handleBaseAPIResultAny normalizes the Base v3 {code,msg,data} envelope used
|
||||
// by shortcut APIs. Success returns data as-is; API failures become the CLI's
|
||||
// structured ErrAPI, with server-provided message/hint promoted to the top level.
|
||||
func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
|
||||
@@ -37,17 +39,34 @@ func handleBaseAPIResultAny(result interface{}, err error, action string) (inter
|
||||
msg, _ = resultMap["msg"].(string)
|
||||
}
|
||||
|
||||
fullMsg := fmt.Sprintf("%s: [%d] %s", action, larkCode, msg)
|
||||
detail := extractErrorDetail(resultMap)
|
||||
apiErr := output.ErrAPI(larkCode, fullMsg, detail)
|
||||
if apiErr.Detail != nil && apiErr.Detail.Hint == "" {
|
||||
if hint := extractErrorHint(resultMap); hint != "" {
|
||||
apiErr.Detail.Hint = hint
|
||||
}
|
||||
apiErr := output.ErrAPI(larkCode, msg, detail)
|
||||
hint := extractErrorHint(resultMap)
|
||||
if apiErr.Detail != nil && apiErr.Detail.Hint == "" && hint != "" {
|
||||
apiErr.Detail.Hint = hint
|
||||
}
|
||||
if apiErr.Detail != nil {
|
||||
apiErr.Detail.Detail = cleanEmptyBaseErrorDetail(detail)
|
||||
}
|
||||
return nil, apiErr
|
||||
}
|
||||
|
||||
func cleanEmptyBaseErrorDetail(detail interface{}) interface{} {
|
||||
detailMap, ok := detail.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for key, value := range detailMap {
|
||||
if value == nil {
|
||||
delete(detailMap, key)
|
||||
}
|
||||
}
|
||||
if len(detailMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
return detailMap
|
||||
}
|
||||
|
||||
func extractErrorDetail(resultMap map[string]interface{}) interface{} {
|
||||
if detail, ok := nonNilMapValue(resultMap, "error"); ok {
|
||||
return detail
|
||||
@@ -77,13 +96,13 @@ func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool)
|
||||
|
||||
func extractErrorHint(resultMap map[string]interface{}) string {
|
||||
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
|
||||
if hint, _ := detail["hint"].(string); strings.TrimSpace(hint) != "" {
|
||||
if hint := consumeStringField(detail, "hint"); hint != "" {
|
||||
return hint
|
||||
}
|
||||
}
|
||||
data, _ := resultMap["data"].(map[string]interface{})
|
||||
if detail, ok := data["error"].(map[string]interface{}); ok {
|
||||
if hint, _ := detail["hint"].(string); strings.TrimSpace(hint) != "" {
|
||||
if hint := consumeStringField(detail, "hint"); hint != "" {
|
||||
return hint
|
||||
}
|
||||
}
|
||||
@@ -93,9 +112,17 @@ func extractErrorHint(resultMap map[string]interface{}) string {
|
||||
func extractDataErrorMessage(resultMap map[string]interface{}) string {
|
||||
data, _ := resultMap["data"].(map[string]interface{})
|
||||
if detail, ok := data["error"].(map[string]interface{}); ok {
|
||||
if message, _ := detail["message"].(string); strings.TrimSpace(message) != "" {
|
||||
if message := consumeStringField(detail, "message"); message != "" {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func consumeStringField(src map[string]interface{}, key string) string {
|
||||
value, _ := src[key].(string)
|
||||
if _, exists := src[key]; exists {
|
||||
delete(src, key)
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestErrorDetailHelpers(t *testing.T) {
|
||||
@@ -47,14 +50,133 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
|
||||
"error": map[string]interface{}{"message": "invalid filter", "hint": "check field name"},
|
||||
},
|
||||
}
|
||||
if _, err := handleBaseAPIResultAny(result, nil, "set filter"); err == nil || !strings.Contains(err.Error(), "invalid filter") || !strings.Contains(err.Error(), "190001") {
|
||||
if _, err := handleBaseAPIResultAny(result, nil, "set filter"); err == nil || !strings.Contains(err.Error(), "invalid filter") {
|
||||
t.Fatalf("err=%v", err)
|
||||
} else {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != 190001 {
|
||||
t.Fatalf("expected structured code 190001, got %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := handleBaseAPIResult(result, nil, "set filter"); err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBaseAPIResultCleansBaseErrorDetail(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"code": 800010407,
|
||||
"msg": "cell value invalid",
|
||||
"data": map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"docs_url": nil,
|
||||
"hint": "Provide a number value.",
|
||||
"level": "error",
|
||||
"logid": "20260508160000000000000000000000",
|
||||
"message": "The cell value does not match the expected input shape.",
|
||||
"path": "Amount",
|
||||
"retry_after_ms": nil,
|
||||
"retryable": false,
|
||||
"extra_context": "future detail field",
|
||||
"table": map[string]interface{}{"id": "tbl_1", "name": "Orders"},
|
||||
"type": "invalid_request",
|
||||
"upstream_code": nil,
|
||||
"value": "abc",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
|
||||
errDetail := exitErr.Detail
|
||||
if errDetail.Code != 800010407 {
|
||||
t.Fatalf("code=%d", errDetail.Code)
|
||||
}
|
||||
if errDetail.Hint != "Provide a number value." {
|
||||
t.Fatalf("hint=%q", errDetail.Hint)
|
||||
}
|
||||
detail, _ := errDetail.Detail.(map[string]interface{})
|
||||
if detail == nil {
|
||||
t.Fatalf("expected cleaned detail, got %#v", errDetail.Detail)
|
||||
}
|
||||
if _, exists := detail["message"]; exists {
|
||||
t.Fatalf("detail should not repeat message: %#v", detail)
|
||||
}
|
||||
if _, exists := detail["hint"]; exists {
|
||||
t.Fatalf("detail should not repeat hint: %#v", detail)
|
||||
}
|
||||
if _, exists := detail["docs_url"]; exists {
|
||||
t.Fatalf("detail should omit nil docs_url: %#v", detail)
|
||||
}
|
||||
if detail["level"] != "error" {
|
||||
t.Fatalf("detail should preserve non-duplicate fields: %#v", detail)
|
||||
}
|
||||
if detail["extra_context"] != "future detail field" {
|
||||
t.Fatalf("detail should pass through unknown non-nil fields: %#v", detail)
|
||||
}
|
||||
if detail["path"] != "Amount" || detail["value"] != "abc" {
|
||||
t.Fatalf("cleaned detail mismatch: %#v", detail)
|
||||
}
|
||||
if detail["logid"] != "20260508160000000000000000000000" {
|
||||
t.Fatalf("logid=%q", detail["logid"])
|
||||
}
|
||||
if retryable, ok := detail["retryable"].(bool); !ok || retryable {
|
||||
t.Fatalf("retryable=%v", detail["retryable"])
|
||||
}
|
||||
table, _ := detail["table"].(map[string]interface{})
|
||||
if table["id"] != "tbl_1" || table["name"] != "Orders" {
|
||||
t.Fatalf("table=%#v", detail["table"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBaseAPIResultAlwaysRemovesMessageAndHintFromDetail(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"code": output.LarkErrTokenNoPermission,
|
||||
"msg": "permission denied",
|
||||
"data": map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"hint": "Grant base:record:read to the app.",
|
||||
"message": "Missing required scope base:record:read.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Message != "Permission denied [99991676]" {
|
||||
t.Fatalf("message=%q", exitErr.Detail.Message)
|
||||
}
|
||||
if exitErr.Detail.Detail != nil {
|
||||
t.Fatalf("detail should be empty after removing message and hint: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachBaseResponseLogIDFromHeader(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"code": 91402,
|
||||
"msg": "NOTEXIST",
|
||||
"data": map[string]interface{}{},
|
||||
}
|
||||
attachBaseErrorLogID(result, "20260508170000000000000000000000")
|
||||
|
||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if detail["logid"] != "20260508170000000000000000000000" {
|
||||
t.Fatalf("logid=%q", detail["logid"])
|
||||
}
|
||||
}
|
||||
|
||||
type assertErr struct{}
|
||||
|
||||
func (assertErr) Error() string { return "network timeout" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -412,6 +412,11 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, parseErr := decodeBaseV3Response(resp.RawBody)
|
||||
if parseErr == nil && baseV3ResultCode(result) != 0 {
|
||||
attachBaseErrorLogID(result, baseResponseLogID(resp))
|
||||
return result, nil
|
||||
}
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
body := strings.TrimSpace(string(resp.RawBody))
|
||||
if body == "" {
|
||||
@@ -419,8 +424,15 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s
|
||||
}
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeBaseV3Response(body []byte) (map[string]interface{}, error) {
|
||||
var result map[string]interface{}
|
||||
dec := json.NewDecoder(bytes.NewReader(resp.RawBody))
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("response parse error: %w", err)
|
||||
@@ -428,6 +440,46 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func baseV3ResultCode(result map[string]interface{}) int {
|
||||
if result == nil {
|
||||
return 0
|
||||
}
|
||||
return toInt(result["code"])
|
||||
}
|
||||
|
||||
func attachBaseErrorLogID(result map[string]interface{}, logID string) {
|
||||
if result == nil || strings.TrimSpace(logID) == "" {
|
||||
return
|
||||
}
|
||||
logID = strings.TrimSpace(logID)
|
||||
if detail, ok := result["error"].(map[string]interface{}); ok {
|
||||
if _, exists := detail["logid"]; !exists {
|
||||
detail["logid"] = logID
|
||||
}
|
||||
return
|
||||
}
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
result["data"] = data
|
||||
}
|
||||
detail, _ := data["error"].(map[string]interface{})
|
||||
if detail == nil {
|
||||
detail = map[string]interface{}{}
|
||||
data["error"] = detail
|
||||
}
|
||||
if _, exists := detail["logid"]; !exists {
|
||||
detail["logid"] = logID
|
||||
}
|
||||
}
|
||||
|
||||
func baseResponseLogID(resp *larkcore.ApiResp) string {
|
||||
if resp == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(resp.Header.Get("x-tt-logid"))
|
||||
}
|
||||
|
||||
func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
||||
result, err := baseV3Raw(runtime, method, path, params, data)
|
||||
return handleBaseAPIResult(result, err, "API call failed")
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user