Compare commits

...

8 Commits

Author SHA1 Message Date
liangshuo-1
ac85c3e34d chore(release): v1.0.38 (#1026)
- Bump version to 1.0.38
- Update CHANGELOG.md with the apps brand gating change since v1.0.37
- Backfill the [v1.0.38] link reference at the bottom of CHANGELOG.md

Change-Id: I6fd0d1243e2219a1eaa1fae5fae4ff6d8de361da
2026-05-22 03:20:21 +08:00
liangshuo-1
daba3c9afd feat(apps): gate apps domain off on Lark brand (#1025)
* feat(apps): gate apps domain off on Lark brand

The Miaoda apps OpenAPI is Feishu-only. On Lark brand:

- shortcut subtree is registered + hidden, RunE returns a structured
  brand-restriction error so users see a clear message instead of
  cobra's generic "unknown command"
- auth login `--domain apps` is treated as unknown; `--domain all`
  skips apps; help text omits it
- scope collection skips apps shortcuts so spark:* scopes are never
  requested

The leaf-stub pattern mirrors internal/cmdpolicy/apply.go::installDenyStub
(DisableFlagParsing + ArbitraryArgs + leaf-level PersistentPreRunE
override) so cobra can't short-circuit the stub with a missing-flag or
parent-PreRunE detour.

Change-Id: I5817e87ae6fedabdb5faf05d0d32ea988f7effc9
2026-05-22 03:03:41 +08:00
wangweiming-01
e54220ade1 feat: support files in drive +add-comment (#975)
* feat: support markdown files in drive +add-comment

Change-Id: Id9a87706a1e43756d8142637be9ec1e0748d4ddf

* fix: use markdown file comment anchor placeholder

Change-Id: Ifffc4cdd963c13e53f4cad154aebe11ae309df9e

* fix: gate drive file comments by supported extensions

Change-Id: Ie6c7f38dbbea1f87a81600da71180627b53a2355
2026-05-21 21:40:27 +08:00
liangshuo-1
d3fbc88527 chore(release): v1.0.37 (#1021)
Change-Id: Ifcc78649e294d516015846d746bb2bc65b239eb3
2026-05-21 20:44:23 +08:00
liujinkun2025
652e96906c feat(wiki): add +member-add / +member-remove / +member-list shortcuts (#997)
- +member-add: wrap POST /spaces/{id}/members; --member-type / --member-role
  enums, optional --need-notification query (omitted entirely when the flag
  is unset, instead of forcing need_notification=false), my_library
  resolution under --as user, flattened single-member output
- +member-remove: wrap DELETE /spaces/{id}/members/{member_id}; surfaces the
  required member_type + member_role body the API expects, my_library
  resolution, fallback to echoing the caller's inputs when the API omits
  the member echo
- +member-list: wrap GET /spaces/{id}/members; reuses the +space-list /
  +node-list pagination contract (single page by default, --page-all walks
  every page capped by --page-limit, --page-token resumes a cursor)
- All three reject bot identity + my_library upfront with a clear hint and
  declare the narrowest scope the API accepts (wiki:member:create /
  wiki:member:update / wiki:member:retrieve) so tokens carrying only the
  narrow scope are not false-rejected by the exact-string preflight
- skill docs: reference pages for the three new shortcuts + SKILL.md
  shortcuts table; switch the membership flow guidance from raw
  `wiki members create` to the new +member-add path

Change-Id: I158a86aa7f00bb7cecc7a4e99346f3fb151b3c09
2026-05-21 20:40:55 +08:00
raistlin042
6cea6c9af0 feat(apps): add miaoda apps domain (6 shortcuts + dry-run e2e) (#1002)
Adds the apps domain to lark-cli for managing Miaoda (妙搭) applications: 6 shortcuts covering the full lifecycle (+create / +update / +list / +access-scope-set / +access-scope-get / +html-publish). Aligned with the OAPI v2 design — app_type enum (currently HTML), string scope enum (All / Tenant / Range), cursor pagination, in-memory tar.gz multipart publish flow. Namespace registered at /open-apis/spark/v1/ with spark:app.* scopes.

---------

Co-authored-by: wangjiangwen-gif <286006750+wangjiangwen-gif@users.noreply.github.com>
2026-05-21 20:30:42 +08:00
fangshuyu-768
816927f8b8 fix: surface auto-grant failures via stderr and JSON hint (#1015)
When a resource is created with bot identity, the CLI attempts to
auto-grant full_access to the current user. If the user open_id is
missing or the grant API call fails, the result was only written to
the JSON permission_grant field and easily overlooked.

Changes:
- Add stderr warnings when auto-grant is skipped or fails
- Add 'hint' field to permission_grant JSON output with failure reason
  and actionable next step (e.g. auth login, check scope, retry)
- Add end-to-end skipped/failed tests across all affected shortcuts
  (doc, drive, sheets, slides, wiki, markdown, base)

Closes #963
2026-05-21 18:17:24 +08:00
caojie0621
56749e70cb fix(sheets): use FileIO for write-image input (#996) 2026-05-21 15:53:44 +08:00
79 changed files with 7468 additions and 159 deletions

View File

@@ -2,6 +2,23 @@
All notable changes to this project will be documented in this file.
## [v1.0.38] - 2026-05-22
### Features
- **apps**: Gate the Miaoda apps domain off on the Lark brand — the `apps` shortcut subtree returns a structured brand-restriction error, `auth login --domain apps` is rejected, `--domain all` skips it, and `spark:*` scopes are no longer requested (#1025)
## [v1.0.37] - 2026-05-21
### Features
- **apps**: Add miaoda apps domain with 6 shortcuts covering `+create` / `+update` / `+list` / `+access-scope-get` / `+access-scope-set` / `+html-publish` (#1002)
### Bug Fixes
- **permission**: Surface auto-grant skipped/failed cases via stderr warnings and a `hint` field in the `permission_grant` JSON output (#1015)
- **sheets**: Use `FileIO` for `+write-image` input so stdin / `-` works consistently (#996)
## [v1.0.36] - 2026-05-21
### Features
@@ -806,6 +823,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38
[v1.0.37]: https://github.com/larksuite/cli/releases/tag/v1.0.37
[v1.0.36]: https://github.com/larksuite/cli/releases/tag/v1.0.36
[v1.0.35]: https://github.com/larksuite/cli/releases/tag/v1.0.35
[v1.0.34]: https://github.com/larksuite/cli/releases/tag/v1.0.34
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33

View File

@@ -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, Markdown, and more, with 200+ commands and 24 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 26 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** — 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/)
- **Wide Coverage** — 18 business domains, 200+ curated commands, 26 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
@@ -41,6 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
## Installation & Quick Start

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 26 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
- **为 Agent 原生设计** — 26 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 18 大业务域、200+ 精选命令、26 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -41,6 +41,7 @@
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
## 安装与快速开始

View File

@@ -68,7 +68,13 @@ run --device-code in a later step after the user confirms authorization.`,
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
available := sortedKnownDomains()
var helpBrand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
helpBrand = cfg.Brand
}
}
available := sortedKnownDomains(helpBrand)
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
@@ -139,14 +145,14 @@ func authLoginRun(opts *LoginOptions) error {
// Expand --domain all to all available domains (from_meta projects + shortcut services)
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
selectedDomains = sortedKnownDomains()
selectedDomains = sortedKnownDomains(config.Brand)
break
}
}
// Validate domain names and suggest corrections for unknown ones
if len(selectedDomains) > 0 {
knownDomains := allKnownDomains()
knownDomains := allKnownDomains(config.Brand)
for _, d := range selectedDomains {
if !knownDomains[d] {
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
@@ -170,7 +176,7 @@ func authLoginRun(opts *LoginOptions) error {
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand)
if err != nil {
return err
}
@@ -208,10 +214,10 @@ func authLoginRun(opts *LoginOptions) error {
if len(selectedDomains) > 0 || opts.Recommend {
var candidateScopes []string
if len(selectedDomains) > 0 {
candidateScopes = collectScopesForDomains(selectedDomains, "user")
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
} else {
// --recommend without --domain: all domains
candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user")
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
}
// Filter to auto-approve scopes if --recommend or interactive "common"
@@ -490,7 +496,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
// shortcut scopes for the given domain names.
// Domains with auth_domain children are automatically expanded to include
// their children's scopes.
func collectScopesForDomains(domains []string, identity string) []string {
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
scopeSet := make(map[string]bool)
// 1. API scopes from from_meta projects
@@ -509,6 +515,9 @@ func collectScopesForDomains(domains []string, identity string) []string {
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
for _, s := range sc.DeclaredScopesForIdentity(identity) {
scopeSet[s] = true
@@ -528,7 +537,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
// allKnownDomains returns all valid auth domain names (from_meta projects +
// shortcut services), excluding domains that have auth_domain set (they are
// folded into their parent domain).
func allKnownDomains() map[string]bool {
func allKnownDomains(brand core.LarkBrand) map[string]bool {
domains := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
if !registry.HasAuthDomain(p) {
@@ -536,6 +545,9 @@ func allKnownDomains() map[string]bool {
}
}
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
if !registry.HasAuthDomain(sc.Service) {
domains[sc.Service] = true
}
@@ -544,8 +556,8 @@ func allKnownDomains() map[string]bool {
}
// sortedKnownDomains returns all valid domain names sorted alphabetically.
func sortedKnownDomains() []string {
m := allKnownDomains()
func sortedKnownDomains(brand core.LarkBrand) []string {
m := allKnownDomains(brand)
domains := make([]string, 0, len(m))
for d := range m {
domains = append(domains, d)

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
feishuDomains := allKnownDomains(core.BrandFeishu)
if !feishuDomains["apps"] {
t.Errorf("expected apps domain to be known on Feishu brand")
}
larkDomains := allKnownDomains(core.BrandLark)
if larkDomains["apps"] {
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
}
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
if len(feishuScopes) == 0 {
t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes))
}
larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark)
if len(larkScopes) != 0 {
t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes)
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/charmbracelet/huh"
"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"
@@ -105,7 +106,7 @@ func buildDomainMeta(name, lang string) domainMeta {
}
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*interactiveResult, error) {
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
allDomains := getDomainMetadata(lang)
// Build multi-select options
@@ -165,7 +166,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i
}
// Compute scope summary
scopes := collectScopesForDomains(selectedDomains, "user")
scopes := collectScopesForDomains(selectedDomains, "user", brand)
if permLevel == "common" {
scopes = registry.FilterAutoApproveScopes(scopes)
}

View File

@@ -125,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", "markdown"}
return []string{"base", "contact", "docs", "markdown", "apps"}
}

View File

@@ -171,7 +171,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) {
}
func TestAllKnownDomains(t *testing.T) {
domains := allKnownDomains()
domains := allKnownDomains("")
if len(domains) == 0 {
t.Fatal("expected non-empty known domains")
}
@@ -185,7 +185,7 @@ func TestAllKnownDomains(t *testing.T) {
}
func TestSortedKnownDomains(t *testing.T) {
sorted := sortedKnownDomains()
sorted := sortedKnownDomains("")
if len(sorted) == 0 {
t.Fatal("expected non-empty sorted domains")
}
@@ -195,7 +195,7 @@ func TestSortedKnownDomains(t *testing.T) {
}
// Should match allKnownDomains
known := allKnownDomains()
known := allKnownDomains("")
if len(sorted) != len(known) {
t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known))
}
@@ -220,7 +220,7 @@ func TestCollectScopesForDomains(t *testing.T) {
t.Skip("no from_meta data available")
}
scopes := collectScopesForDomains([]string{"calendar"}, "user")
scopes := collectScopesForDomains([]string{"calendar"}, "user", "")
if len(scopes) == 0 {
t.Fatal("expected non-empty scopes for calendar domain")
}
@@ -247,7 +247,7 @@ func TestCollectScopesForDomains(t *testing.T) {
}
func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user")
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user", "")
if len(scopes) != 0 {
t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes))
}
@@ -1077,7 +1077,7 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
}
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
domains := allKnownDomains()
domains := allKnownDomains("")
if domains["whiteboard"] {
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
}
@@ -1087,7 +1087,7 @@ func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
}
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
scopes := collectScopesForDomains([]string{"docs"}, "user")
scopes := collectScopesForDomains([]string{"docs"}, "user", "")
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
found := false
for _, s := range scopes {

View File

@@ -3,6 +3,10 @@
"en": { "title": "Approval", "description": "Approval instance, and task management" },
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
},
"apps": {
"en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" },
"zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" }
},
"base": {
"en": { "title": "Base", "description": "Table, field, record, view, dashboard, workflow, form, role & permission management" },
"zh": { "title": "多维表格", "description": "数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限管理" }

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.36",
"version": "1.0.38",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsAccessScopeGet reads the current access scope configuration of a Miaoda app.
// 响应原样透传服务端契约(字符串 scope 枚举 All/Tenant/Range + 拆分的 users/departments/chats 数组)。
var AppsAccessScopeGet = common.Shortcut{
Service: appsService,
Command: "+access-scope-get",
Description: "Get Miaoda app access scope configuration",
Risk: "read",
Scopes: []string{"spark:app.access_scope:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Get Miaoda app access scope")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPI("GET", path, nil, nil)
if err != nil {
return err
}
// 原样透传 — 保留服务端字符串枚举 (All/Tenant/Range),不合并 users/departments/chats。
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "scope: %v\n", data["scope"])
})
return nil
},
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsAccessScopeGet_Specific(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"scope": "Range",
"users": []interface{}{"ou_x", "ou_y"},
"departments": []interface{}{"od_z"},
"chats": []interface{}{"oc_g"},
"apply_config": map[string]interface{}{
"enabled": true,
"approvers": []interface{}{"ou_appr"},
},
},
},
})
if err := runAppsShortcut(t, AppsAccessScopeGet,
[]string{"+access-scope-get", "--app-id", "app_x", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"scope": "Range"`) {
t.Fatalf("scope string not preserved (expect raw \"Range\"): %s", got)
}
if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od_z"`) || !strings.Contains(got, `"oc_g"`) {
t.Fatalf("users/departments/chats fields missing in envelope: %s", got)
}
if !strings.Contains(got, `"ou_appr"`) {
t.Fatalf("apply_config.approvers missing: %s", got)
}
}
func TestAppsAccessScopeGet_Public(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"scope": "All", "require_login": false},
},
})
if err := runAppsShortcut(t, AppsAccessScopeGet,
[]string{"+access-scope-get", "--app-id", "app_x", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"scope": "All"`) {
t.Fatalf("scope=All missing: %s", got)
}
if !strings.Contains(got, `"require_login": false`) {
t.Fatalf("require_login missing: %s", got)
}
}
func TestAppsAccessScopeGet_Tenant(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"scope": "Tenant"},
},
})
if err := runAppsShortcut(t, AppsAccessScopeGet,
[]string{"+access-scope-get", "--app-id", "app_x", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), `"scope": "Tenant"`) {
t.Fatalf("scope=Tenant missing: %s", stdout.String())
}
}
func TestAppsAccessScopeGet_RequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeGet,
[]string{"+access-scope-get", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "app-id") {
t.Fatalf("expected --app-id required, got %v", err)
}
}
func TestAppsAccessScopeGet_TrimsAppIDInPath(t *testing.T) {
// 与 +update 的 D1.2 修复对称URL 拼接前必须 TrimSpace(app-id)
// 否则 " app_x " 会被 EncodePathSegment 编码进 path segment 出现空格转义。
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"scope": "Tenant"},
},
})
if err := runAppsShortcut(t, AppsAccessScopeGet,
[]string{"+access-scope-get", "--app-id", " app_x ", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}

View File

@@ -0,0 +1,208 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var allowedAccessTargetTypes = map[string]bool{
"user": true,
"department": true,
"chat": true,
}
// AppsAccessScopeSet sets the app's access scope (specific / public / tenant).
var AppsAccessScopeSet = common.Shortcut{
Service: appsService,
Command: "+access-scope-set",
Description: "Set Miaoda app access scope (specific / public / tenant)",
Risk: "write",
Scopes: []string{"spark:app.access_scope:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "scope", Desc: "scope: specific | public | tenant", Required: true, Enum: []string{"specific", "public", "tenant"}},
{Name: "targets", Desc: `targets JSON array: [{"type":"user|department|chat","id":"..."}, ...]`},
{Name: "apply-enabled", Type: "bool", Desc: "allow apply for access (scope=specific)"},
{Name: "approver", Desc: "approver open_id (when --apply-enabled; server allows exactly one)"},
{Name: "require-login", Type: "bool", Desc: "require login (scope=public)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
return validateAccessScopeFlags(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
dry := common.NewDryRunAPI().
PUT(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Set Miaoda app access scope")
body, bodyErr := buildAccessScopeBody(rctx)
if bodyErr != nil {
dry.Set("body_error", bodyErr.Error())
} else {
dry.Body(body)
}
return dry
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
body, err := buildAccessScopeBody(rctx)
if err != nil {
return err
}
appID := strings.TrimSpace(rctx.Str("app-id"))
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPI("PUT", path, nil, body)
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "access-scope set: %s\n", rctx.Str("scope"))
})
return nil
},
}
func validateAccessScopeFlags(rctx *common.RuntimeContext) error {
scope := rctx.Str("scope")
targets := strings.TrimSpace(rctx.Str("targets"))
applyEnabled := rctx.Bool("apply-enabled")
approver := strings.TrimSpace(rctx.Str("approver"))
requireLogin := rctx.Bool("require-login")
switch scope {
case "specific":
if targets == "" {
return output.ErrValidation("--targets is required when --scope=specific")
}
if err := validateTargetsJSON(targets); err != nil {
return err
}
if approver != "" && !applyEnabled {
return output.ErrValidation("--approver requires --apply-enabled")
}
if requireLogin {
return output.ErrValidation("--require-login is not allowed when --scope=specific")
}
case "public":
if targets != "" {
return output.ErrValidation("--targets is not allowed when --scope=public")
}
if applyEnabled {
return output.ErrValidation("--apply-enabled is not allowed when --scope=public")
}
if approver != "" {
return output.ErrValidation("--approver is not allowed when --scope=public")
}
if !rctx.Cmd.Flags().Changed("require-login") {
return output.ErrValidation("--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
}
case "tenant":
if targets != "" || applyEnabled || approver != "" || requireLogin {
return output.ErrValidation("no extra flags allowed when --scope=tenant")
}
default:
return output.ErrValidation("--scope must be specific / public / tenant")
}
return nil
}
func validateTargetsJSON(targetsJSON string) error {
var items []map[string]interface{}
if err := json.Unmarshal([]byte(targetsJSON), &items); err != nil {
return output.ErrValidation("--targets is not valid JSON: %v", err)
}
if len(items) == 0 {
return output.ErrValidation("--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
}
for i, t := range items {
typ, _ := t["type"].(string)
if !allowedAccessTargetTypes[typ] {
return output.ErrValidation("--targets[%d].type %q must be one of: user / department / chat", i, typ)
}
if id, _ := t["id"].(string); strings.TrimSpace(id) == "" {
return output.ErrValidation("--targets[%d].id is empty", i)
}
}
return nil
}
// scopeStringToServerEnum 把 CLI 友好的 scope 字符串映射成后端字符串枚举。
// CLI 用户 / Agent 仍然写 specific / public / tenantbody 里发后端枚举名。
// 后端语义All=互联网公开 / Tenant=组织内 / Range=部分人员。
var scopeStringToServerEnum = map[string]string{
"public": "All",
"tenant": "Tenant",
"specific": "Range",
}
func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
scope := rctx.Str("scope")
enum, ok := scopeStringToServerEnum[scope]
if !ok {
return nil, output.ErrValidation("--scope must be specific / public / tenant, got %q", scope)
}
body := map[string]interface{}{"scope": enum}
switch scope {
case "specific":
// 用户传统一格式 [{type:user|department|chat, id:...}]body 里拆 3 个并列数组发后端。
var targets []map[string]interface{}
if err := json.Unmarshal([]byte(rctx.Str("targets")), &targets); err != nil {
return nil, output.ErrValidation("--targets is not valid JSON: %v", err)
}
users, departments, chats := splitAccessScopeTargets(targets)
if len(users) > 0 {
body["users"] = users
}
if len(departments) > 0 {
body["departments"] = departments
}
if len(chats) > 0 {
body["chats"] = chats
}
if rctx.Bool("apply-enabled") {
applyConfig := map[string]interface{}{"enabled": true}
if approver := strings.TrimSpace(rctx.Str("approver")); approver != "" {
applyConfig["approvers"] = []string{approver}
}
body["apply_config"] = applyConfig
}
case "public":
body["require_login"] = rctx.Bool("require-login")
}
return body, nil
}
// splitAccessScopeTargets 把统一 [{type,id}] 形态拆成后端要求的 users/departments/chats 三个数组。
func splitAccessScopeTargets(targets []map[string]interface{}) (users, departments, chats []string) {
for _, t := range targets {
typ, _ := t["type"].(string)
id, _ := t["id"].(string)
id = strings.TrimSpace(id)
if id == "" {
continue
}
switch typ {
case "user":
users = append(users, id)
case "department":
departments = append(departments, id)
case "chat":
chats = append(chats, id)
}
}
return
}

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsAccessScopeSet_Specific(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"user","id":"ou_xxx"},{"type":"chat","id":"oc_xxx"}]`,
"--apply-enabled",
"--approver", "ou_yyy",
"--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
// 新协议scope 是 string 枚举 (specific=Range)targets 拆成 users/departments/chats
if got, _ := sent["scope"].(string); got != "Range" {
t.Fatalf("scope = %v, want %q", sent["scope"], "Range")
}
if _, present := sent["targets"]; present {
t.Fatalf("legacy 'targets' field should not be sent: %v", sent)
}
users, _ := sent["users"].([]interface{})
if len(users) != 1 || users[0] != "ou_xxx" {
t.Fatalf("users = %v, want [ou_xxx]", sent["users"])
}
chats, _ := sent["chats"].([]interface{})
if len(chats) != 1 || chats[0] != "oc_xxx" {
t.Fatalf("chats = %v, want [oc_xxx]", sent["chats"])
}
if _, present := sent["departments"]; present {
t.Fatalf("departments should be omitted when empty: %v", sent)
}
}
func TestAppsAccessScopeSet_Public(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
if err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set",
"--app-id", "app_x",
"--scope", "public",
"--require-login=false",
"--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}
func TestAppsAccessScopeSet_Tenant(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
if err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set",
"--app-id", "app_x",
"--scope", "tenant",
"--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}
func TestAppsAccessScopeSet_SpecificRequiresTargets(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", "app_x", "--scope", "specific", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "targets") {
t.Fatalf("expected targets required error, got %v", err)
}
}
func TestAppsAccessScopeSet_TenantRejectsExtraFlags(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", "app_x", "--scope", "tenant",
"--targets", `[]`, "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatalf("expected error when --targets passed with scope=tenant")
}
}
func TestAppsAccessScopeSet_RejectsBadTargetType(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"group","id":"oc_xxx"}]`,
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "type") {
t.Fatalf("expected bad target type rejected, got %v", err)
}
}
func TestAppsAccessScopeSet_ApproverRequiresApplyEnabled(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"user","id":"ou_x"}]`,
"--approver", "ou_y",
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "apply-enabled") {
t.Fatalf("expected --approver requires --apply-enabled, got %v", err)
}
}
func TestAppsAccessScopeSet_PublicRejectsApprover(t *testing.T) {
// --approver 只在 specific + apply 流程下有意义public 模式带它当前会被静默丢弃,
// 是真实用户语义 bug。这条测试钉死 Validate 阶段拦截。
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", "app_x",
"--scope", "public",
"--require-login=false",
"--approver", "ou_y",
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--approver is not allowed when --scope=public") {
t.Fatalf("expected --approver rejected for scope=public, got %v", err)
}
}
func TestAppsAccessScopeSet_PublicRequiresExplicitRequireLogin(t *testing.T) {
// bare --scope public without --require-login defaults silently to
// require_login=false (Internet-public + no auth). Reject so the caller
// has to make an explicit choice; matches SKILL.md "public 必传 --require-login".
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", "app_x",
"--scope", "public",
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--require-login is required when --scope=public") {
t.Fatalf("expected --require-login required for public, got %v", err)
}
}
func TestAppsAccessScopeSet_SpecificRejectsEmptyTargets(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", "app_x",
"--scope", "specific",
"--targets", "[]",
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--targets must contain at least one entry") {
t.Fatalf("expected empty --targets rejected, got %v", err)
}
}
func TestAppsAccessScopeSet_TrimsAppIDInPath(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
if err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", " app_x ",
"--scope", "tenant",
"--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsCreate creates a new Miaoda app.
var AppsCreate = common.Shortcut{
Service: appsService,
Command: "+create",
Description: "Create a new Miaoda app",
Risk: "write",
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "name", Desc: "app display name", Required: true},
{Name: "app-type", Desc: "app type (currently only: HTML)", Required: true},
{Name: "description", Desc: "app description"},
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("name")) == "" {
return output.ErrValidation("--name is required")
}
appType := strings.TrimSpace(rctx.Str("app-type"))
if appType == "" {
return output.ErrValidation("--app-type is required")
}
if !validAppTypes[appType] {
return output.ErrValidation(fmt.Sprintf("--app-type %q is not supported (allowed: HTML)", appType))
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(apiBasePath + "/apps").
Desc("Create a Miaoda app").
Body(buildAppsCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPI("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "created: %s\n", common.GetString(data, "app_id"))
})
return nil
},
}
// 应用类型枚举。当前只有 HTML未来会扩展SPA、NATIVE、...)。
var validAppTypes = map[string]bool{
"HTML": true,
}
func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"name": strings.TrimSpace(rctx.Str("name")),
"app_type": strings.TrimSpace(rctx.Str("app-type")),
}
if desc := strings.TrimSpace(rctx.Str("description")); desc != "" {
body["description"] = desc
}
if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" {
body["icon_url"] = icon
}
return body
}

View File

@@ -0,0 +1,157 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// 测试基础设施 —— 后续 Task 2.2-2.4 / Task 3.4 复用
func newAppsExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, stdout, _, reg := cmdutil.TestFactory(t, cfg)
return factory, stdout, reg
}
func runAppsShortcut(t *testing.T, sc common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "apps"}
sc.Mount(parent, factory)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.ExecuteContext(context.Background())
}
// +create 测试
func TestAppsCreate_Success(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app_id": "app_x",
"name": "Demo",
"icon_url": "https://lf3-static.bytednsdoc.com/.../default.svg",
"created_at": "2026-05-18T10:00:00Z",
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--description", "d", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"app_id": "app_x"`) {
t.Fatalf("stdout missing app_id: %s", got)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["name"] != "Demo" {
t.Fatalf("body.name = %v", sent["name"])
}
if sent["app_type"] != "HTML" {
t.Fatalf("body.app_type = %v (want HTML)", sent["app_type"])
}
if sent["description"] != "d" {
t.Fatalf("body.description = %v", sent["description"])
}
if _, present := sent["icon_url"]; present {
t.Fatalf("icon_url should be omitted when not provided: %v", sent)
}
}
func TestAppsCreate_WithIconURL(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"app_id": "app_x", "name": "Demo"},
},
})
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--icon-url", "https://example.com/icon.svg", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}
func TestAppsCreate_RequiresName(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "HTML", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "name") {
t.Fatalf("expected name required error, got %v", err)
}
}
func TestAppsCreate_RequiresAppType(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "app-type") {
t.Fatalf("expected --app-type required error, got %v", err)
}
}
func TestAppsCreate_RejectsInvalidAppType(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "spa", "--as", "user"},
factory, stdout)
if err == nil || !strings.Contains(err.Error(), "not supported") {
t.Fatalf("expected unsupported app-type error, got %v", err)
}
}
func TestAppsCreate_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
if !strings.Contains(got, `"name": "Demo"`) {
t.Fatalf("dry-run missing body: %s", got)
}
if !strings.Contains(got, `"app_type": "HTML"`) {
t.Fatalf("dry-run missing app_type: %s", got)
}
}

View File

@@ -0,0 +1,192 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsHTMLPublish packs --path as tar.gz and uploads + publishes via one multipart POST.
var AppsHTMLPublish = common.Shortcut{
Service: appsService,
Command: "+html-publish",
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
Risk: "write",
Scopes: []string{"spark:app:publish"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "path", Desc: "path to HTML file or directory", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
path := strings.TrimSpace(rctx.Str("path"))
if path == "" {
return output.ErrValidation("--path is required")
}
// Reject --path equal to the current working directory. Publishing
// cwd recursively packs .git/ / .env / node_modules / .aws/credentials
// alongside the intended HTML, and combined with --scope public puts
// those on an internet-reachable URL.
if filepath.Clean(path) == "." {
return output.ErrWithHint(output.ExitValidation, "validation",
"--path 不能指向当前工作目录(避免误把整个工程一并发布出去)",
"改成具体的子目录或文件,如 './dist' / './public' / './index.html'")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
path := strings.TrimSpace(rctx.Str("path"))
dry := common.NewDryRunAPI()
dry.Desc("Upload tar.gz + publish HTML (multipart, returns url)")
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
Set("content_type", "multipart/form-data")
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path)
if err != nil {
dry.Set("path_error", err.Error())
return dry
}
if err := ensureIndexHTML(candidates); err != nil {
// Surface the same failure Execute would hit, but as a structured
// envelope field so dry-run still exits 0 (matches repo convention
// for dry-run "advisory preview" semantics).
dry.Set("validation_error", err.Error())
}
dry.Set("file_count", len(candidates))
var totalSize int64
names := make([]string, 0, len(candidates))
for _, c := range candidates {
totalSize += c.Size
names = append(names, c.RelPath)
}
dry.Set("total_size_bytes", totalSize)
dry.Set("files", names)
// Advisory scan: surface paths matching well-known secret / credential
// patterns so the caller can review before going public. Dry-run still
// exits 0; this is non-blocking by design (legit doc sites may ship
// example .env files).
var warnings []string
for _, c := range candidates {
if isSensitiveRelPath(c.RelPath) {
warnings = append(warnings, c.RelPath)
}
}
if len(warnings) > 0 {
dry.Set("warnings", warnings)
dry.Set("warning_summary", fmt.Sprintf("manifest contains %d sensitive path(s); review before publishing", len(warnings)))
}
return dry
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
spec := appsHTMLPublishSpec{
AppID: strings.TrimSpace(rctx.Str("app-id")),
Path: strings.TrimSpace(rctx.Str("path")),
}
client := appsHTMLPublishAPI{runtime: rctx}
out, err := runHTMLPublish(ctx, rctx.FileIO(), client, spec)
if err != nil {
return err
}
rctx.OutFormat(out, nil, func(w io.Writer) {
if url, ok := out["url"].(string); ok && url != "" {
fmt.Fprintf(w, "url: %s\n", url)
}
})
return nil
},
}
type appsHTMLPublishSpec struct {
AppID string
Path string
}
// maxHTMLPublishTarballBytes 是 client 端 tar.gz 包体上限,对齐 OAPI 设计 20MB 约束。
// 用 var 而非 const便于单测调小覆盖拦截路径。
var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
// maxHTMLPublishRawBytes caps the total UNCOMPRESSED candidate size before
// tar+gzip writes them into the in-memory buffer. Defends against
// highly-compressible "decompression bomb" inputs (e.g. 50GB of zeros)
// that would balloon process memory before the gzip-after check fires.
// 200MB is much higher than any plausible legitimate HTML/static-site
// payload but low enough to stay well under typical container memory.
// Mutable for tests.
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
// 目录形态:根目录下必须有 index.html。
// 单文件形态:文件名必须就是 index.html。
// 妙搭服务端用 index.html 作为应用入口。
func ensureIndexHTML(candidates []htmlPublishCandidate) error {
for _, c := range candidates {
if c.RelPath == "index.html" {
return nil
}
}
return output.ErrWithHint(output.ExitAPI, "validation",
"--path 中缺少 index.html",
"妙搭以 index.html 作为应用入口;目录形态把首页放在根目录命名 index.html单文件形态把文件命名为 index.html")
}
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
// Defense in depth: callers reaching runHTMLPublish bypass the shortcut's
// Validate closure. Re-check that --path is not cwd before walking.
if filepath.Clean(spec.Path) == "." {
return nil, output.ErrWithHint(output.ExitValidation, "validation",
"--path 不能指向当前工作目录(避免误把整个工程一并发布出去)",
"改成具体的子目录或文件,如 './dist' / './public' / './index.html'")
}
candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
if err != nil {
return nil, output.Errorf(output.ExitAPI, "io", "scan --path %s: %v", spec.Path, err)
}
if err := ensureIndexHTML(candidates); err != nil {
return nil, err
}
var rawTotal int64
for _, c := range candidates {
rawTotal += c.Size
}
if rawTotal > maxHTMLPublishRawBytes {
return nil, output.ErrWithHint(output.ExitAPI, "validation",
fmt.Sprintf("--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes),
"在 tar+gzip 进入内存前拦截,避免 OOM精简 --path 内容或选择更小的子目录")
}
tarball, err := buildHTMLPublishTarball(fio, candidates)
if err != nil {
return nil, output.Errorf(output.ExitAPI, "io", "pack: %v", err)
}
if tarball.Size > maxHTMLPublishTarballBytes {
return nil, output.ErrWithHint(output.ExitAPI, "validation",
fmt.Sprintf("packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes),
"请精简 --path 目录(去掉无关大文件 / 压缩资源)后重试;本期接口上限 20MB")
}
resp, err := client.HTMLPublish(ctx, spec.AppID, tarball)
if err != nil {
return nil, err
}
out := map[string]interface{}{}
if resp.URL != "" {
out["url"] = resp.URL
}
return out, nil
}

View File

@@ -0,0 +1,338 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
)
type fakeAppsHTMLPublishClient struct {
resp *htmlPublishResponse
err error
calls []string
}
func (f *fakeAppsHTMLPublishClient) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
f.calls = append(f.calls, appID)
if f.err != nil {
return nil, f.err
}
return f.resp, nil
}
func writeAppsSampleSite(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
return dir
}
func TestRunHTMLPublish_HappyPath(t *testing.T) {
site := writeAppsSampleSite(t)
fake := &fakeAppsHTMLPublishClient{
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
}
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if err != nil {
t.Fatalf("err=%v", err)
}
if out["url"] != "https://miaoda/app_x" {
t.Fatalf("url=%v", out["url"])
}
if len(fake.calls) != 1 || fake.calls[0] != "app_x" {
t.Fatalf("calls=%v", fake.calls)
}
}
func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) {
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步":
// envelope 只含 url未来若有人加 status / release_id 字段会被这个测试拦截。
site := writeAppsSampleSite(t)
fake := &fakeAppsHTMLPublishClient{
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
}
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if err != nil {
t.Fatalf("err=%v", err)
}
if len(out) != 1 {
t.Fatalf("envelope should only contain 'url', got %d keys: %v", len(out), out)
}
if _, ok := out["url"]; !ok {
t.Fatalf("envelope missing 'url': %v", out)
}
}
func TestRunHTMLPublish_ClientErrorPropagated(t *testing.T) {
site := writeAppsSampleSite(t)
wantErr := errors.New("server timeout")
fake := &fakeAppsHTMLPublishClient{err: wantErr}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if !errors.Is(err, wantErr) {
t.Fatalf("err=%v", err)
}
}
func TestRunHTMLPublish_PathNotFound(t *testing.T) {
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: "/nonexistent"})
if err == nil {
t.Fatalf("expected error")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when path invalid")
}
}
func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
// 目录形态:缺 index.html 应该被拦
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected error for missing index.html")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "index.html") {
t.Fatalf("message missing 'index.html': %v", exitErr.Detail.Message)
}
if exitErr.Detail.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when index.html missing")
}
}
func TestRunHTMLPublish_DirWithIndexHTMLPasses(t *testing.T) {
// 目录含 index.html 应该正常走完
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "extra.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
t.Fatalf("err=%v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("client should be called when index.html present")
}
}
func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
// 单文件形态:文件名不是 index.html 也要拦
dir := t.TempDir()
single := filepath.Join(dir, "foo.html")
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single})
if err == nil {
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
t.Fatalf("expected ExitError type=validation, got %v", err)
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when index.html missing")
}
}
func TestRunHTMLPublish_SingleFileNamedIndexPasses(t *testing.T) {
// 单文件形态:文件名恰好就是 index.html → 放行
dir := t.TempDir()
single := filepath.Join(dir, "index.html")
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single}); err != nil {
t.Fatalf("err=%v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("client should be called for single index.html")
}
}
func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
// 把上限调到 100 字节验证拦截defer 恢复原值避免污染其它测试。
orig := maxHTMLPublishTarballBytes
maxHTMLPublishTarballBytes = 100
defer func() { maxHTMLPublishTarballBytes = orig }()
dir := t.TempDir()
// 写 index.html满足新加的 index 校验)+ 大文件超 100 字节上限。
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "big.html"),
[]byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected oversize error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "exceeds") {
t.Fatalf("message missing 'exceeds': %v", exitErr.Detail.Message)
}
if exitErr.Detail.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when tarball oversize")
}
}
func TestMaxHTMLPublishTarballBytes_Default(t *testing.T) {
// Pin 20MB 常量值typo 到 20*1000*1024 之类会被拦截。
if maxHTMLPublishTarballBytes != 20*1024*1024 {
t.Fatalf("default = %d, want %d (20MiB)", maxHTMLPublishTarballBytes, 20*1024*1024)
}
}
func TestAppsHTMLPublish_RequiresAppID(t *testing.T) {
site := writeAppsSampleSite(t)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsHTMLPublish,
[]string{"+html-publish", "--path", site}, factory, stdout)
// cobra Required:true may report flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "app-id") {
t.Fatalf("expected --app-id required, got %v", err)
}
}
func TestAppsHTMLPublish_RequiresPath(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsHTMLPublish,
[]string{"+html-publish", "--app-id", "app_x"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "path") {
t.Fatalf("expected --path required, got %v", err)
}
}
func TestAppsHTMLPublish_DryRunPrintsManifest(t *testing.T) {
// 这个用例走真实 shortcut → 真实 LocalFileIOcwd-bounded
// 必须 chdir 进 tmp 用相对路径,否则 SafeInputPath 会拒绝绝对 --path。
// --path "." 被 Validate 拒绝,因此改为在 tmp 下建 dist 子目录并传 ./dist。
dir := t.TempDir()
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := os.MkdirAll(filepath.Join(dir, "dist"), 0o755); err != nil {
t.Fatalf("mkdir dist: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsHTMLPublish,
[]string{"+html-publish", "--app-id", "app_x", "--path", "./dist", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
if !strings.Contains(got, "index.html") {
t.Fatalf("dry-run missing file list: %s", got)
}
}
func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
orig := maxHTMLPublishRawBytes
maxHTMLPublishRawBytes = 100
defer func() { maxHTMLPublishRawBytes = orig }()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake,
appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected raw-size cap to fire")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "raw") || !strings.Contains(exitErr.Detail.Message, "bytes") {
t.Fatalf("expected message to explain raw-byte cap, got %q", exitErr.Detail.Message)
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when raw cap hit")
}
}
func TestRunHTMLPublish_RejectsCurrentDirectoryPath(t *testing.T) {
// Publishing the entire current working directory is the canonical
// secrets-exfiltration footgun (.git/.env/node_modules all end up in the
// tarball). Reject --path "." (and Clean equivalents) at runHTMLPublish
// entry so any direct caller cannot accidentally trigger it. (Validate
// also rejects at flag layer; this is defense in depth.)
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake,
appsHTMLPublishSpec{AppID: "app_x", Path: "."})
if err == nil {
t.Fatalf("expected --path '.' to be rejected")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
t.Fatalf("expected ExitError type=validation, got %v", err)
}
if !strings.Contains(exitErr.Detail.Message, "当前工作目录") {
t.Fatalf("error message should explain cwd is forbidden, got %q", exitErr.Detail.Message)
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when --path is cwd")
}
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsList lists Miaoda apps owned by the calling user (cursor pagination).
//
// Hidden from --help / tab completion (Hidden: true) so agents do not discover it
// as a way to enumerate / search applications. Direct invocation still works for
// humans who know the command. When agents need an existing app_id, they should
// ask the user to provide either the Miaoda app URL (extract app_id from the
// path segment after /app/) or the app_id string directly; see lark-apps SKILL.md.
var AppsList = common.Shortcut{
Service: appsService,
Command: "+list",
Description: "List Miaoda apps owned by the calling user (cursor pagination)",
Risk: "read",
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Hidden: true,
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(apiBasePath + "/apps").
Desc("List Miaoda apps").
Params(buildAppsListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPI("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil)
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
rctx.OutFormat(data, nil, func(w io.Writer) {
// Table view (--format table) intentionally shows only the columns
// most useful for visual scanning: app_id (to copy-paste downstream),
// name (to match what the user sees in the UI), and updated_at (to
// pick the most recent variant). description / icon_url / created_at
// stay in the underlying JSON (--format json) but would make the
// table too wide for a terminal.
rows := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
rows = append(rows, map[string]interface{}{
"app_id": m["app_id"],
"name": m["name"],
"updated_at": m["updated_at"],
})
}
output.PrintTable(w, rows)
})
return nil
},
}
func buildAppsListParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"page_size": rctx.Int("page-size"),
}
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
params["page_token"] = token
}
return params
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsList_FirstPage(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps?page_size=20",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"app_id": "app_a", "name": "Alpha", "updated_at": "2026-05-18T10:00:00Z"},
map[string]interface{}{"app_id": "app_b", "name": "Beta", "updated_at": "2026-05-18T09:00:00Z"},
},
"page_token": "next_cursor",
"has_more": true,
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsList, []string{"+list", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "app_a") || !strings.Contains(got, "app_b") {
t.Fatalf("output missing items: %s", got)
}
if !strings.Contains(got, "Alpha") || !strings.Contains(got, "Beta") {
t.Fatalf("output missing item names: %s", got)
}
}
func TestAppsList_WithPageToken(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps?page_size=50&page_token=cursor_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{},
"has_more": false,
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsList,
[]string{"+list", "--page-size", "50", "--page-token", "cursor_abc", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}
func TestAppsList_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsList,
[]string{"+list", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
if !strings.Contains(got, "page_size") {
t.Fatalf("dry-run missing page_size param: %s", got)
}
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsUpdate partially updates a Miaoda app's name / description.
var AppsUpdate = common.Shortcut{
Service: appsService,
Command: "+update",
Description: "Partially update a Miaoda app (only provided fields are sent)",
Risk: "write",
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "name", Desc: "new app display name"},
{Name: "description", Desc: "new app description"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
body := buildAppsUpdateBody(rctx)
if len(body) == 0 {
return output.ErrValidation("provide at least one of --name or --description")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
PATCH(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Update a Miaoda app").
Body(buildAppsUpdateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPI("PATCH", path, nil, buildAppsUpdateBody(rctx))
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "updated: %s\n", common.GetString(data, "app_id"))
})
return nil
},
}
func buildAppsUpdateBody(rctx *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
if v := strings.TrimSpace(rctx.Str("name")); v != "" {
body["name"] = v
}
if v := strings.TrimSpace(rctx.Str("description")); v != "" {
body["description"] = v
}
return body
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsUpdate_PartialFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/spark/v1/apps/app_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app_id": "app_x",
"name": "renamed",
"updated_at": "2026-05-18T10:05:00Z",
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsUpdate,
[]string{"+update", "--app-id", "app_x", "--name", "renamed", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["name"] != "renamed" {
t.Fatalf("body.name = %v", sent["name"])
}
if _, present := sent["description"]; present {
t.Fatalf("description should not be in body when not provided: %v", sent)
}
}
func TestAppsUpdate_RequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsUpdate,
[]string{"+update", "--name", "renamed", "--as", "user"}, factory, stdout)
// cobra Required:true may match "app-id" instead of "--app-id"
if err == nil || !strings.Contains(err.Error(), "app-id") {
t.Fatalf("expected --app-id required, got %v", err)
}
}
func TestAppsUpdate_RequiresAtLeastOneField(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsUpdate,
[]string{"+update", "--app-id", "app_x", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected error when no field provided")
}
}
func TestAppsUpdate_TrimsAppIDInPath(t *testing.T) {
// 钉死 --app-id 在拼进 URL 前要先 TrimSpace —— 与 create / access-scope-* 等保持一致,
// 避免 " app_x " 这种取值被原样 EncodePathSegment 编进 path 出现空格转义。
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/spark/v1/apps/app_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"app_id": "app_x"},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsUpdate,
[]string{"+update", "--app-id", " app_x ", "--name", "renamed", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}

10
shortcuts/apps/common.go Normal file
View File

@@ -0,0 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
// appsService 是 CLI 命令的 service 前缀lark-cli apps ...)。
const appsService = "apps"
// apiBasePath is the registered OAPI prefix for the Miaoda apps domain.
const apiBasePath = "/open-apis/spark/v1"

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type htmlPublishResponse struct {
URL string
}
type appsHTMLPublishClient interface {
HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error)
}
type appsHTMLPublishAPI struct {
runtime *common.RuntimeContext
}
func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
fd := larkcore.NewFormdata()
fd.AddFile("file", bytes.NewReader(tarball.Body))
apiResp, err := api.runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID)),
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return nil, err
}
return parseHTMLPublishResponse(apiResp.RawBody)
}
func parseHTMLPublishResponse(raw []byte) (*htmlPublishResponse, error) {
var envelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("decode html-publish response: %w", err)
}
if envelope.Code != 0 {
return nil, output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("html-publish failed (code=%d): %s", envelope.Code, envelope.Msg),
buildHTMLPublishFailureHint(envelope.Code))
}
return &htmlPublishResponse{URL: envelope.Data.URL}, nil
}
// OAPI business error codes returned by the Miaoda
// /apps/{id}/upload_and_release_html_code endpoint. Owned by the backend
// service; update when new codes are documented in the OAPI spec.
const (
errCodeBuildFailed = 90001 // tar.gz uploaded but server-side build failed
errCodeAppNotFound = 90002 // app_id unknown or caller lacks permission
)
func buildHTMLPublishFailureHint(code int) string {
switch code {
case errCodeBuildFailed:
return "构建失败:用 `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` 检查打包文件清单"
case errCodeAppNotFound:
return "应用不存在或无权访问;请用户确认 app_id从妙搭应用链接 https://miaoda.feishu.cn/app/app_xxx 的 /app/ 后面提取,或直接给 app_xxx 字符串)"
default:
return ""
}
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"errors"
"mime"
"mime/multipart"
"strings"
"testing"
"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/shortcuts/common"
)
func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
rctx := common.TestNewRuntimeContextForAPI(context.Background(), nil, cfg, factory, core.AsUser)
return rctx, reg
}
func TestAppsHTMLPublishAPI_Success(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"url": "https://miaoda.feishu.cn/app/app_x",
},
},
}
reg.Register(stub)
api := appsHTMLPublishAPI{runtime: rctx}
tarball := &htmlPublishTarball{Body: []byte("fake"), Size: 4, SHA256: "abc"}
resp, err := api.HTMLPublish(context.Background(), "app_x", tarball)
if err != nil {
t.Fatalf("err=%v", err)
}
if resp.URL != "https://miaoda.feishu.cn/app/app_x" {
t.Fatalf("url=%q", resp.URL)
}
ct := stub.CapturedHeaders.Get("Content-Type")
mt, params, err := mime.ParseMediaType(ct)
if err != nil || mt != "multipart/form-data" {
t.Fatalf("content type %q wrong", ct)
}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
saw := false
for {
p, err := mr.NextPart()
if err != nil {
break
}
if p.FormName() == "file" {
saw = true
}
}
if !saw {
t.Fatalf("multipart missing 'file' part")
}
}
func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 90001,
"msg": "build failed: dependency conflict",
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
if err == nil {
t.Fatalf("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
}
if exitErr.Detail.Hint == "" {
t.Fatalf("expected non-empty hint on code 90001")
}
if !strings.Contains(exitErr.Detail.Message, "build failed") {
t.Fatalf("missing failure message: %v", exitErr.Detail.Message)
}
}
func TestBuildHTMLPublishFailureHint_UnknownCodeReturnsEmpty(t *testing.T) {
// 默认分支:未识别的 code 返回空 hint让 Agent 用 message 兜底。
if hint := buildHTMLPublishFailureHint(99999); hint != "" {
t.Fatalf("unknown code should return empty hint, got %q", hint)
}
if hint := buildHTMLPublishFailureHint(0); hint != "" {
t.Fatalf("zero code should return empty hint, got %q", hint)
}
}
func TestBuildHTMLPublishFailureHint_KnownCodes(t *testing.T) {
if hint := buildHTMLPublishFailureHint(90001); hint == "" {
t.Fatalf("code 90001 should return non-empty hint")
}
if hint := buildHTMLPublishFailureHint(90002); hint == "" {
t.Fatalf("code 90002 should return non-empty hint")
}
}
func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing.T) {
hint := buildHTMLPublishFailureHint(90002)
if hint == "" {
t.Fatalf("code 90002 should return non-empty hint")
}
if strings.Contains(hint, "+list") {
t.Fatalf("hint must not point at hidden +list command, got: %q", hint)
}
if !strings.Contains(hint, "app_id") {
t.Fatalf("hint should reference app_id, got: %q", hint)
}
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"github.com/larksuite/cli/extension/fileio"
)
// htmlPublishTarball is the in-memory packed tar.gz ready for multipart upload.
// Body is bounded by maxHTMLPublishTarballBytes (20MiB) — see runHTMLPublish.
type htmlPublishTarball struct {
Body []byte
Size int64
SHA256 string
}
func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidate) (*htmlPublishTarball, error) {
if len(candidates) == 0 {
return nil, errors.New("no files to pack")
}
var buf bytes.Buffer
hasher := sha256.New()
multi := io.MultiWriter(&buf, hasher)
gz := gzip.NewWriter(multi)
tw := tar.NewWriter(gz)
for _, c := range candidates {
if err := writeHTMLPublishTarEntry(fio, tw, c); err != nil {
_ = tw.Close()
_ = gz.Close()
return nil, err
}
}
if err := tw.Close(); err != nil {
_ = gz.Close()
return nil, fmt.Errorf("tar close: %w", err)
}
if err := gz.Close(); err != nil {
return nil, fmt.Errorf("gzip close: %w", err)
}
return &htmlPublishTarball{
Body: buf.Bytes(),
Size: int64(buf.Len()),
SHA256: hex.EncodeToString(hasher.Sum(nil)),
}, nil
}
func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCandidate) error {
if isUnsafeRelPath(c.RelPath) {
return fmt.Errorf("invalid tar entry name %q", c.RelPath)
}
src, err := fio.Open(c.AbsPath)
if err != nil {
return fmt.Errorf("open %s: %w", c.AbsPath, err)
}
defer src.Close()
hdr := &tar.Header{
Name: c.RelPath,
Size: c.Size,
Mode: 0o644,
Typeflag: tar.TypeReg,
}
if err := tw.WriteHeader(hdr); err != nil {
return fmt.Errorf("write header %s: %w", c.RelPath, err)
}
if _, err := io.Copy(tw, src); err != nil {
return fmt.Errorf("copy %s: %w", c.RelPath, err)
}
return nil
}

View File

@@ -0,0 +1,193 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"archive/tar"
"bytes"
"compress/gzip"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/extension/fileio"
)
// readFailingFIO opens a File whose Read always returns the configured error,
// letting tests exercise the io.Copy failure branch without filesystem games.
type readFailingFIO struct{ readErr error }
func (f readFailingFIO) Open(string) (fileio.File, error) {
return &readFailingFile{err: f.readErr}, nil
}
func (f readFailingFIO) Stat(string) (fileio.FileInfo, error) {
return nil, errors.New("Stat not used")
}
func (readFailingFIO) ResolvePath(p string) (string, error) { return p, nil }
func (readFailingFIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
return nil, errors.New("Save not used")
}
type readFailingFile struct{ err error }
func (f *readFailingFile) Read([]byte) (int, error) { return 0, f.err }
func (f *readFailingFile) ReadAt([]byte, int64) (int, error) { return 0, f.err }
func (f *readFailingFile) Close() error { return nil }
func TestBuildHTMLPublishTarball_RoundTrip(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fio := newTestFIO()
candidates, err := walkHTMLPublishCandidates(fio, dir)
if err != nil {
t.Fatalf("walk: %v", err)
}
tarball, err := buildHTMLPublishTarball(fio, candidates)
if err != nil {
t.Fatalf("build: %v", err)
}
if len(tarball.SHA256) != 64 {
t.Fatalf("SHA256 wrong len: %d", len(tarball.SHA256))
}
if tarball.Size <= 0 || int64(len(tarball.Body)) != tarball.Size {
t.Fatalf("size=%d body=%d", tarball.Size, len(tarball.Body))
}
gz, err := gzip.NewReader(bytes.NewReader(tarball.Body))
if err != nil {
t.Fatalf("gzip: %v", err)
}
tr := tar.NewReader(gz)
hdr, err := tr.Next()
if err != nil {
t.Fatalf("tar.Next: %v", err)
}
if hdr.Name != "index.html" {
t.Fatalf("entry name = %q, want index.html", hdr.Name)
}
body, err := io.ReadAll(tr)
if err != nil || string(body) != "<html></html>" {
t.Fatalf("body=%q err=%v", body, err)
}
}
func TestBuildHTMLPublishTarball_EmptyCandidates(t *testing.T) {
if _, err := buildHTMLPublishTarball(newTestFIO(), nil); err == nil {
t.Fatalf("expected error")
}
}
func TestWriteHTMLPublishTarEntry_OpenFailure(t *testing.T) {
// candidate 指向不存在文件 → fio.Open 失败 → 错误返回
tw := tar.NewWriter(io.Discard)
defer tw.Close()
err := writeHTMLPublishTarEntry(newTestFIO(), tw, htmlPublishCandidate{
RelPath: "x.html",
AbsPath: "/nonexistent-path-for-test/x.html",
Size: 0,
})
if err == nil {
t.Fatalf("expected error for nonexistent abs path")
}
if !strings.Contains(err.Error(), "open") {
t.Fatalf("expected open error, got %v", err)
}
}
func TestWriteHTMLPublishTarEntry_WriteHeaderFailure(t *testing.T) {
// 在已 close 的 tar.Writer 上写 header → WriteHeader 失败
dir := t.TempDir()
file := filepath.Join(dir, "x.html")
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
tw := tar.NewWriter(io.Discard)
_ = tw.Close() // 先 close下次 WriteHeader 必失败
err := writeHTMLPublishTarEntry(newTestFIO(), tw, htmlPublishCandidate{
RelPath: "x.html",
AbsPath: file,
Size: 1,
})
if err == nil {
t.Fatalf("expected error when writing to closed tar.Writer")
}
if !strings.Contains(err.Error(), "write header") {
t.Fatalf("expected 'write header' error, got %v", err)
}
}
func TestWriteHTMLPublishTarEntry_CopyFailure(t *testing.T) {
// 注入一个 Read 必失败的 fileio.File让 io.Copy 在 tar 写入阶段出错。
// 避免 chmod 0o000 的跨平台 / root 用户 flake。
fio := readFailingFIO{readErr: errors.New("synthetic read failure")}
tw := tar.NewWriter(io.Discard)
defer tw.Close()
err := writeHTMLPublishTarEntry(fio, tw, htmlPublishCandidate{
RelPath: "x.html",
AbsPath: "fixtures/x.html", // 任意路径Open 由 stub 接管
Size: 7,
})
if err == nil {
t.Fatalf("expected error when underlying Read fails")
}
if !strings.Contains(err.Error(), "copy") {
t.Fatalf("expected copy-stage error, got %v", err)
}
}
func TestBuildHTMLPublishTarball_EntryWriteFailureReturnsError(t *testing.T) {
// candidate 指向不存在文件 → writeHTMLPublishTarEntry 失败
// → buildHTMLPublishTarball 返回 nil tarball + error。
candidates := []htmlPublishCandidate{
{RelPath: "x.html", AbsPath: "/nonexistent-path-for-test/x.html", Size: 0},
}
tarball, err := buildHTMLPublishTarball(newTestFIO(), candidates)
if err == nil {
t.Fatalf("expected error, got tarball=%+v", tarball)
}
if tarball != nil {
t.Fatalf("expected nil tarball on error, got %+v", tarball)
}
}
func TestWriteHTMLPublishTarEntry_RejectsPathTraversal(t *testing.T) {
tw := tar.NewWriter(io.Discard)
defer tw.Close()
cases := []struct {
name string
rel string
}{
{"parent traversal", "../etc/passwd"},
{"absolute path", "/etc/passwd"},
{"embedded traversal", "a/../../etc/passwd"},
{"null byte", "evil\x00.html"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := writeHTMLPublishTarEntry(newTestFIO(), tw, htmlPublishCandidate{
RelPath: c.rel,
AbsPath: "fixtures/whatever",
Size: 0,
})
if err == nil {
t.Fatalf("expected error for RelPath=%q", c.rel)
}
if !strings.Contains(err.Error(), "invalid tar entry name") {
t.Fatalf("expected 'invalid tar entry name' error, got %v", err)
}
})
}
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import "strings"
// isSensitiveRelPath reports whether a relative path inside the candidate
// manifest looks like something that should not ship to a public-internet
// share URL — secrets, credentials, SCM internals, SSH keys. The check is
// path-element-wise (each "/"-delimited segment is inspected) so secrets
// nested under arbitrary subdirectories are still caught.
//
// Used by +html-publish dry-run to populate a "warnings" field; the
// caller still proceeds (this is advisory, not a hard block) so legit
// edge cases (e.g. a documentation site that has a .env example file
// on purpose) are not gated, but the user/agent sees the list.
func isSensitiveRelPath(rel string) bool {
if rel == "" {
return false
}
parts := strings.Split(rel, "/")
for i, p := range parts {
switch {
case p == ".git":
return true
case p == ".env" || strings.HasPrefix(p, ".env."):
return true
case p == ".npmrc" || p == ".netrc":
return true
case p == "credentials" || p == "config":
if i > 0 {
parent := parts[i-1]
if parent == ".aws" || parent == ".docker" || parent == ".gcloud" || parent == ".kube" {
return true
}
}
case strings.HasPrefix(p, "id_rsa") || strings.HasPrefix(p, "id_ed25519") || strings.HasPrefix(p, "id_ecdsa") || strings.HasPrefix(p, "id_dsa"):
return true
case strings.HasSuffix(p, ".pem") || strings.HasSuffix(p, ".key"):
return true
case strings.HasSuffix(p, ".json") && p == "config.json" && i > 0 && parts[i-1] == ".docker":
return true
}
}
return false
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import "testing"
func TestIsSensitiveRelPath(t *testing.T) {
cases := []struct {
rel string
want bool
}{
// dotfiles and well-known secret stores
{".env", true},
{".env.local", true},
{".env.production", true},
{"backend/.env", true},
{".npmrc", true},
{"sub/.npmrc", true},
{".netrc", true},
// .git tree
{".git/config", true},
{".git/HEAD", true},
{"subdir/.git/config", true},
{".gitignore", false}, // NOT sensitive (intended to be committed)
// SSH keys
{".ssh/id_rsa", true},
{".ssh/id_ed25519", true},
{"backup/id_rsa.pub", true}, // pub also flagged (often near private)
// Cloud creds
{".aws/credentials", true},
{".aws/config", true},
{".docker/config.json", true},
// Generic crypto
{"server.pem", true},
{"certs/private.key", true},
{"path/to/whatever.pem", true},
// Benign
{"index.html", false},
{"dist/main.js", false},
{"assets/logo.svg", false},
{"README.md", false},
{"package.json", false},
}
for _, c := range cases {
if got := isSensitiveRelPath(c.rel); got != c.want {
t.Errorf("isSensitiveRelPath(%q) = %v, want %v", c.rel, got, c.want)
}
}
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all apps domain shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
AppsCreate,
AppsUpdate,
AppsList,
AppsAccessScopeSet,
AppsAccessScopeGet,
AppsHTMLPublish,
}
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import "testing"
// 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。
func TestAppsShortcuts_Returns6(t *testing.T) {
got := Shortcuts()
if len(got) != 6 {
t.Fatalf("Shortcuts() returned %d entries, want 6", len(got))
}
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"fmt"
"io/fs"
"path/filepath"
"strings"
"github.com/larksuite/cli/extension/fileio"
)
type htmlPublishCandidate struct {
RelPath string
AbsPath string
Size int64
}
// isUnsafeRelPath reports whether a forward-slash relative path contains
// anything that should never be written into a tar header or treated as
// inside-root: leading slash (absolute), .. as a path component (start /
// middle / end / whole), or an embedded null byte. Component-aware so it
// does not false-positive on legitimate filenames that contain ".." as a
// substring (e.g. "archive.tar..bak").
func isUnsafeRelPath(rel string) bool {
return strings.HasPrefix(rel, "/") ||
rel == ".." ||
strings.HasPrefix(rel, "../") ||
strings.Contains(rel, "/../") ||
strings.HasSuffix(rel, "/..") ||
strings.ContainsRune(rel, 0)
}
// walkHTMLPublishCandidates walks rootPath and returns each regular file as a
// candidate. Stat goes through fileio so SafeInputPath validation runs on the
// root; the directory walk itself uses filepath.WalkDir because runtime.FileIO
// has no WalkDir equivalent today.
func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublishCandidate, error) {
stat, err := fio.Stat(rootPath)
if err != nil {
return nil, fmt.Errorf("stat %s: %w", rootPath, err)
}
if !stat.IsDir() {
return []htmlPublishCandidate{{
RelPath: filepath.Base(rootPath),
AbsPath: rootPath,
Size: stat.Size(),
}}, nil
}
var out []htmlPublishCandidate
//nolint:forbidigo // fileio has no WalkDir; rootPath is already validated above via fio.Stat -> SafeInputPath.
err = filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
// 只接受 regular file —— symlink / device / pipe / socket 都跳过。
// symlink 不跟随是设计决策(避免 loop + out-of-root 引用),且 fio.Open 也会拒非 regular。
if !info.Mode().IsRegular() {
return nil
}
rel, err := filepath.Rel(rootPath, path)
if err != nil {
return err
}
relSlash := filepath.ToSlash(rel)
// Defense in depth: WalkDir + Rel inside rootPath should never yield a
// path with .. components, but a future logic change or unusual
// filesystem layout shouldn't be able to inject one into RelPath.
// Mirrors the same guard at tar entry write time.
if isUnsafeRelPath(relSlash) {
return fmt.Errorf("walker produced unsafe relative path %q for %s", relSlash, path)
}
out = append(out, htmlPublishCandidate{
RelPath: relSlash,
AbsPath: path,
Size: info.Size(),
})
return nil
})
return out, err
}

View File

@@ -0,0 +1,140 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"io"
"os"
"path/filepath"
"sort"
"testing"
"github.com/larksuite/cli/extension/fileio"
)
// permissiveFIO is a test-only fileio that delegates to os without
// SafeInputPath validation. Unit tests use it so we can drive the walker
// and tarball algorithms with absolute t.TempDir paths; production code
// goes through LocalFileIO which is cwd-bounded.
type permissiveFIO struct{}
func (permissiveFIO) Open(name string) (fileio.File, error) { return os.Open(name) }
func (permissiveFIO) Stat(name string) (fileio.FileInfo, error) { return os.Stat(name) }
func (permissiveFIO) ResolvePath(p string) (string, error) { return p, nil }
func (permissiveFIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
panic("Save not used in apps unit tests")
}
func newTestFIO() fileio.FileIO { return permissiveFIO{} }
func TestWalkHTMLPublishCandidates_SingleFile(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "index.html")
if err := os.WriteFile(file, []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
got, err := walkHTMLPublishCandidates(newTestFIO(), file)
if err != nil {
t.Fatalf("err=%v", err)
}
if len(got) != 1 || got[0].RelPath != "index.html" || got[0].Size != 13 {
t.Fatalf("got=%+v", got)
}
}
func TestWalkHTMLPublishCandidates_Directory(t *testing.T) {
dir := t.TempDir()
files := map[string]string{
"index.html": "<html></html>",
"css/main.css": "body{}",
"assets/logo.svg": "<svg/>",
}
for rel, content := range files {
full := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
if err != nil {
t.Fatalf("err=%v", err)
}
if len(got) != 3 {
t.Fatalf("got %d candidates, want 3", len(got))
}
rels := make([]string, 3)
for i, c := range got {
rels[i] = c.RelPath
}
sort.Strings(rels)
want := []string{"assets/logo.svg", "css/main.css", "index.html"}
for i, w := range want {
if rels[i] != w {
t.Fatalf("rel[%d]=%q want %q", i, rels[i], w)
}
}
}
func TestWalkHTMLPublishCandidates_NotFound(t *testing.T) {
if _, err := walkHTMLPublishCandidates(newTestFIO(), "/nonexistent/xyz"); err == nil {
t.Fatalf("expected error")
}
}
func TestIsUnsafeRelPath(t *testing.T) {
cases := []struct {
rel string
want bool
}{
{"index.html", false},
{"assets/logo.svg", false},
{"deep/nested/path/file.html", false},
{"archive.tar..bak", false},
{"version.1..2.html", false},
{"..config", false},
{"", false},
{"/etc/passwd", true},
{"..", true},
{"../etc/passwd", true},
{"a/../../etc/passwd", true},
{"a/..", true},
{"evil\x00.html", true},
}
for _, c := range cases {
if got := isUnsafeRelPath(c.rel); got != c.want {
t.Errorf("isUnsafeRelPath(%q) = %v, want %v", c.rel, got, c.want)
}
}
}
func TestWalkHTMLPublishCandidates_SymlinkSkipped(t *testing.T) {
// Walker 只接受 regular file —— symlink 跳过(避免 loop + out-of-root 引用,
// 且 fio.Open 对 symlink 行为不一致。real.html 仍然被收link.html 不在结果里。
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "real.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.Symlink(filepath.Join(dir, "real.html"), filepath.Join(dir, "link.html")); err != nil {
t.Skipf("symlink not supported on this filesystem: %v", err)
}
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
if err != nil {
t.Fatalf("err=%v", err)
}
rels := make(map[string]bool)
for _, c := range got {
rels[c.RelPath] = true
}
if !rels["real.html"] {
t.Fatalf("expected real.html (regular file) in candidates, got %+v", got)
}
if rels["link.html"] {
t.Fatalf("symlink link.html should NOT appear in candidates, got %+v", got)
}
}

View File

@@ -34,6 +34,7 @@ func AutoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
PermissionGrantSkipped,
"",
fmt.Sprintf("The operation did not return a permission target (missing token/type), so current user %s was not granted. You can retry later or continue using bot identity.", permissionGrantPermMessage()),
"No permission target (missing token or type) returned by the operation.",
)
}
@@ -43,11 +44,14 @@ func AutoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourceType string) map[string]interface{} {
userOpenID := strings.TrimSpace(runtime.UserOpenId())
if userOpenID == "" {
return buildPermissionGrantResult(
result := buildPermissionGrantResult(
PermissionGrantSkipped,
"",
fmt.Sprintf("Resource was created with bot identity, but no current CLI user open_id is configured, so current user %s was not granted. You can retry later or continue using bot identity.", permissionGrantPermMessage()),
"No current user identity (not logged in or session expired).",
)
fmt.Fprintf(runtime.IO().ErrOut, "Warning: resource was created with bot identity, but no current user open_id is configured, so auto-grant was skipped. Run `lark-cli auth login` and retry, or grant permission manually.\n")
return result
}
body := map[string]interface{}{
@@ -70,21 +74,26 @@ func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
body,
)
if err != nil {
return buildPermissionGrantResult(
errMsg := compactPermissionGrantError(err)
result := buildPermissionGrantResult(
PermissionGrantFailed,
userOpenID,
fmt.Sprintf("Resource was created, but granting current user %s failed: %s. You can retry later or continue using bot identity.", permissionGrantPermMessage(), compactPermissionGrantError(err)),
fmt.Sprintf("Resource was created, but granting current user %s failed: %s. You can retry later or continue using bot identity.", permissionGrantPermMessage(), errMsg),
fmt.Sprintf("Auto-grant failed: %s. The app may lack the required scope or the resource restricts permission changes.", errMsg),
)
fmt.Fprintf(runtime.IO().ErrOut, "Warning: resource was created, but auto-grant failed: %s. Retry later or grant permission manually.\n", errMsg)
return result
}
return buildPermissionGrantResult(
PermissionGrantGranted,
userOpenID,
fmt.Sprintf("Granted the current CLI user %s on the new %s.", permissionGrantPermMessage(), permissionTargetLabel(resourceType)),
"",
)
}
func buildPermissionGrantResult(status, userOpenID, message string) map[string]interface{} {
func buildPermissionGrantResult(status, userOpenID, message, reason string) map[string]interface{} {
result := map[string]interface{}{
"status": status,
"perm": permissionGrantPerm,
@@ -94,6 +103,11 @@ func buildPermissionGrantResult(status, userOpenID, message string) map[string]i
result["user_open_id"] = userOpenID
result["member_type"] = "openid"
}
if status == PermissionGrantSkipped {
result["hint"] = reason + " Run `lark-cli auth login` and retry, or grant permission manually via the Lark document UI."
} else if status == PermissionGrantFailed {
result["hint"] = reason + " Retry later or grant permission manually via the Lark document UI."
}
return result
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) {
config := &core.CliConfig{
AppID: "perm-grant-test-skip",
AppSecret: "perm-grant-test-secret-skip",
Brand: core.BrandFeishu,
}
f, _, stderr, _ := cmdutil.TestFactory(t, config)
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-1")
runtime := &RuntimeContext{
ctx: ctx,
Config: config,
Factory: f,
resolvedAs: core.AsBot,
}
result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx")
if result == nil {
t.Fatal("expected non-nil result for bot mode with empty user open_id")
}
if result["status"] != PermissionGrantSkipped {
t.Fatalf("status = %v, want %q", result["status"], PermissionGrantSkipped)
}
if !strings.Contains(stderr.String(), "auto-grant was skipped") {
t.Fatalf("stderr missing auto-grant skipped warning; got:\n%s", stderr.String())
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
t.Fatalf("hint = %#v, want string containing 'auth login'", result["hint"])
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "not logged in") {
t.Fatalf("hint = %#v, want string containing 'not logged in'", result["hint"])
}
}
func TestAutoGrantStderrWarning_GrantFailed(t *testing.T) {
config := &core.CliConfig{
AppID: "perm-grant-test-fail",
AppSecret: "perm-grant-test-secret-fail",
Brand: core.BrandFeishu,
UserOpenId: "ou_test_user",
}
f, _, stderr, reg := cmdutil.TestFactory(t, config)
// Register a stub that returns an error code so CallAPI returns an error.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/tkn_doc/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
})
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-2")
runtime := &RuntimeContext{
ctx: ctx,
Config: config,
Factory: f,
resolvedAs: core.AsBot,
}
result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx")
if result == nil {
t.Fatal("expected non-nil result for bot mode with grant failure")
}
if result["status"] != PermissionGrantFailed {
t.Fatalf("status = %v, want %q", result["status"], PermissionGrantFailed)
}
if !strings.Contains(stderr.String(), "auto-grant failed") {
t.Fatalf("stderr missing auto-grant failed warning; got:\n%s", stderr.String())
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
t.Fatalf("hint = %#v, want string containing 'Retry later'", result["hint"])
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "scope") {
t.Fatalf("hint = %#v, want string containing 'scope'", result["hint"])
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "permission changes") {
t.Fatalf("hint = %#v, want string containing 'permission changes'", result["hint"])
}
}

View File

@@ -80,7 +80,7 @@ func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
func TestDocsCreateV2BotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
@@ -107,6 +107,9 @@ func TestDocsCreateV2BotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
if _, ok := grant["user_open_id"]; ok {
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
}
if !strings.Contains(stderr.String(), "auto-grant was skipped") {
t.Fatalf("stderr missing auto-grant skipped warning; got:\n%s", stderr.String())
}
}
func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) {
@@ -140,7 +143,7 @@ func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) {
func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
@@ -180,6 +183,9 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
if !strings.Contains(stderr.String(), "auto-grant failed") {
t.Fatalf("stderr missing auto-grant failed warning; got:\n%s", stderr.String())
}
}
func TestDocsCreateV2FallbackURLWhenBackendOmitsIt(t *testing.T) {

View File

@@ -47,6 +47,34 @@ const defaultLocateDocLimit = 10
// with `drive file.comments create_v2` against a fresh docx.
const maxCommentTotalRunes = 10000
// The file comment API treats supported Drive file comments as full-file
// comments in the UI, but currently rejects an empty anchor.block_id for file
// targets. TODO: remove this placeholder after the API accepts omitting
// anchor.block_id for file full comments.
const fileFullCommentAnchorBlockID = "test"
// File comments are enabled only for extensions verified to render correctly in
// the Lark file preview comment UI. Keep this list conservative: PDF, docx, and
// xlsx currently accept the API request but display poorly in the page.
var supportedFileCommentExtensions = []string{
".md",
".txt",
".json",
".csv",
".go",
".js",
".py",
".pptx",
".png",
".jpg",
".jpeg",
".zip",
".mp3",
".mp4",
}
var supportedFileCommentExtensionSet = newSupportedFileCommentExtensionSet(supportedFileCommentExtensions)
type commentDocRef struct {
Kind string
Token string
@@ -93,17 +121,18 @@ const (
var DriveAddComment = common.Shortcut{
Service: "drive",
Command: "+add-comment",
Description: "Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides",
Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only",
Risk: "write",
Scopes: []string{
"drive:drive.metadata:readonly",
"docx:document:readonly",
"docs:document.comment:create",
"docs:document.comment:write_only",
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/sheet/slides", Required: true},
{Name: "type", Desc: "document type: doc, docx, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "sheet", "slides"}},
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
{Name: "content", Desc: "reply_elements JSON string", Required: true},
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
@@ -145,7 +174,6 @@ var DriveAddComment = common.Shortcut{
}
return nil
}
selection := runtime.Str("selection-with-ellipsis")
blockID := strings.TrimSpace(runtime.Str("block-id"))
if strings.TrimSpace(selection) != "" && blockID != "" {
@@ -156,6 +184,9 @@ var DriveAddComment = common.Shortcut{
}
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
if docRef.Kind == "file" {
return validateFileCommentMode(mode, "")
}
if mode == commentModeLocal && docRef.Kind == "doc" {
return output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
}
@@ -217,6 +248,33 @@ var DriveAddComment = common.Shortcut{
Body(commentBody).
Set("file_token", resolvedToken)
}
if resolvedKind == "file" {
commentBody := buildCommentCreateV2Request("file", "", "", replyElements, nil)
desc := "2-step orchestration: verify supported file metadata -> create file comment"
verifyStep := "[1]"
createStep := "[2]"
if isWiki {
desc = "3-step orchestration: resolve wiki -> verify supported file metadata -> create file comment"
verifyStep = "[2]"
createStep = "[3]"
}
return common.NewDryRunAPI().
Desc(desc).
POST("/open-apis/drive/v1/metas/batch_query").
Desc(verifyStep+" Read file metadata and verify the title extension is supported").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": resolvedToken,
"doc_type": "file",
},
},
}).
POST("/open-apis/drive/v1/files/:file_token/new_comments").
Desc(createStep+" Create file full comment").
Body(commentBody).
Set("file_token", resolvedToken)
}
// Doc/docx comment dry-run.
createPath := "/open-apis/drive/v1/files/:file_token/new_comments"
@@ -317,6 +375,9 @@ var DriveAddComment = common.Shortcut{
if target.FileType == "slides" {
return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken})
}
if target.FileType == "file" {
return executeFileComment(runtime, target)
}
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
@@ -421,6 +482,9 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
if token, ok := extractURLToken(raw, "/sheets/"); ok {
return commentDocRef{Kind: "sheet", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/file/"); ok {
return commentDocRef{Kind: "file", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/slides/"); ok {
return commentDocRef{Kind: "slides", Token: token}, nil
}
@@ -431,7 +495,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
return commentDocRef{Kind: "doc", Token: token}, nil
}
if strings.Contains(raw, "://") {
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/sheet/slides", raw)
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw)
}
if strings.ContainsAny(raw, "/?#") {
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
@@ -440,7 +504,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
// Bare token: --type is required.
docType = strings.TrimSpace(docType)
if docType == "" {
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, sheet, slides)")
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)")
}
return commentDocRef{Kind: docType, Token: raw}, nil
}
@@ -451,9 +515,16 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
return resolvedCommentTarget{}, err
}
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
if mode == commentModeLocal && docRef.Kind != "docx" && docRef.Kind != "sheet" && docRef.Kind != "slides" {
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
if mode == commentModeLocal {
switch docRef.Kind {
case "doc":
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
case "file":
if err := validateFileCommentMode(mode, ""); err != nil {
return resolvedCommentTarget{}, err
}
}
}
return resolvedCommentTarget{
DocID: docRef.Token,
@@ -507,11 +578,24 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
WikiToken: docRef.Token,
}, nil
}
if objType == "file" {
if err := validateFileCommentMode(mode, objType); err != nil {
return resolvedCommentTarget{}, err
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
return resolvedCommentTarget{
DocID: objToken,
FileToken: objToken,
FileType: "file",
ResolvedBy: "wiki",
WikiToken: docRef.Token,
}, nil
}
if mode == commentModeLocal && objType != "docx" {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
}
if mode == commentModeFull && objType != "docx" && objType != "doc" {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/sheet/slides", objType)
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
@@ -718,6 +802,10 @@ func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, reply
"sheet_col": sheet.Col,
"sheet_row": sheet.Row,
}
} else if fileType == "file" {
body["anchor"] = map[string]interface{}{
"block_id": fileFullCommentAnchorBlockID,
}
} else if strings.TrimSpace(blockID) != "" {
body["anchor"] = map[string]interface{}{
"block_id": blockID,
@@ -809,6 +897,107 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) {
return &sheetAnchor{SheetID: sheetID, Col: col, Row: row}, nil
}
func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken string) (string, error) {
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": fileToken,
"doc_type": "file",
},
},
},
)
if err != nil {
return "", err
}
metas := common.GetSlice(data, "metas")
if len(metas) == 0 {
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken))
}
meta, ok := metas[0].(map[string]interface{})
if !ok {
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken))
}
return common.GetString(meta, "title"), nil
}
func ensureSupportedFileCommentTarget(runtime *common.RuntimeContext, fileToken string) (string, string, error) {
title, err := fetchCommentTargetFileTitle(runtime, fileToken)
if err != nil {
return "", "", err
}
extension := fileCommentExtension(title)
if isSupportedFileCommentExtension(extension) {
return title, extension, nil
}
if strings.TrimSpace(title) == "" {
return "", "", output.ErrWithHint(
output.ExitValidation,
"unsupported_file_comment_type",
"drive +add-comment does not support comments for this Drive file type yet; the file metadata did not return a title",
"file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(),
)
}
extensionLabel := extension
if extensionLabel == "" {
extensionLabel = "no extension"
}
return "", "", output.ErrWithHint(
output.ExitValidation,
"unsupported_file_comment_type",
fmt.Sprintf("drive +add-comment does not support comments for this Drive file type yet; got %q (%s)", title, extensionLabel),
"file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(),
)
}
func fileCommentExtension(title string) string {
title = strings.TrimSpace(title)
idx := strings.LastIndex(title, ".")
if idx == 0 {
extension := strings.ToLower(title)
if isSupportedFileCommentExtension(extension) {
return extension
}
return ""
}
if idx < 0 || idx == len(title)-1 {
return ""
}
return strings.ToLower(title[idx:])
}
func isSupportedFileCommentExtension(extension string) bool {
_, ok := supportedFileCommentExtensionSet[strings.TrimSpace(extension)]
return ok
}
func supportedFileCommentExtensionsText() string {
return strings.Join(supportedFileCommentExtensions, ", ")
}
func newSupportedFileCommentExtensionSet(extensions []string) map[string]struct{} {
set := make(map[string]struct{}, len(extensions))
for _, extension := range extensions {
set[extension] = struct{}{}
}
return set
}
func validateFileCommentMode(mode commentMode, resolvedObjType string) error {
if mode != commentModeLocal {
return nil
}
if resolvedObjType != "" {
return output.ErrValidation("wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType)
}
return output.ErrValidation("file comments only support full comments; omit --block-id and --selection-with-ellipsis")
}
func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
@@ -849,6 +1038,48 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
return nil
}
func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
return err
}
title, extension, err := ensureSupportedFileCommentTarget(runtime, target.FileToken)
if err != nil {
return err
}
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
requestBody := buildCommentCreateV2Request("file", "", "", replyElements, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Creating file comment in %s (%s)\n", common.MaskToken(target.FileToken), extension)
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
if err != nil {
return err
}
out := map[string]interface{}{
"comment_id": data["comment_id"],
"doc_id": target.DocID,
"file_token": target.FileToken,
"file_type": "file",
"file_name": title,
"file_extension": extension,
"resolved_by": target.ResolvedBy,
"comment_mode": string(commentModeFull),
}
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
out["created_at"] = createdAt
}
if target.WikiToken != "" {
out["wiki_token"] = target.WikiToken
}
runtime.Out(out, nil)
return nil
}
func executeSlidesComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {

View File

@@ -105,6 +105,13 @@ func TestParseCommentDocRef(t *testing.T) {
wantKind: "doc",
wantToken: "docToken",
},
{
name: "raw token with type file",
input: "fileToken",
docType: "file",
wantKind: "file",
wantToken: "fileToken",
},
{
name: "raw token without type",
input: "xxxxxx",
@@ -122,6 +129,12 @@ func TestParseCommentDocRef(t *testing.T) {
wantKind: "slides",
wantToken: "pres_123",
},
{
name: "file url",
input: "https://example.larksuite.com/file/boxcn123?from=share",
wantKind: "file",
wantToken: "boxcn123",
},
{
name: "unsupported url",
input: "https://example.com/not-a-doc",
@@ -545,6 +558,29 @@ func TestBuildCommentCreateV2RequestFull(t *testing.T) {
}
}
func TestBuildCommentCreateV2RequestFile(t *testing.T) {
t.Parallel()
replyElements := []map[string]interface{}{
{
"type": "text",
"text": "README comment",
},
}
got := buildCommentCreateV2Request("file", "", "", replyElements, nil)
if got["file_type"] != "file" {
t.Fatalf("expected file_type file, got %#v", got["file_type"])
}
anchor, ok := got["anchor"].(map[string]interface{})
if !ok {
t.Fatalf("expected anchor map, got %#v", got["anchor"])
}
if blockID, ok := anchor["block_id"].(string); !ok || blockID != fileFullCommentAnchorBlockID {
t.Fatalf("expected file anchor.block_id %q, got %#v", fileFullCommentAnchorBlockID, anchor["block_id"])
}
}
func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
t.Parallel()
@@ -906,6 +942,34 @@ func TestSlidesCommentValidateCompoundBlockID(t *testing.T) {
}
}
func TestFileCommentValidateRejectsBlockID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", "blk_123",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "file comments only support full comments") {
t.Fatalf("expected file local-comment rejection, got: %v", err)
}
}
func TestFileCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--selection-with-ellipsis", "something",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "file comments only support full comments") {
t.Fatalf("expected file local-comment rejection, got: %v", err)
}
}
// ── Slides comment execute tests ────────────────────────────────────────────
func TestSlidesCommentExecuteSuccess(t *testing.T) {
@@ -1116,6 +1180,146 @@ func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
}
}
func TestFileCommentExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"metas": []interface{}{
map[string]interface{}{"title": "README.txt"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/files/fileToken/new_comments",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"comment_id": "fileComment123", "created_at": 1700000000},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"请补充 README 示例"}]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "fileComment123") {
t.Fatalf("stdout missing comment_id: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "file" {
t.Fatalf("stdout file_type = %q, want file\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "file_name", "data.file_name"); got != "README.txt" {
t.Fatalf("stdout file_name = %q, want README.txt\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "file_extension", "data.file_extension"); got != ".txt" {
t.Fatalf("stdout file_extension = %q, want .txt\nstdout:\n%s", got, stdout.String())
}
}
func TestFileCommentExecuteRejectsUnsupportedFileType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"metas": []interface{}{
map[string]interface{}{"title": "notes.pdf"},
},
},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "does not support comments for this Drive file type yet") {
t.Fatalf("expected unsupported file comment type error, got: %v", err)
}
if !strings.Contains(err.Error(), "notes.pdf") {
t.Fatalf("expected error to mention unsupported title, got: %v", err)
}
}
func TestFileCommentExecuteRejectsUnexpectedMetadataFormat(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"metas": []interface{}{"unexpected"},
},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "unexpected metadata format") {
t.Fatalf("expected unexpected metadata format error, got: %v", err)
}
}
func TestFileCommentSupportedExtensions(t *testing.T) {
t.Parallel()
supported := []string{
"README.md",
"notes.TXT",
"data.json",
"table.csv",
"main.go",
"app.js",
"script.py",
"slides.pptx",
"image.png",
"photo.jpg",
"photo.jpeg",
".md",
"archive.zip",
"audio.mp3",
"video.mp4",
}
for _, title := range supported {
extension := fileCommentExtension(title)
if !isSupportedFileCommentExtension(extension) {
t.Fatalf("%s extension %q should be supported", title, extension)
}
}
unsupported := []string{
"report.pdf",
"word.docx",
"sheet.xlsx",
"unknown.bin",
"no-extension",
".gitignore",
}
for _, title := range unsupported {
extension := fileCommentExtension(title)
if isSupportedFileCommentExtension(extension) {
t.Fatalf("%s extension %q should not be supported", title, extension)
}
}
if extension := fileCommentExtension(".gitignore"); extension != "" {
t.Fatalf("dotfile extension = %q, want empty", extension)
}
}
// ── DryRun coverage ─────────────────────────────────────────────────────────
func TestDryRunSheetDirectURL(t *testing.T) {
@@ -1346,6 +1550,43 @@ func TestDryRunDocxFullComment(t *testing.T) {
}
}
func TestDryRunFileDirectURL(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "verify supported file metadata") {
t.Fatalf("dry-run output missing supported file metadata verification step: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
if len(api) != 2 {
t.Fatalf("expected 2 dry-run api calls, got %d\nstdout:\n%s", len(api), stdout.String())
}
verifyCall := mustMapValue(t, api[0], "api[0]")
createCall := mustMapValue(t, api[1], "api[1]")
verifyBody := mustMapValue(t, verifyCall["body"], "api[0].body")
createBody := mustMapValue(t, createCall["body"], "api[1].body")
requestDocs := mustSliceValue(t, verifyBody["request_docs"], "api[0].body.request_docs")
requestDoc := mustMapValue(t, requestDocs[0], "api[0].body.request_docs[0]")
if got := mustStringField(t, requestDoc, "doc_type", "api[0].body.request_docs[0].doc_type"); got != "file" {
t.Fatalf("metadata query doc_type = %q, want file\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, createBody, "file_type", "api[1].body.file_type"); got != "file" {
t.Fatalf("comment create file_type = %q, want file\nstdout:\n%s", got, stdout.String())
}
anchor := mustMapValue(t, createBody["anchor"], "api[1].body.anchor")
if got := mustStringField(t, anchor, "block_id", "api[1].body.anchor.block_id"); got != fileFullCommentAnchorBlockID {
t.Fatalf("comment create anchor.block_id = %q, want %q\nstdout:\n%s", got, fileFullCommentAnchorBlockID, stdout.String())
}
}
// ── resolveCommentTarget coverage ───────────────────────────────────────────
func TestResolveWikiToDocxFullComment(t *testing.T) {
@@ -1397,7 +1638,7 @@ func TestResolveWikiToUnsupportedType(t *testing.T) {
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/sheet/slides") {
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/file/sheet/slides") {
t.Fatalf("expected unsupported type error, got: %v", err)
}
}

View File

@@ -284,3 +284,94 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
}
return body
}
func TestDriveUploadBotAutoGrantSkippedNoUser(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, ""))
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"file_token": "file_skipped",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("report.pdf", []byte("pdf"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "report.pdf",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
}
}
func TestDriveUploadBotAutoGrantFailed(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"file_token": "file_grant_fail",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/file_grant_fail/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("report.pdf", []byte("pdf"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "report.pdf",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
}
}

View File

@@ -33,6 +33,13 @@ func markdownTestConfig() *core.CliConfig {
}
}
func markdownPermissionTestConfig(userOpenID string) *core.CliConfig {
return &core.CliConfig{
AppID: "markdown-perm-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
UserOpenId: userOpenID,
}
}
func mountAndRunMarkdown(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "markdown"}
@@ -647,6 +654,114 @@ func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
}
}
func TestMarkdownCreateBotAutoGrantSkippedNoUser(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownPermissionTestConfig(""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_skipped",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "box_md_skipped", "doc_type": "file", "url": "https://example.feishu.cn/file/box_md_skipped"},
},
},
},
})
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello\n",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal: %v", err)
}
grant, _ := envelope.Data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
}
}
func TestMarkdownCreateBotAutoGrantFailed(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownPermissionTestConfig("ou_current_user"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_grant_fail",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "box_md_grant_fail", "doc_type": "file", "url": "https://example.feishu.cn/file/box_md_grant_fail"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/box_md_grant_fail/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
})
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello\n",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal: %v", err)
}
grant, _ := envelope.Data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
}
}
func TestMarkdownCreateMissingFileReturnsReadError(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())

View File

@@ -5,13 +5,18 @@ package shortcuts
import (
"context"
"fmt"
"slices"
"github.com/larksuite/cli/shortcuts/okr"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdmeta"
"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/apps"
"github.com/larksuite/cli/shortcuts/base"
"github.com/larksuite/cli/shortcuts/calendar"
"github.com/larksuite/cli/shortcuts/common"
@@ -31,10 +36,28 @@ import (
"github.com/larksuite/cli/shortcuts/wiki"
)
// Empty brand (no config loaded) is treated as no-restriction so bootstrap
// paths and tests without config still see the full service list.
var brandRestrictedServices = map[string][]core.LarkBrand{
"apps": {core.BrandFeishu},
}
func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool {
allowed, ok := brandRestrictedServices[service]
if !ok {
return true
}
if brand == "" {
return true
}
return slices.Contains(allowed, brand)
}
// allShortcuts aggregates shortcuts from all domain packages.
var allShortcuts []common.Shortcut
func init() {
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
@@ -67,6 +90,14 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) {
}
func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) {
// Factory.Config may be nil in tests that pass a zero-value factory.
var brand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
brand = cfg.Brand
}
}
// Group by service
byService := make(map[string][]common.Shortcut)
for _, s := range allShortcuts {
@@ -115,5 +146,46 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
if service == "mail" {
mail.InstallOnMail(svc)
}
if !IsShortcutServiceAvailable(service, brand) {
installBrandRestrictionGuard(svc, service, brand)
}
}
}
// Mirrors internal/cmdpolicy/apply.go::installDenyStub: DisableFlagParsing +
// ArbitraryArgs keep cobra from short-circuiting with "missing required flag"
// before our RunE runs; leaf-level PersistentPreRunE defeats cobra's "first
// PreRunE wins" walk-up that would otherwise shadow the stub.
func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core.LarkBrand) {
stub := func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return output.ErrValidation(
"the %q feature is not yet supported on the %s brand",
service, brand,
)
}
noopPreRun := func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
}
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
c.Hidden = true
c.DisableFlagParsing = true
c.Args = cobra.ArbitraryArgs
c.PreRunE = nil
c.PreRun = nil
c.PersistentPreRunE = noopPreRun
c.PersistentPreRun = nil
c.RunE = stub
c.Run = nil
for _, child := range c.Commands() {
walk(child)
}
}
walk(svc)
// --help bypasses RunE, so surface the restriction in Long too.
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
}

View File

@@ -0,0 +1,122 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package shortcuts
import (
"context"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
func newFactoryWithBrand(brand core.LarkBrand) *cmdutil.Factory {
return &cmdutil.Factory{
Config: func() (*core.CliConfig, error) {
return &core.CliConfig{Brand: brand}, nil
},
}
}
func findChild(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
}
return nil
}
func TestBrandGuard_AppsStaysRegisteredOnLark(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark))
apps := findChild(program, "apps")
if apps == nil {
t.Fatal("apps service command should be registered on Lark brand (so users see a clear brand error, not 'unknown command')")
}
if !apps.Hidden {
t.Error("apps service command should be Hidden on Lark brand")
}
if len(apps.Commands()) == 0 {
t.Error("apps subcommands should still be mounted (so children also hit the brand-restriction stub)")
}
for _, child := range apps.Commands() {
if !child.Hidden {
t.Errorf("apps child %q should be Hidden on Lark brand", child.Name())
}
}
}
func TestBrandGuard_AppsExecuteReturnsBrandError(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark))
apps := findChild(program, "apps")
if apps == nil {
t.Fatal("apps should be registered")
}
create := findChild(apps, "+create")
if create == nil {
t.Fatal("apps +create should be registered")
}
err := create.RunE(create, []string{"--name", "x"})
if err == nil {
t.Fatal("expected brand-restriction error, got nil")
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("expected ExitValidation (%d), got %d", output.ExitValidation, exitErr.Code)
}
if !strings.Contains(exitErr.Error(), "apps") || !strings.Contains(exitErr.Error(), "lark") {
t.Errorf("expected error to mention apps + lark, got: %s", exitErr.Error())
}
}
func TestBrandGuard_AppsExecutableOnFeishu(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newFactoryWithBrand(core.BrandFeishu))
apps := findChild(program, "apps")
if apps == nil {
t.Fatal("apps should be registered on Feishu brand")
}
if apps.Hidden {
t.Error("apps should NOT be Hidden on Feishu brand")
}
create := findChild(apps, "+create")
if create == nil {
t.Fatal("apps +create should be registered on Feishu brand")
}
if create.DisableFlagParsing {
t.Error("apps +create should not have DisableFlagParsing on Feishu (the guard must not have run)")
}
}
func TestBrandGuard_DispatchHitsStubViaCobra(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark))
program.SetArgs([]string{"apps", "+create", "--name", "x", "--app-type", "HTML"})
program.SetContext(context.Background())
err := program.Execute()
if err == nil {
t.Fatal("expected error from dispatching apps +create on Lark brand")
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError from cobra dispatch, got %T: %v", err, err)
}
if !strings.Contains(exitErr.Error(), "lark") {
t.Errorf("dispatched error should mention lark brand, got: %s", exitErr.Error())
}
}

View File

@@ -5,13 +5,15 @@ package sheets
import (
"context"
"errors"
"fmt"
"io/fs"
"io"
"os"
"path/filepath"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -44,10 +46,6 @@ var SheetWriteImage = common.Shortcut{
if err := validateSingleCellRange(runtime.Str("range")); err != nil {
return err
}
_, _, err := validateSheetWriteImageFile(runtime.Str("image"))
if err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -79,12 +77,19 @@ var SheetWriteImage = common.Shortcut{
pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
imagePath := runtime.Str("image")
safePath, stat, err := validateSheetWriteImageFile(imagePath)
fio := runtime.FileIO()
stat, err := validateSheetWriteImageFile(fio, imagePath)
if err != nil {
return err
}
imageBytes, err := vfs.ReadFile(safePath)
imageFile, err := fio.Open(imagePath)
if err != nil {
return wrapSheetWriteImageOpenError(err)
}
defer imageFile.Close()
imageBytes, err := io.ReadAll(imageFile)
if err != nil {
return output.ErrValidation("cannot read image file: %s", err)
}
@@ -109,21 +114,37 @@ var SheetWriteImage = common.Shortcut{
},
}
func validateSheetWriteImageFile(imagePath string) (string, fs.FileInfo, error) {
safePath, err := validate.SafeInputPath(imagePath)
if err != nil {
return "", nil, output.ErrValidation("unsafe image path: %s", err)
func validateSheetWriteImageFile(fio fileio.FileIO, imagePath string) (fileio.FileInfo, error) {
if fio == nil {
return nil, output.ErrValidation("no file I/O provider registered")
}
stat, err := vfs.Stat(safePath)
stat, err := fio.Stat(imagePath)
if err != nil {
return "", nil, output.ErrValidation("image file not found: %s", imagePath)
return nil, wrapSheetWriteImageStatError(err, imagePath)
}
if !stat.Mode().IsRegular() {
return "", nil, output.ErrValidation("image must be a regular file: %s", imagePath)
if stat.IsDir() || !stat.Mode().IsRegular() {
return nil, output.ErrValidation("image must be a regular file: %s", imagePath)
}
const maxImageSize int64 = 20 * 1024 * 1024
if stat.Size() > maxImageSize {
return "", nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
return nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
}
return safePath, stat, nil
return stat, nil
}
func wrapSheetWriteImageStatError(err error, imagePath string) error {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe image path: %s", err)
}
if os.IsNotExist(err) {
return output.ErrValidation("image file not found: %s", imagePath)
}
return output.ErrValidation("cannot stat image file: %s", err)
}
func wrapSheetWriteImageOpenError(err error) error {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe image path: %s", err)
}
return output.ErrValidation("cannot read image file: %s", err)
}

View File

@@ -304,3 +304,88 @@ func decodeSheetCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]in
}
return data
}
func TestSheetCreateBotAutoGrantSkippedNoUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_skipped",
"url": "https://example.feishu.cn/sheets/shtcn_skipped",
},
},
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "No User Sheet",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
}
}
func TestSheetCreateBotAutoGrantFailed(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "ou_current_user"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_grant_fail",
"url": "https://example.feishu.cn/sheets/shtcn_grant_fail",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/shtcn_grant_fail/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Grant Fail Sheet",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
}
}

View File

@@ -7,12 +7,15 @@ import (
"bytes"
"context"
"encoding/json"
"io"
"io/fs"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -38,6 +41,56 @@ func mountAndRunSheets(t *testing.T, s common.Shortcut, args []string, f *cmduti
return parent.Execute()
}
type sheetWriteImageStaticFileIOProvider struct {
fio fileio.FileIO
}
func (p *sheetWriteImageStaticFileIOProvider) Name() string { return "sheet-write-image-static" }
func (p *sheetWriteImageStaticFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO {
return p.fio
}
type sheetWriteImageMemoryFileIO struct {
files map[string][]byte
}
func (f *sheetWriteImageMemoryFileIO) Open(name string) (fileio.File, error) {
data, ok := f.files[name]
if !ok {
return nil, os.ErrNotExist
}
return sheetWriteImageMemoryFile{Reader: bytes.NewReader(data)}, nil
}
func (f *sheetWriteImageMemoryFileIO) Stat(name string) (fileio.FileInfo, error) {
data, ok := f.files[name]
if !ok {
return nil, os.ErrNotExist
}
return sheetWriteImageFileInfo{size: int64(len(data))}, nil
}
func (f *sheetWriteImageMemoryFileIO) ResolvePath(path string) (string, error) { return path, nil }
func (f *sheetWriteImageMemoryFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
return nil, nil
}
type sheetWriteImageMemoryFile struct {
*bytes.Reader
}
func (sheetWriteImageMemoryFile) Close() error { return nil }
type sheetWriteImageFileInfo struct {
size int64
}
func (i sheetWriteImageFileInfo) Size() int64 { return i.size }
func (i sheetWriteImageFileInfo) IsDir() bool { return false }
func (i sheetWriteImageFileInfo) Mode() fs.FileMode { return 0 }
const existingWriteImageTestFile = "./lark_sheets_cell_images.go"
// ── Validate ─────────────────────────────────────────────────────────────────
@@ -221,80 +274,20 @@ func TestSheetWriteImageDryRunWithSheetID(t *testing.T) {
}
}
func TestSheetWriteImageDryRunRejectsMissingFile(t *testing.T) {
func TestSheetWriteImageDryRunDoesNotValidateImageFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "./missing.png",
"--image", "/__bridge_url__/qKrk1wSAtS",
"--dry-run", "--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "image file not found") {
t.Fatalf("expected file-not-found error before dry-run planning, got: %v", err)
}
}
func TestSheetWriteImageDryRunRejectsDirectory(t *testing.T) {
tmpDir := t.TempDir()
cmdutil.TestChdir(t, tmpDir)
if err := os.Mkdir("imgdir", 0o755); err != nil {
t.Fatalf("Mkdir() error: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "./imgdir",
"--dry-run", "--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "regular file") {
t.Fatalf("expected regular-file error before dry-run planning, got: %v", err)
}
}
func TestSheetWriteImageDryRunRejectsAbsolutePath(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "/etc/passwd",
"--dry-run", "--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "unsafe image path") {
t.Fatalf("expected unsafe-path error before dry-run planning, got: %v", err)
}
}
func TestSheetWriteImageDryRunRejectsOversizedFile(t *testing.T) {
tmpDir := t.TempDir()
cmdutil.TestChdir(t, tmpDir)
fh, err := os.Create("huge.png")
if err != nil {
t.Fatalf("Create() error: %v", err)
t.Fatalf("dry-run should not stat or open image files, got: %v", err)
}
if err := fh.Truncate(20*1024*1024 + 1); err != nil {
fh.Close()
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err = mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "./huge.png",
"--dry-run", "--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "exceeds 20MB limit") {
t.Fatalf("expected size error before dry-run planning, got: %v", err)
if !strings.Contains(stdout.String(), "/__bridge_url__/qKrk1wSAtS") {
t.Fatalf("dry-run output should preserve image path: %s", stdout.String())
}
}
@@ -368,6 +361,55 @@ func TestSheetWriteImageExecuteSendsJSON(t *testing.T) {
}
}
func TestSheetWriteImageExecuteUsesFileIOForBridgeSentinelPath(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
imagePath := "/__bridge_url__/qKrk1wSAtS"
imageData := []byte{0x89, 0x50, 0x4E, 0x47}
f.FileIOProvider = &sheetWriteImageStaticFileIOProvider{
fio: &sheetWriteImageMemoryFileIO{
files: map[string][]byte{imagePath: imageData},
},
}
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"spreadsheetToken": "shtTOKEN",
"revision": float64(5),
"updateRange": "sheet1!A1:A1",
},
},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", imagePath,
"--name", "bridge.png",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("request body is not valid JSON: %v", err)
}
if body["name"] != "bridge.png" {
t.Fatalf("body name = %v, want bridge.png", body["name"])
}
if body["image"] == nil {
t.Fatal("body image field is nil")
}
}
func TestSheetWriteImageExecuteRejectsNonexistentFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())

View File

@@ -147,6 +147,55 @@ func TestSlidesCreateBotSkippedWithoutCurrentUser(t *testing.T) {
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
}
}
func TestSlidesCreateBotAutoGrantFailed(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "ou_current_user"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_grant_fail",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_grant_fail", "https://example.feishu.cn/slides/pres_grant_fail")
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/pres_grant_fail/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
})
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Grant Fail PPT",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantFailed)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
}
}
// TestSlidesCreateDryRunDefaultTitle verifies that dry-run also normalizes an empty title to "Untitled".

View File

@@ -17,5 +17,8 @@ func Shortcuts() []common.Shortcut {
WikiNodeCopy,
WikiNodeGet,
WikiNodeDelete,
WikiMemberAdd,
WikiMemberRemove,
WikiMemberList,
}
}

View File

@@ -0,0 +1,176 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// WikiMemberAdd wraps POST /open-apis/wiki/v2/spaces/{space_id}/members. The
// shortcut adds flag ergonomics over the raw API: explicit --member-type and
// --member-role enum hints, optional --need-notification, my_library
// resolution, and a flattened single-member output envelope.
var WikiMemberAdd = common.Shortcut{
Service: "wiki",
Command: "+member-add",
Description: "Add a member to a wiki space",
Risk: "write",
// The API also accepts wiki:wiki, but the framework's preflight does
// exact-string scope matching (see +space-list), so declare the narrowest
// scope so tokens that only carry wiki:member:create aren't false-rejected.
Scopes: []string{"wiki:member:create"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "space-id", Desc: "wiki space ID; use my_library for the personal document library (user only)", Required: true},
{Name: "member-id", Desc: "member ID; interpretation is decided by --member-type", Required: true},
{Name: "member-type", Desc: "ID type for --member-id", Required: true, Enum: wikiMemberTypes},
{Name: "member-role", Desc: "role granted within the space", Required: true, Enum: wikiMemberRoles},
{Name: "need-notification", Type: "bool", Desc: "send an in-app notification to the new member after the grant"},
},
Tips: []string{
"Use --member-type=email with the user's mailbox if you do not know their open_id.",
"--member-role=admin grants full space administration; pick --member-role=member for collaborator access.",
"--space-id my_library is a per-user alias and is only valid with --as user.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readWikiMemberAddSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readWikiMemberAddSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildWikiMemberAddDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readWikiMemberAddSpec(runtime)
if err != nil {
return err
}
spaceID, err := resolveWikiMemberSpaceID(runtime, spec.SpaceID)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Adding wiki space member %s (type=%s, role=%s) to space %s...\n",
common.MaskToken(spec.MemberID), spec.MemberType, spec.MemberRole, common.MaskToken(spaceID))
path := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", validate.EncodePathSegment(spaceID))
data, err := runtime.CallAPI("POST", path, spec.QueryParams(), spec.RequestBody())
if err != nil {
return err
}
out := wikiMemberAddOutput(spaceID, common.GetMap(data, "member"))
// Defensive default: mirror +member-remove and fall back to the caller's
// inputs per-field when the API echoes empty strings or omits member
// fields, so scripts always see what was added.
if common.GetString(out, "member_id") == "" {
out["member_id"] = spec.MemberID
}
if common.GetString(out, "member_type") == "" {
out["member_type"] = spec.MemberType
}
if common.GetString(out, "member_role") == "" {
out["member_role"] = spec.MemberRole
}
fmt.Fprintf(runtime.IO().ErrOut, "Added wiki space member %s\n", common.MaskToken(common.GetString(out, "member_id")))
runtime.Out(out, nil)
return nil
},
}
// wikiMemberAddSpec is the normalized CLI input.
type wikiMemberAddSpec struct {
SpaceID string
MemberID string
MemberType string
MemberRole string
NeedNotification bool
NotificationSet bool
}
// RequestBody builds the JSON body for POST /spaces/{id}/members.
func (spec wikiMemberAddSpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"member_id": spec.MemberID,
"member_type": spec.MemberType,
"member_role": spec.MemberRole,
}
}
// QueryParams returns nil unless the caller explicitly set --need-notification,
// so the request stays clean when the flag is omitted instead of always
// forcing need_notification=false.
func (spec wikiMemberAddSpec) QueryParams() map[string]interface{} {
if !spec.NotificationSet {
return nil
}
return map[string]interface{}{"need_notification": spec.NeedNotification}
}
func readWikiMemberAddSpec(runtime *common.RuntimeContext) (wikiMemberAddSpec, error) {
spec := wikiMemberAddSpec{
SpaceID: strings.TrimSpace(runtime.Str("space-id")),
MemberID: strings.TrimSpace(runtime.Str("member-id")),
MemberType: strings.ToLower(strings.TrimSpace(runtime.Str("member-type"))),
MemberRole: strings.ToLower(strings.TrimSpace(runtime.Str("member-role"))),
NeedNotification: runtime.Bool("need-notification"),
NotificationSet: runtime.Cmd.Flags().Changed("need-notification"),
}
if err := validateWikiMemberSpaceID(runtime, spec.SpaceID); err != nil {
return wikiMemberAddSpec{}, err
}
if spec.MemberID == "" {
return wikiMemberAddSpec{}, output.ErrValidation("--member-id is required and cannot be blank")
}
// The space-member API rejects opendepartmentid grants under a
// tenant_access_token; surface that as a CLI validation error so callers do
// not waste a network round-trip on a server-side 403. The escape hatch is
// --as user, which is the only identity the API accepts for departments.
if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" {
return wikiMemberAddSpec{}, output.ErrValidation(
"--as bot does not support --member-type opendepartmentid; rerun with --as user",
)
}
// --member-type / --member-role enum membership is enforced by the
// framework's validateEnumFlags (runner.go) before Validate runs, so no
// extra membership check is needed here.
return spec, nil
}
func buildWikiMemberAddDryRun(spec wikiMemberAddSpec) *common.DryRunAPI {
dry := common.NewDryRunAPI()
if spec.SpaceID == wikiMyLibrarySpaceID {
dry.Desc("2-step orchestration: resolve my_library -> add wiki space member").
GET("/open-apis/wiki/v2/spaces/my_library").
Desc("[1] Resolve my_library space ID")
dry.POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", "<resolved_space_id>")).
Desc("[2] Add wiki space member").
Params(spec.QueryParams()).
Body(spec.RequestBody())
return dry
}
return dry.POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", validate.EncodePathSegment(spec.SpaceID))).
Params(spec.QueryParams()).
Body(spec.RequestBody())
}
// wikiMemberAddOutput flattens data.member onto a top-level envelope so
// scripts can read member fields without traversing the nested response.
func wikiMemberAddOutput(spaceID string, raw map[string]interface{}) map[string]interface{} {
out := map[string]interface{}{"space_id": spaceID}
for k, v := range wikiMemberRecord(raw) {
out[k] = v
}
return out
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// wikiMemberTypes is the set of member_type values the space-member APIs
// accept. Shared by +member-add and +member-remove so the two stay aligned.
var wikiMemberTypes = []string{
"openid", "userid", "email", "unionid", "openchat", "opendepartmentid",
}
// wikiMemberRoles is the set of member_role values the space-member APIs
// accept.
var wikiMemberRoles = []string{"admin", "member"}
// validateWikiMemberSpaceID enforces the two universal rules for the
// space-member shortcuts:
// - --space-id must be non-blank and a valid resource name
// - bot identity may not use the my_library alias (it has no meaning for a
// tenant_access_token; same contract as +node-list / +node-create)
func validateWikiMemberSpaceID(runtime *common.RuntimeContext, spaceID string) error {
if spaceID == "" {
return output.ErrValidation("--space-id is required and cannot be blank")
}
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id")
}
return validateOptionalResourceName(spaceID, "--space-id")
}
// resolveWikiMemberSpaceID transparently expands the my_library alias to the
// caller's real per-user space_id; raw IDs pass through. Mirrors the pattern
// used by +node-list so the three member shortcuts behave the same way.
func resolveWikiMemberSpaceID(runtime *common.RuntimeContext, spaceID string) (string, error) {
if spaceID != wikiMyLibrarySpaceID {
return spaceID, nil
}
resolved, err := resolveMyLibrarySpaceID(runtime)
if err != nil {
return "", err
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved my_library to space %s\n", common.MaskToken(resolved))
return resolved, nil
}
// wikiMemberRecord parses a /spaces/{id}/members member object into a stable
// flat map. Used by all three shortcuts so they emit the same shape.
func wikiMemberRecord(raw map[string]interface{}) map[string]interface{} {
if raw == nil {
// Callers (wikiMemberAddOutput, member-remove Execute) handle nil via
// for-range or per-field fallback against the caller's input spec.
return nil
}
out := map[string]interface{}{
"member_id": common.GetString(raw, "member_id"),
"member_type": common.GetString(raw, "member_type"),
"member_role": common.GetString(raw, "member_role"),
}
if t := common.GetString(raw, "type"); t != "" {
out["type"] = t
}
return out
}

View File

@@ -0,0 +1,183 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
wikiMemberListDefaultPageSize = 50
wikiMemberListMaxPageSize = 50
)
// WikiMemberList lists the members of a wiki space. Pagination follows the
// same conventions as +space-list / +node-list (single page by default,
// --page-all to walk every page, --page-token for explicit cursor resume).
var WikiMemberList = common.Shortcut{
Service: "wiki",
Command: "+member-list",
Description: "List members of a wiki space",
Risk: "read",
// Same exact-match-scope rationale as +space-list: declare the narrowest
// scope the API takes so tokens carrying only wiki:member:retrieve are
// accepted.
Scopes: []string{"wiki:member:retrieve"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "space-id", Desc: "wiki space ID; use my_library for the personal document library (user only)", Required: true},
{Name: "page-size", Type: "int", Default: strconv.Itoa(wikiMemberListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", wikiMemberListMaxPageSize)},
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
},
Tips: []string{
"Default fetches a single page; pass --page-all to walk every page.",
"--space-id my_library is a per-user alias and is only valid with --as user.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateWikiMemberSpaceID(runtime, strings.TrimSpace(runtime.Str("space-id"))); err != nil {
return err
}
return validateWikiListPagination(runtime, wikiMemberListMaxPageSize)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
params["page_token"] = pt
}
dry := common.NewDryRunAPI()
if wikiListShouldAutoPaginate(runtime) {
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
if spaceID == wikiMyLibrarySpaceID {
return dry.
Desc("2-step orchestration: resolve my_library -> list members").
GET("/open-apis/wiki/v2/spaces/my_library").
Desc("[1] Resolve my_library space ID").
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", "<resolved_space_id>")).
Desc("[2] List wiki space members").
Params(params)
}
return dry.
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", validate.EncodePathSegment(spaceID))).
Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
spaceID, err := resolveWikiMemberSpaceID(runtime, strings.TrimSpace(runtime.Str("space-id")))
if err != nil {
return err
}
members, hasMore, nextToken, err := fetchWikiMembers(runtime, spaceID)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Found %d wiki space member(s)\n", len(members))
outData := map[string]interface{}{
"space_id": spaceID,
"members": members,
"has_more": hasMore,
"page_token": nextToken,
}
runtime.OutFormat(outData, &output.Meta{Count: len(members)}, func(w io.Writer) {
renderWikiMembersPretty(w, spaceID, members, hasMore, nextToken)
})
return nil
},
}
// fetchWikiMembers honours the four pagination flags, matching +space-list /
// +node-list behavior so the three list shortcuts feel uniform.
func fetchWikiMembers(runtime *common.RuntimeContext, spaceID string) ([]map[string]interface{}, bool, string, error) {
pageSize := runtime.Int("page-size")
startToken := strings.TrimSpace(runtime.Str("page-token"))
auto := wikiListShouldAutoPaginate(runtime)
pageLimit := runtime.Int("page-limit")
apiPath := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", validate.EncodePathSegment(spaceID))
var (
members = make([]map[string]interface{}, 0)
pageToken = startToken
lastHasMore bool
lastPageToken string
)
for page := 0; ; page++ {
params := map[string]interface{}{"page_size": pageSize}
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", apiPath, params, nil)
if err != nil {
return nil, false, "", err
}
items, _ := data["members"].([]interface{})
for _, item := range items {
if m, ok := item.(map[string]interface{}); ok {
members = append(members, wikiMemberRecord(m))
}
}
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !auto {
break
}
if !lastHasMore || lastPageToken == "" {
break
}
if lastPageToken == pageToken {
// Guard against a buggy server echoing the same cursor with
// has_more=true: without --page-limit we would loop forever.
fmt.Fprintf(runtime.IO().ErrOut, "Stopping pagination: server returned a non-advancing page_token.\n")
break
}
if pageLimit > 0 && page+1 >= pageLimit {
break
}
pageToken = lastPageToken
}
return members, lastHasMore, lastPageToken, nil
}
func renderWikiMembersPretty(w io.Writer, spaceID string, members []map[string]interface{}, hasMore bool, pageToken string) {
fmt.Fprintf(w, "Wiki space: %s\n", spaceID)
if len(members) == 0 {
// Distinguish "nothing here" from "current page empty but server says
// more pages follow" — the latter is a hint to keep paginating.
if hasMore && pageToken != "" {
fmt.Fprintln(w, "Current page is empty but the server reports more pages.")
fmt.Fprintln(w, "Pass --page-all to walk every page, or --page-token to resume from the cursor below:")
fmt.Fprintf(w, " next page_token: %s\n", pageToken)
return
}
fmt.Fprintln(w, "No wiki space members found.")
return
}
for i, m := range members {
fmt.Fprintf(w, "[%d] %s\n", i+1, valueOrDash(m["member_id"]))
fmt.Fprintf(w, " member_type: %s\n", valueOrDash(m["member_type"]))
fmt.Fprintf(w, " member_role: %s\n", valueOrDash(m["member_role"]))
if t, _ := m["type"].(string); t != "" {
fmt.Fprintf(w, " type: %s\n", t)
}
fmt.Fprintln(w)
}
if hasMore && pageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", pageToken)
}
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// WikiMemberRemove wraps DELETE /open-apis/wiki/v2/spaces/{space_id}/members/{member_id}.
// Unlike most DELETEs, this API requires a body specifying member_type and
// member_role, since the path :member_id is ambiguous without both. The
// shortcut surfaces both as flags and flattens the returned member object.
var WikiMemberRemove = common.Shortcut{
Service: "wiki",
Command: "+member-remove",
Description: "Remove a member from a wiki space",
Risk: "write",
// The API also accepts wiki:wiki; we declare the narrowest valid scope so
// tokens carrying only wiki:member:update aren't false-rejected by the
// exact-string scope preflight (see +space-list for the full reasoning).
Scopes: []string{"wiki:member:update"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "space-id", Desc: "wiki space ID; use my_library for the personal document library (user only)", Required: true},
{Name: "member-id", Desc: "member ID; interpretation is decided by --member-type", Required: true},
{Name: "member-type", Desc: "ID type for --member-id (must match the original grant)", Required: true, Enum: wikiMemberTypes},
{Name: "member-role", Desc: "role being revoked (must match the original grant)", Required: true, Enum: wikiMemberRoles},
},
Tips: []string{
"--member-type and --member-role must match the original grant; revoking a non-existent (member_id, type, role) tuple is a no-op error from the API.",
"To switch a member from admin to member or vice versa, remove the old role first, then call +member-add with the new one.",
"--space-id my_library is a per-user alias and is only valid with --as user.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readWikiMemberRemoveSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readWikiMemberRemoveSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildWikiMemberRemoveDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readWikiMemberRemoveSpec(runtime)
if err != nil {
return err
}
spaceID, err := resolveWikiMemberSpaceID(runtime, spec.SpaceID)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Removing wiki space member %s (type=%s, role=%s) from space %s...\n",
common.MaskToken(spec.MemberID), spec.MemberType, spec.MemberRole, common.MaskToken(spaceID))
path := fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/members/%s",
validate.EncodePathSegment(spaceID),
validate.EncodePathSegment(spec.MemberID),
)
data, err := runtime.CallAPI("DELETE", path, nil, spec.RequestBody())
if err != nil {
return err
}
out := map[string]interface{}{"space_id": spaceID}
for k, v := range wikiMemberRecord(common.GetMap(data, "member")) {
out[k] = v
}
// Defensive default: if the API omits the member echo, or echoes empty
// strings for any of the three identifying fields, fall back to the
// caller's inputs per-field so scripts still see what was removed.
if common.GetString(out, "member_id") == "" {
out["member_id"] = spec.MemberID
}
if common.GetString(out, "member_type") == "" {
out["member_type"] = spec.MemberType
}
if common.GetString(out, "member_role") == "" {
out["member_role"] = spec.MemberRole
}
fmt.Fprintf(runtime.IO().ErrOut, "Removed wiki space member %s\n", common.MaskToken(common.GetString(out, "member_id")))
runtime.Out(out, nil)
return nil
},
}
// wikiMemberRemoveSpec is the normalized CLI input.
type wikiMemberRemoveSpec struct {
SpaceID string
MemberID string
MemberType string
MemberRole string
}
// RequestBody builds the JSON body the DELETE endpoint requires.
func (spec wikiMemberRemoveSpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"member_type": spec.MemberType,
"member_role": spec.MemberRole,
}
}
func readWikiMemberRemoveSpec(runtime *common.RuntimeContext) (wikiMemberRemoveSpec, error) {
spec := wikiMemberRemoveSpec{
SpaceID: strings.TrimSpace(runtime.Str("space-id")),
MemberID: strings.TrimSpace(runtime.Str("member-id")),
MemberType: strings.ToLower(strings.TrimSpace(runtime.Str("member-type"))),
MemberRole: strings.ToLower(strings.TrimSpace(runtime.Str("member-role"))),
}
if err := validateWikiMemberSpaceID(runtime, spec.SpaceID); err != nil {
return wikiMemberRemoveSpec{}, err
}
if spec.MemberID == "" {
return wikiMemberRemoveSpec{}, output.ErrValidation("--member-id is required and cannot be blank")
}
// Enum membership for --member-type / --member-role is enforced by the
// framework's validateEnumFlags (runner.go) before Validate runs.
return spec, nil
}
func buildWikiMemberRemoveDryRun(spec wikiMemberRemoveSpec) *common.DryRunAPI {
dry := common.NewDryRunAPI()
if spec.SpaceID == wikiMyLibrarySpaceID {
dry.Desc("2-step orchestration: resolve my_library -> remove wiki space member").
GET("/open-apis/wiki/v2/spaces/my_library").
Desc("[1] Resolve my_library space ID")
dry.DELETE(fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/members/%s",
"<resolved_space_id>",
validate.EncodePathSegment(spec.MemberID),
)).
Desc("[2] Remove wiki space member").
Body(spec.RequestBody())
return dry
}
return dry.DELETE(fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/members/%s",
validate.EncodePathSegment(spec.SpaceID),
validate.EncodePathSegment(spec.MemberID),
)).
Body(spec.RequestBody())
}

View File

@@ -0,0 +1,734 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"encoding/json"
"net/http"
"net/url"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// ── registration / declared contract ────────────────────────────────────────
func TestWikiShortcutsIncludesMembers(t *testing.T) {
t.Parallel()
commands := map[string]bool{}
for _, s := range Shortcuts() {
commands[s.Command] = true
}
for _, want := range []string{"+member-add", "+member-remove", "+member-list"} {
if !commands[want] {
t.Errorf("Shortcuts() missing %q", want)
}
}
}
// TestWikiMemberShortcutsDeclareNarrowScopes pins the per-endpoint scope so a
// future broadening (e.g. wiki:wiki) doesn't silently reject tokens that
// carry only the narrow scope the API accepts.
func TestWikiMemberShortcutsDeclareNarrowScopes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
shortcut common.Shortcut
want []string
}{
{"+member-add", WikiMemberAdd, []string{"wiki:member:create"}},
{"+member-remove", WikiMemberRemove, []string{"wiki:member:update"}},
{"+member-list", WikiMemberList, []string{"wiki:member:retrieve"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if !reflect.DeepEqual(tc.shortcut.Scopes, tc.want) {
t.Fatalf("%s scopes = %v, want %v", tc.name, tc.shortcut.Scopes, tc.want)
}
})
}
}
func TestWikiMemberShortcutsDeclareRiskAndAuth(t *testing.T) {
t.Parallel()
cases := []struct {
name string
shortcut common.Shortcut
risk string
}{
{"+member-add", WikiMemberAdd, "write"},
{"+member-remove", WikiMemberRemove, "write"},
{"+member-list", WikiMemberList, "read"},
}
wantAuth := []string{"user", "bot"}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.shortcut.Risk != tc.risk {
t.Errorf("Risk = %q, want %q", tc.shortcut.Risk, tc.risk)
}
if !reflect.DeepEqual(tc.shortcut.AuthTypes, wantAuth) {
t.Errorf("AuthTypes = %v, want %v", tc.shortcut.AuthTypes, wantAuth)
}
})
}
}
// ── +member-add ──────────────────────────────────────────────────────────────
func TestWikiMemberAddRequestBodyOmitsQueryWhenNotificationFlagUnset(t *testing.T) {
t.Parallel()
spec := wikiMemberAddSpec{
SpaceID: "space_1",
MemberID: "ou_x",
MemberType: "openid",
MemberRole: "member",
}
if got := spec.QueryParams(); got != nil {
t.Fatalf("QueryParams() = %v, want nil when --need-notification was not set", got)
}
body := spec.RequestBody()
want := map[string]interface{}{"member_id": "ou_x", "member_type": "openid", "member_role": "member"}
if !reflect.DeepEqual(body, want) {
t.Fatalf("RequestBody() = %v, want %v", body, want)
}
}
func TestWikiMemberAddQueryParamsHonorsExplicitNotification(t *testing.T) {
t.Parallel()
spec := wikiMemberAddSpec{
NotificationSet: true,
NeedNotification: true,
}
if got := spec.QueryParams(); !reflect.DeepEqual(got, map[string]interface{}{"need_notification": true}) {
t.Fatalf("QueryParams() = %v, want need_notification=true", got)
}
}
func TestWikiMemberAddQueryParamsHonorsExplicitFalse(t *testing.T) {
t.Parallel()
// The three-state design (unset / true / false) must distinguish false from
// unset so --need-notification=false reaches the server instead of being
// dropped along with the param block.
spec := wikiMemberAddSpec{
NotificationSet: true,
NeedNotification: false,
}
if got := spec.QueryParams(); !reflect.DeepEqual(got, map[string]interface{}{"need_notification": false}) {
t.Fatalf("QueryParams() = %v, want need_notification=false", got)
}
}
func TestWikiMemberAddDryRunSingleStep(t *testing.T) {
t.Parallel()
dry := buildWikiMemberAddDryRun(wikiMemberAddSpec{
SpaceID: "space_42",
MemberID: "ou_x",
MemberType: "openid",
MemberRole: "admin",
})
api := dryRunAPIList(t, dry)
if len(api) != 1 || api[0].Method != "POST" || api[0].URL != "/open-apis/wiki/v2/spaces/space_42/members" {
t.Fatalf("dry-run api = %#v", api)
}
if api[0].Body["member_id"] != "ou_x" || api[0].Body["member_role"] != "admin" {
t.Fatalf("dry-run body = %#v", api[0].Body)
}
}
func TestWikiMemberAddDryRunMyLibraryIsTwoStep(t *testing.T) {
t.Parallel()
dry := buildWikiMemberAddDryRun(wikiMemberAddSpec{
SpaceID: wikiMyLibrarySpaceID,
MemberID: "ou_x",
MemberType: "openid",
MemberRole: "member",
})
api := dryRunAPIList(t, dry)
if len(api) != 2 {
t.Fatalf("dry-run api count = %d, want 2", len(api))
}
if api[0].Method != "GET" || !strings.Contains(api[0].URL, "/spaces/my_library") {
t.Fatalf("dry-run step 1 = %#v", api[0])
}
if api[1].Method != "POST" || !strings.Contains(api[1].URL, "<resolved_space_id>/members") {
t.Fatalf("dry-run step 2 = %#v", api[1])
}
}
func TestWikiMemberAddRejectsMyLibraryForBot(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiMemberAdd, []string{
"+member-add",
"--space-id", "my_library",
"--member-id", "ou_x",
"--member-type", "openid",
"--member-role", "member",
"--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "bot identity does not support --space-id my_library") {
t.Fatalf("expected my_library bot rejection, got %v", err)
}
}
func TestWikiMemberAddRejectsBotWithDepartment(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiMemberAdd, []string{
"+member-add",
"--space-id", "space_42",
"--member-id", "od_dept_1",
"--member-type", "opendepartmentid",
"--member-role", "member",
"--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "--as bot does not support --member-type opendepartmentid") {
t.Fatalf("expected bot+opendepartmentid rejection, got %v", err)
}
}
func TestWikiMemberAddMountedExecuteFlattensMember(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
var capturedQuery string
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_42/members",
OnMatch: func(req *http.Request) { capturedQuery = req.URL.RawQuery },
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_abc",
"member_type": "openid",
"member_role": "admin",
"type": "user",
},
},
"msg": "success",
},
}
reg.Register(stub)
err := mountAndRunWiki(t, WikiMemberAdd, []string{
"+member-add",
"--space-id", "space_42",
"--member-id", "ou_abc",
"--member-type", "openid",
"--member-role", "admin",
"--need-notification",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["space_id"] != "space_42" {
t.Fatalf("space_id = %#v", data["space_id"])
}
if data["member_id"] != "ou_abc" || data["member_role"] != "admin" || data["type"] != "user" {
t.Fatalf("flattened envelope = %#v", data)
}
// Captured body must carry the three required fields; query must include the
// notification flag because the caller passed --need-notification.
var captured map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if captured["member_id"] != "ou_abc" || captured["member_type"] != "openid" || captured["member_role"] != "admin" {
t.Fatalf("captured request body = %#v", captured)
}
if !strings.Contains(capturedQuery, "need_notification=true") {
t.Fatalf("captured query = %q, want need_notification=true", capturedQuery)
}
if !strings.Contains(stderr.String(), "Added wiki space member") {
t.Fatalf("stderr = %q, want success log", stderr.String())
}
}
func TestWikiMemberAddFallsBackToSpecWhenMemberEchoIsEmpty(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Server returns an empty member object: scripts must still see the three
// identifying fields, restored from the caller's spec.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_42/members",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"member": map[string]interface{}{},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiMemberAdd, []string{
"+member-add",
"--space-id", "space_42",
"--member-id", "ou_abc",
"--member-type", "openid",
"--member-role", "admin",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["space_id"] != "space_42" ||
data["member_id"] != "ou_abc" ||
data["member_type"] != "openid" ||
data["member_role"] != "admin" {
t.Fatalf("fallback envelope = %#v", data)
}
}
func TestWikiMemberAddResolvesMyLibraryForUser(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/my_library",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"space": map[string]interface{}{"space_id": "space_personal_7", "name": "My Library", "space_type": "my_library"},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_personal_7/members",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_x",
"member_type": "openid",
"member_role": "member",
},
},
},
})
err := mountAndRunWiki(t, WikiMemberAdd, []string{
"+member-add",
"--space-id", "my_library",
"--member-id", "ou_x",
"--member-type", "openid",
"--member-role", "member",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["space_id"] != "space_personal_7" {
t.Fatalf("space_id = %#v, want space_personal_7", data["space_id"])
}
}
// ── +member-remove ───────────────────────────────────────────────────────────
func TestWikiMemberRemoveSpecRequiresMemberID(t *testing.T) {
t.Parallel()
cmd := newMemberRemoveCmd("space_1", "", "openid", "member")
runtime := common.TestNewRuntimeContext(cmd, nil)
if _, err := readWikiMemberRemoveSpec(runtime); err == nil || !strings.Contains(err.Error(), "--member-id is required") {
t.Fatalf("expected --member-id rejection, got %v", err)
}
}
func TestWikiMemberRemoveDryRunIncludesBody(t *testing.T) {
t.Parallel()
dry := buildWikiMemberRemoveDryRun(wikiMemberRemoveSpec{
SpaceID: "space_42",
MemberID: "ou_x",
MemberType: "openid",
MemberRole: "admin",
})
api := dryRunAPIList(t, dry)
if len(api) != 1 || api[0].Method != "DELETE" {
t.Fatalf("dry-run api = %#v", api)
}
if api[0].URL != "/open-apis/wiki/v2/spaces/space_42/members/ou_x" {
t.Fatalf("dry-run url = %q", api[0].URL)
}
if api[0].Body["member_type"] != "openid" || api[0].Body["member_role"] != "admin" {
t.Fatalf("dry-run body = %#v", api[0].Body)
}
}
func TestWikiMemberRemoveDryRunMyLibraryIsTwoStep(t *testing.T) {
t.Parallel()
dry := buildWikiMemberRemoveDryRun(wikiMemberRemoveSpec{
SpaceID: wikiMyLibrarySpaceID,
MemberID: "ou_x",
MemberType: "openid",
MemberRole: "member",
})
api := dryRunAPIList(t, dry)
if len(api) != 2 {
t.Fatalf("dry-run api count = %d, want 2", len(api))
}
if api[1].Method != "DELETE" || !strings.Contains(api[1].URL, "<resolved_space_id>/members/ou_x") {
t.Fatalf("dry-run step 2 = %#v", api[1])
}
}
func TestWikiMemberRemoveMountedExecuteFlattensMember(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/wiki/v2/spaces/space_42/members/ou_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_abc",
"member_type": "openid",
"member_role": "admin",
},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiMemberRemove, []string{
"+member-remove",
"--space-id", "space_42",
"--member-id", "ou_abc",
"--member-type", "openid",
"--member-role", "admin",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["space_id"] != "space_42" || data["member_id"] != "ou_abc" || data["member_role"] != "admin" {
t.Fatalf("envelope = %#v", data)
}
}
// ── +member-list ─────────────────────────────────────────────────────────────
func TestWikiMemberListRequiresSpaceID(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiMemberList, []string{"+member-list", "--as", "user"}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "required") {
t.Fatalf("expected required flag error, got %v", err)
}
}
func TestWikiMemberListRejectsMyLibraryForBot(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiMemberList, []string{
"+member-list", "--space-id", "my_library", "--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "bot identity does not support --space-id my_library") {
t.Fatalf("expected my_library bot rejection, got %v", err)
}
}
func TestWikiMemberListReturnsMembers(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_42/members",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"members": []interface{}{
map[string]interface{}{
"member_id": "ou_1",
"member_type": "openid",
"member_role": "admin",
},
map[string]interface{}{
"member_id": "ou_2",
"member_type": "openid",
"member_role": "member",
},
},
},
},
})
err := mountAndRunWiki(t, WikiMemberList, []string{
"+member-list", "--space-id", "space_42", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
SpaceID string `json:"space_id"`
Members []map[string]interface{} `json:"members"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if !envelope.OK {
t.Fatalf("expected ok=true, got %s", stdout.String())
}
if envelope.Meta.Count != 2 {
t.Fatalf("meta.count = %v, want 2", envelope.Meta.Count)
}
if envelope.Data.SpaceID != "space_42" {
t.Fatalf("data.space_id = %q, want space_42", envelope.Data.SpaceID)
}
if envelope.Data.Members[0]["member_role"] != "admin" {
t.Fatalf("members[0].member_role = %v", envelope.Data.Members[0]["member_role"])
}
}
func TestWikiMemberListResolvesMyLibraryForUser(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/my_library",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"space": map[string]interface{}{"space_id": "space_personal_7", "name": "My Library", "space_type": "my_library"},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_personal_7/members",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"members": []interface{}{},
},
},
})
err := mountAndRunWiki(t, WikiMemberList, []string{
"+member-list", "--space-id", "my_library", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
SpaceID string `json:"space_id"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Data.SpaceID != "space_personal_7" {
t.Fatalf("data.space_id = %q, want space_personal_7", envelope.Data.SpaceID)
}
}
func TestWikiMemberListAutoPaginatesAcrossPages(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Page 1: has_more=true, page_token set. Loop must continue.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_42/members",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_page2",
"members": []interface{}{
map[string]interface{}{"member_id": "ou_1", "member_type": "openid", "member_role": "admin"},
},
},
},
})
// Page 2: must carry page_token=tok_page2 in the query. Captured to verify.
var page2Query string
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_42/members",
OnMatch: func(req *http.Request) { page2Query = req.URL.RawQuery },
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"members": []interface{}{
map[string]interface{}{"member_id": "ou_2", "member_type": "openid", "member_role": "member"},
},
},
},
})
err := mountAndRunWiki(t, WikiMemberList, []string{
"+member-list", "--space-id", "space_42", "--page-all", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
Members []map[string]interface{} `json:"members"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Meta.Count != 2 || len(envelope.Data.Members) != 2 {
t.Fatalf("merged members = %d / count=%v, want 2 / 2", len(envelope.Data.Members), envelope.Meta.Count)
}
if envelope.Data.HasMore || envelope.Data.PageToken != "" {
t.Fatalf("natural end should clear has_more/page_token, got has_more=%v page_token=%q",
envelope.Data.HasMore, envelope.Data.PageToken)
}
q, _ := url.ParseQuery(page2Query)
if q.Get("page_token") != "tok_page2" {
t.Fatalf("page2 page_token = %q, want tok_page2", q.Get("page_token"))
}
}
func TestWikiMemberListPageLimitTruncatesAndExposesNextCursor(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Only stub page 1; with --page-limit=1 the loop must stop BEFORE page 2 —
// and the response must surface has_more/page_token so the caller can resume.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_42/members",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_next",
"members": []interface{}{
map[string]interface{}{"member_id": "ou_only", "member_type": "openid", "member_role": "admin"},
},
},
},
})
err := mountAndRunWiki(t, WikiMemberList, []string{
"+member-list", "--space-id", "space_42", "--page-all", "--page-limit", "1", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
Members []map[string]interface{} `json:"members"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if len(envelope.Data.Members) != 1 {
t.Fatalf("members = %d, want 1 (capped)", len(envelope.Data.Members))
}
if !envelope.Data.HasMore || envelope.Data.PageToken != "tok_next" {
t.Fatalf("truncated state = has_more=%v page_token=%q, want true / tok_next",
envelope.Data.HasMore, envelope.Data.PageToken)
}
}
// ── helpers ──────────────────────────────────────────────────────────────────
func newMemberRemoveCmd(spaceID, memberID, memberType, memberRole string) *cobra.Command {
cmd := &cobra.Command{Use: "wiki +member-remove"}
cmd.Flags().String("space-id", spaceID, "")
cmd.Flags().String("member-id", memberID, "")
cmd.Flags().String("member-type", memberType, "")
cmd.Flags().String("member-role", memberRole, "")
return cmd
}
// dryRunAPIList serializes a DryRunAPI through JSON to match how the framework
// exposes it to callers — same approach used by +space-create's tests.
func dryRunAPIList(t *testing.T, dry *common.DryRunAPI) []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
Params map[string]interface{} `json:"params"`
} {
t.Helper()
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
return got.API
}

View File

@@ -577,6 +577,105 @@ func TestWikiNodeCreateBotAutoGrantSuccess(t *testing.T) {
}
}
func TestWikiNodeCreateBotAutoGrantSkippedNoUser(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiPermissionTestConfig(""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_skipped",
"obj_token": "docx_skipped",
"obj_type": "docx",
"node_type": "origin",
"title": "Wiki Skipped",
"has_child": false,
},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiNodeCreate, []string{
"+node-create",
"--space-id", "space_123",
"--title", "Wiki Skipped",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
}
}
func TestWikiNodeCreateBotAutoGrantFailed(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiPermissionTestConfig("ou_current_user"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_grant_fail",
"obj_token": "docx_grant_fail",
"obj_type": "docx",
"node_type": "origin",
"title": "Wiki Fail",
"has_child": false,
},
},
"msg": "success",
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/wik_grant_fail/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
})
err := mountAndRunWiki(t, WikiNodeCreate, []string{
"+node-create",
"--space-id", "space_123",
"--title", "Wiki Fail",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
}
}
func TestWikiNodeCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -0,0 +1,6 @@
## 妙搭应用apps域介绍
妙搭是飞书的低代码 / 无代码应用平台。本域命令围绕"妙搭应用"展开:
- **App应用**:用户创建的妙搭应用对象,含 `app_id``name``description``icon_url`;通过 `+html-publish` 发布 HTML 内容
- **Access Scope可用范围**`specific`(指定可见)/ `public`(互联网公开)/ `tenant`(企业全员)三选一

View File

@@ -1,7 +1,7 @@
> **成员管理硬限制:**
> - 如果目标是“部门”,先判断身份,再决定是否继续。
> - `--as bot` 对应 `tenant_access_token`。官方限制:这种身份下不能使用部门 ID (`opendepartmentid`) 添加知识空间成员。
> - 遇到“部门 + --as bot”时禁止先调用 `lark-cli wiki members create` 试错;直接说明该路径不可行。
> - 遇到“部门 + --as bot”时禁止先调用 `lark-cli wiki +member-add` 试错;直接说明该路径不可行。
> - 如果用户明确要求“以 bot 身份运行”,且目标是部门,必须停下说明 bot 路径无法完成,不要静默切到 `--as user`。
## 快速决策
@@ -14,18 +14,20 @@
- 命中 0 条:停下来问用户是名称拼错了还是调用方无权限;**不要**自行改名字重试。
- 用户明确选定后再执行 `lark-cli wiki +delete-space --space-id <ID> --yes`(高风险写操作,必须显式 `--yes`)。
- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门”三类之一,再决定 `member_type`,不要先调 `wiki members create` 再根据报错反推类型。
- 用户说“部门 + bot”这是已知不支持路径。不要继续尝试 `wiki members create --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。
- 用户说“用户 / 群 + 添加成员”:先解析对应 ID再执行 `wiki members create`
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门”三类之一,再决定 `--member-type`,不要先调 `wiki +member-add` 再根据报错反推类型。
- 用户说“部门 + bot”这是已知不支持路径。不要继续尝试 `wiki +member-add --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。
- 用户说“用户 / 群 + 添加成员”:先解析对应 ID再执行 `wiki +member-add`
- 用户说“查看 / 列出空间成员”:用 `wiki +member-list`;该 shortcut 默认只取一页,多成员场景显式加 `--page-all`
- 用户说“移除 / 删除空间成员”:用 `wiki +member-remove`,必须传齐原始授予时的 `--member-type``--member-role`(不知道就先 `wiki +member-list` 查一下)。
## 成员添加流程
- 调用 `lark-cli wiki members create` 前,先把自然语言里的“人 / 群 / 部门”解析成正确的 `member_id`,不要猜格式。
- 用户场景默认优先 `member_type=openid`:用 `lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --format json` 获取 `open_id`
- 群组场景使用 `member_type=openchat`:用 `lark-cli im +chat-search --query "<群名关键词>" --format json` 获取 `chat_id`
- 调用 `lark-cli wiki +member-add` 前,先把自然语言里的“人 / 群 / 部门”解析成正确的 `--member-id`,不要猜格式。
- 用户场景默认优先 `--member-type=openid`:用 `lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --format json` 获取 `open_id`
- 群组场景使用 `--member-type=openchat`:用 `lark-cli im +chat-search --query "<群名关键词>" --format json` 获取 `chat_id`
- `userid` / `unionid` 只在下游明确要求时才使用;先拿到 `open_id`,再调用 `lark-cli api GET /open-apis/contact/v3/users/<open_id> --params '{"user_id_type":"open_id"}' --format json` 读取 `user_id` / `union_id`
- 部门场景使用 `member_type=opendepartmentid`:当前 CLI 没有 shortcut需调用 `lark-cli api POST /open-apis/contact/v3/departments/search --as user --params '{"department_id_type":"open_department_id"}' --data '{"query":"<部门名>"}'` 获取 `open_department_id`
- 只有在目标类型和身份都已确认可行后,才调用 `lark-cli wiki members create`。对于部门场景,这意味着必须是 `--as user`
- 部门场景使用 `--member-type=opendepartmentid`:当前 CLI 没有 shortcut需调用 `lark-cli api POST /open-apis/contact/v3/departments/search --as user --params '{"department_id_type":"open_department_id"}' --data '{"query":"<部门名>"}'` 获取 `open_department_id`
- 只有在目标类型和身份都已确认可行后,才调用 `lark-cli wiki +member-add`。对于部门场景,这意味着必须是 `--as user`
## 目标语义约束

92
skills/lark-apps/SKILL.md Normal file
View File

@@ -0,0 +1,92 @@
---
name: lark-apps
description: "把本地 HTML 文件或目录部署到飞书妙搭Miaoda生成可分享访问的 Web 页面并返回 URL管理应用的创建、更新、列表和访问范围。当用户要把 HTML、静态网站或 Web demo 发布成可分享链接,或提到妙搭 / Miaoda 时使用。不用于:上传普通文件到云空间(用 lark-drive、编辑飞书云文档内容用 lark-doc、创建飞书原生幻灯片 / 演示文稿(用 lark-slides。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli apps --help"
---
# apps (v1)
```bash
# 常用示例
lark-cli apps +create --name "客户调研问卷" --app-type HTML
lark-cli apps +html-publish --app-id app_xxx --path ./dist
lark-cli apps +access-scope-set --app-id app_xxx --scope tenant
```
## 品牌可用性(先做)
`lark-cli apps --help`;若提示暂未支持,告诉用户敬请期待并停止。
## 前置条件 — 执行操作前必读
**CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **创建应用(`apps +create`** → 必读 [`lark-apps-create.md`](references/lark-apps-create.md)
3. **更新应用元信息(`apps +update`** → 必读 [`lark-apps-update.md`](references/lark-apps-update.md)(部分更新,未传字段不变)
4. **发布 HTML / PPT / 静态网站(`apps +html-publish`** → 必读 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)`--path` 文件 vs 目录、tar.gz 打包不做过滤)
5. **设置可用范围(`apps +access-scope-set`** → 必读 [`lark-apps-access-scope-set.md`](references/lark-apps-access-scope-set.md)specific / public / tenant 三态互斥校验、targets JSON 结构)
**未读完以上文件就执行相应操作会导致参数选择错误、互斥违反或文件被错误打包。**
## 身份与一次性授权
妙搭应用是用户的个人资产,**统一使用 `--as user`**CLI 默认 `--as auto` 会按 shortcut 声明自动落到 user
**首次操作前一次性把本域 scope 全拿到,避免每条命令首次跑都触发新一轮授权**
```bash
lark-cli auth login --domain apps
```
## 端到端流程HTML / PPT / 静态网站发布)
**第一步:判断用户意图是「明示部署」还是「仅演示」**
| 用户表达 | 意图 | 处理 |
|---------|------|------|
| "部署 ./xxx 的 HTML"、"发布到妙搭"、"开发 xxx 并部署成可分享的网站 / 可访问的链接"、"生成可分享 URL" | **明示部署 / 分享** | 不停下追问HTML 写完直接走下表 step 1→2 |
| "用 HTML 写一个 PPT / 幻灯片 / 演示文稿"、"做个可演示的 demo"、"写个介绍 xxx 的页面"(没提部署 / 分享 / URL | **仅演示** | HTML 写完先输出本地文件路径 + 简要说明,**主动追问一句**"要部署到妙搭以便分享给别人吗?"用户同意再走 step 1→2用户说不用就停 |
**第二步:用户同意部署 / 已明示部署后,按下表走完整链路并把最终 URL 返回给用户**
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1. 新建应用 | `apps +create --name "<根据内容主题起的应用名>" --app-type HTML` → 从响应里拿 `app_id` | 默认都走新建(**不要尝试搜索 / 枚举已有应用**)。用户明确要复用现有应用时让他提供 **妙搭应用链接****app_id 字符串**(详见下方"快速决策"`--app-type` 必填,当前只支持 `HTML`(区分大小写),未来扩展 |
| 1.5 预检 | `apps +html-publish --app-id <id> --path <path> --dry-run``warnings` 字段 | 命中 `.git` / `.env*` / `*.pem` / `*.key` 等敏感文件时**停下来**,把 warnings 列给用户看,确认要继续才走 step 2用户没确认前不要去掉 `--dry-run` 真发 |
| 2. 发布 HTML | `apps +html-publish --app-id <id> --path <文件或目录>` | 必走 |
| 3. 设置可用范围(可选) | `apps +access-scope-set --app-id <id> --scope tenant\|public\|specific ...` | 用户说"公开 / 全员可见 / 让 Alice 看 / 互联网可分享"等 |
报告给用户的话术:
> 应用「{name}」已发布,访问链接:`{url}`
若用户没指定可用范围且场景明显需要分享,主动追问一句"要设为企业全员 / 互联网公开吗?",但不要为了问而问。
## 快速决策
- 用户**明示**"部署 / 发布 ./xxx 的 HTML"、"开发 xxx 并部署成可分享的网站 / 可访问的链接"、"发到妙搭" → 直接走「端到端流程」step 1→2`apps +html-publish` 自动部署并返回 URL不要追问
- 用户**只说**"用 HTML 写 PPT / 幻灯片 / 演示文稿 / demo"、"开发一个可演示的页面"**没提**部署 / 分享 / URL → HTML 写完先输出本地路径 + 简要说明,主动问一句"要部署到妙搭以便分享吗?",用户同意才走 publish不要擅自部署但也不要忘了问
- 用户说"把应用 X 开放给全员 / 全公司" → `--scope tenant`,不要再传别的 flag
- 用户说"公开 / 让任何人都能访问 / 互联网可见" → `--scope public --require-login=<bool>`,二选一
- 用户说"只让 Alice / 某部门 / 某群访问" → `--scope specific --targets <JSON>`;姓名先用 `contact +search-user``ou_id`,群名先用 `im +chat-search``chat_id`
- 用户没给 app_id → **默认 `apps +create --name "<根据内容主题起的名字>" --app-type HTML` 新建一个**。**不要尝试搜索 / 枚举已有应用** —— 列举应用的命令对 Agent 不可见,强行调用也只会浪费一次 OAPI 请求。如果用户明确要复用现有应用,**让他提供下列任一种**
- **妙搭应用链接**:形如 `https://miaoda.feishu.cn/app/app_xxxxxxxxxxxxx`(或带尾斜杠 `/app/app_xxx/`)—— `app_id``/app/` 后面的 path segment`app_` 开头)。从 URL 中提取的简单办法:`APP_ID=$(echo "$URL" | sed -E 's|.*/app/([^/?#]+).*|\1|')`
- **app_id 字符串**:用户直接给的 `app_xxxxxxxxxxxxx`,不需要再做处理
- `--path` 既可传单个 HTML 文件也可传目录;目录会**递归打包成 tar.gz 不做过滤**,要提醒用户传干净的产物目录(如 `./dist`),避免把 `.git` / `node_modules` 一起打进去
- `apps +update` 只更新传入字段,未传字段保持不变;`--name` / `--description` 至少传一个,否则 Validate 阶段直接拦截
- `apps +access-scope-set` 三种 scope **互斥**specific 必传 `--targets`、不允许 `--require-login`public 必传 `--require-login`、不允许 `--targets` / `--apply-enabled` / `--approver`tenant 不允许任何其他 flag
- 失败时**优先转述 `error.hint`**CLI 给的可执行修复建议hint 为空时退回 `error.message`;不要原样把 envelope JSON 复述给用户
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli apps +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-apps-create.md) | 创建妙搭应用name / description / icon-url |
| [`+update`](references/lark-apps-update.md) | 部分更新应用名 / 描述(只发传入字段) |
| [`+access-scope-set`](references/lark-apps-access-scope-set.md) | 设置应用可用范围specific / public / tenant三态互斥校验 |
| [`+html-publish`](references/lark-apps-html-publish.md) | **把本地 HTML 文件 / 目录 / PPT / 静态网站部署为可分享的妙搭应用,返回访问 URL**(用户明示部署 / 分享时直接调;仅说"可演示"时先问用户是否要部署再调) |

View File

@@ -0,0 +1,104 @@
# apps +access-scope-get
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
获取应用当前的可用范围配置。一次 `GET /apps/{appId}/access-scope` 调用,响应原样透传服务端契约(字符串 scope 枚举 + 拆分数组)。
## 命令
```bash
lark-cli apps +access-scope-get --app-id app_xxx
```
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--app-id <id>` | ✅ | 应用 ID |
## 返回值
**成功specific三种 target 类型混合):**
```json
{
"ok": true,
"data": {
"scope": "Range",
"users": ["ou_xxx", "ou_yyy"],
"departments": ["od_xxx"],
"chats": ["oc_xxx"],
"apply_config": {
"enabled": true,
"approvers": ["ou_approver"]
}
}
}
```
**成功public + 免登):**
```json
{ "ok": true, "data": { "scope": "All", "require_login": false } }
```
**成功tenant**
```json
{ "ok": true, "data": { "scope": "Tenant" } }
```
**失败:**
```json
{ "ok": false, "error": { "type": "api_error", "message": "...", "hint": "..." } }
```
## 字段语义
- `scope` 是**字符串枚举**
- `"All"` = 互联网公开 — 对应 `apps +access-scope-set --scope public`
- `"Tenant"` = 组织内 — 对应 `--scope tenant`
- `"Range"` = 部分人员 — 对应 `--scope specific`
- `users` / `departments` / `chats` 三个数组(仅 `scope="Range"`服务端拆分形态CLI 不合并回统一 targets
- `apply_config`(可选,仅 `scope="Range"` 且申请开启时):含 `enabled``approvers`(只允许一个 user open_id
- `require_login`(仅 `scope="All"`bool
## 典型场景
### 场景 1查看当前应用对谁可见
```bash
lark-cli apps +access-scope-get --app-id app_xxx
```
`scope` 值组装报告:
- `scope="All"` → "应用 `{app_id}` 当前互联网公开require_login={require_login}"
- `scope="Tenant"` → "应用 `{app_id}` 当前对企业全员可见"
- `scope="Range"` → "应用 `{app_id}` 当前指定可见,包含 N 个用户 / M 个部门 / K 个群"
### 场景 2把 GET 响应拼回 `+access-scope-set` 命令(复制 / 备份可用范围)
```bash
# 拼一个 --targets JSON 数组jq
lark-cli apps +access-scope-get --app-id app_src -q '
.data
| (.users // [] | map({type:"user", id:.}))
+ (.departments // [] | map({type:"department", id:.}))
+ (.chats // [] | map({type:"chat", id:.}))
'
```
得到 `[{"type":"user","id":"ou_x"}, ...]` 数组,可作为 `apps +access-scope-set --targets '...'` 的入参。
## 协同命令
| 场景 | 命令 |
|---|---|
| 设置可用范围 | `apps +access-scope-set` |
| 拿 app_id | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx``/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md` |
## 参考
- [lark-apps](../SKILL.md)
- [lark-shared](../../lark-shared/SKILL.md)

View File

@@ -0,0 +1,126 @@
# apps +access-scope-set
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
设置应用的可用范围。三种 scope 形态互斥:`specific`(指定可见)、`public`(互联网公开)、`tenant`(企业全员)。
## 命令
```bash
# 指定可见 + 允许申请targets 支持 user / department / chat 三种类型)
lark-cli apps +access-scope-set --app-id app_xxx \
--scope specific \
--targets '[{"type":"user","id":"ou_xxx"},{"type":"department","id":"od_xxx"},{"type":"chat","id":"oc_xxx"}]' \
--apply-enabled \
--approver ou_yyy
# 互联网公开 + 免登
lark-cli apps +access-scope-set --app-id app_xxx --scope public --require-login=false
# 企业全员
lark-cli apps +access-scope-set --app-id app_xxx --scope tenant
```
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--app-id <id>` | ✅ | 应用 ID |
| `--scope <enum>` | ✅ | `specific` / `public` / `tenant` |
| `--targets <json>` | scope=specific 必填 | targets JSON 数组,每项 `{"type":"user\|department\|chat", "id":"<id>"}` |
| `--apply-enabled` | scope=specific 可选 | 是否允许申请访问 |
| `--approver <ou_xxx>` | `--apply-enabled` 必填 | 申请审批人(**只能传一个 user open_id**,服务端限制) |
| `--require-login` | scope=public 必填 | 是否要求登录 |
## 互斥校验Validate 阶段,不通过直接报错不发请求)
- `scope=specific`:必传 `--targets`;不允许 `--require-login`
- `scope=public`:必传 `--require-login`;不允许 `--targets` / `--apply-enabled` / `--approver`
- `scope=tenant`:不允许任何其它 flag
- `--targets` 内每项的 `type` 必须是 `user` / `department` / `chat` 之一
## 返回值
**成功:**
```json
{ "ok": true, "data": {} }
```
**API 失败:**
```json
{ "ok": false, "error": { "type": "api_error", "message": "...", "hint": "..." } }
```
**Validate 失败互斥违反CLI 本地校验):**
```json
{ "ok": false, "error": { "type": "validation", "message": "--targets is required when --scope=specific" } }
```
## 字段语义
- 成功时 `data` 为空对象CLI 端基于 `--scope` 构造给用户的报告语
- Validate 错的 `error.type=validation` 是本地校验,**不发请求**
## 典型场景
### 场景 1用户说"把应用 X 开放给全员"
```bash
lark-cli apps +access-scope-set --app-id app_xxx --scope tenant
```
> 应用 `{app_id}` 可用范围已设为企业全员。
### 场景 2用户说"把应用 X 设为互联网公开 + 免登"
```bash
lark-cli apps +access-scope-set --app-id app_xxx --scope public --require-login=false
```
> 应用 `{app_id}` 可用范围已设为互联网公开(免登)。
### 场景 3用户说"只让 Alice 和 Bob 访问应用 X"
先用 `lark-cli contact +search-user --query Alice` 拿到 ou_id再调
```bash
lark-cli apps +access-scope-set --app-id app_xxx \
--scope specific \
--targets '[{"type":"user","id":"ou_alice"},{"type":"user","id":"ou_bob"}]'
```
> 应用 `{app_id}` 可用范围已设为指定可见,目标人数 2。
### 场景 4用户说"开放给「项目讨论群」"
把群名转 chat_id`lark-cli im +chat-search --query "项目讨论群"`,再调:
```bash
lark-cli apps +access-scope-set --app-id app_xxx \
--scope specific \
--targets '[{"type":"chat","id":"oc_xxx"}]'
```
### 场景 5互斥违反
例如 `--scope tenant --targets ...` —— Validate 本地拦截。**不发请求**。
### 场景 6API 失败
转述 `error.hint` / `error.message`
## 协同命令
| 场景 | 命令 |
|---|---|
| 拿 app_id | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx``/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md` |
| 把人名转 ou_id | `lark-cli contact +search-user --query <name>` |
| 把群名转 chat_id | `lark-cli im +chat-search --query <群名>` |
## 参考
- [lark-apps](../SKILL.md)
- [lark-shared](../../lark-shared/SKILL.md)

View File

@@ -0,0 +1,112 @@
# apps +create
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
创建一个新的妙搭应用。一次 `POST /apps` 调用,返回新建应用的元信息。
## 命令
```bash
# 最小调用
lark-cli apps +create --name "客户调研问卷" --app-type HTML
# 全参数
lark-cli apps +create \
--name "客户调研问卷" \
--app-type HTML \
--description "本季度客户满意度调研" \
--icon-url "https://lf3-static.bytednsdoc.com/.../feisuda/avatar/5.svg"
# Dry-run仅打印请求不执行
lark-cli apps +create --name "Demo" --app-type HTML --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--name <str>` | ✅ | 应用显示名 |
| `--app-type <enum>` | ✅ | 应用类型,当前可选值:`HTML`(区分大小写;未来会扩展) |
| `--description <str>` | ❌ | 应用描述 |
| `--icon-url <url>` | ❌ | 应用图标 URL不传服务端给默认图标 |
## 返回值
**成功:**
```json
{
"ok": true,
"data": {
"app_id": "app_4k5jepcbjmv6m",
"name": "客户调研问卷",
"description": "本季度客户满意度调研",
"icon_url": "https://lf3-static.bytednsdoc.com/.../feisuda/avatar/5.svg",
"created_at": "2026-05-18T10:00:00Z"
}
}
```
**失败:**
```json
{
"ok": false,
"error": {
"type": "api_error",
"code": "api_error",
"message": "...",
"hint": "可执行的修复建议(可能为空)"
}
}
```
## 字段语义
- `app_type` 是应用类型枚举,**区分大小写**,当前只允许 `HTML`,未来会扩展(如 `SPA``NATIVE` 等);不在白名单的取值 CLI 端会直接拒绝
- `created_at` 是 ISO 8601 UTC 时间字符串
- `error.hint` 是 CLI 给出的可执行修复建议,**优先**转述给用户hint 为空时退回 `error.message`
- 不要原样把 envelope JSON 复述给用户
## 典型场景
### 场景 1用户说"创建一个妙搭应用,名字叫 X"
目前只支持 HTML 类型,统一传 `--app-type HTML`(用户没说类型时不要追问,直接用大写 HTML区分大小写
```bash
lark-cli apps +create --name "X" --app-type HTML
```
向用户报告:
> 应用「{name}」已创建ID: `{app_id}`)。
可选建议下一步:
> 接下来用 `apps +html-publish --app-id {app_id} --path <你的 HTML 目录>` 发布内容。
### 场景 2用户提供完整元信息
```bash
lark-cli apps +create --name "Q4 调研" --app-type HTML --description "..."
```
返回后同场景 1。
### 场景 3失败处理
转述 `error.hint`(优先)或 `error.message`**不要**原样输出 envelope JSON。
## 协同命令
| 场景 | 命令 |
|---|---|
| 修改应用名 / 描述 | `apps +update` |
| 发布 HTML | `apps +html-publish` |
| 拿现有应用 ID | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx``/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md` |
## 参考
- [lark-apps](../SKILL.md) — 妙搭应用全部命令
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,151 @@
# apps +html-publish
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
把本地的 HTML 文件或目录部署为可访问的妙搭应用,响应返回应用的访问链接 `url`
## 命令
```bash
# 发布整个目录
lark-cli apps +html-publish --app-id app_xxx --path ./dist/
# 发布单个 HTML 文件
lark-cli apps +html-publish --app-id app_xxx --path ./index.html
# 预演(打印文件清单 + SHA256 + 目标 endpoint不发请求
lark-cli apps +html-publish --app-id app_xxx --path ./dist --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--app-id <id>` | ✅ | 应用 ID。从 `apps +create` 响应里拿;或者从用户给的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx``/app/` 后面提取(详见 `../SKILL.md` "用户没给 app_id" 一节) |
| `--path <path>` | ✅ | 本地文件或目录路径;目录会递归打包成 tar.gz。**必须含 `index.html`**:目录形态时根目录下,单文件形态时文件名必须就是 `index.html`(妙搭统一以 `index.html` 作为应用入口) |
## 返回值
**成功:**
```json
{
"ok": true,
"data": {
"url": "https://miaoda.feishu.cn/app/app_4k5jepcbjmv6m"
}
}
```
**业务失败(如构建失败、应用不存在):**
```json
{
"ok": false,
"error": {
"type": "api_error",
"code": "api_error",
"message": "html-publish failed (code=90001): build failed: dependency conflict",
"hint": "构建失败:用 `lark-cli apps +html-publish --path <path> --dry-run` 检查打包文件清单"
}
}
```
**基础设施失败(网络 / HTTP 5xx**
```json
{
"ok": false,
"error": { "type": "infra_error", "message": "...", "hint": "" }
}
```
**Validate 失败(本地校验,如缺 --app-id**
```json
{
"ok": false,
"error": { "type": "validation", "message": "--app-id is required" }
}
```
## 字段语义
| 字段 / 组合 | 含义 |
|---|---|
| `data.url` 存在且无 `error` | 发布成功URL 可访问 |
| `error.type=api_error` | 业务失败(构建失败、应用不存在等),按 `hint` 引导用户修复 |
| `error.type=infra_error` | 网络 / 服务端 5xx告诉用户稍后重试 |
| `error.type=validation` | 本地参数错,提示用户修 flag |
| `error.hint` 非空 | **优先转述给用户**,比 `error.message` 更可操作 |
## 典型场景
### 场景 1用户说"把这个目录发布到妙搭"
```bash
lark-cli apps +html-publish --app-id app_xxx --path ./dist
```
成功后:
> 应用发布成功!访问 `{url}` 查看。
可选追加:
> 如需让其他人访问,可以用 `apps +access-scope-set` 设置可用范围。
### 场景 2用户没有 app_id
```bash
APP=$(lark-cli apps +create --name "..." -q '.data.app_id' | tr -d '"')
lark-cli apps +html-publish --app-id "$APP" --path ./dist
```
### 场景 3构建失败code=90001
转述 hint
> 构建失败,建议用 `lark-cli apps +html-publish --app-id <your-app-id> --path ./dist --dry-run` 看一下打包文件清单是否完整。
### 场景 4应用不存在code=90002
> hint"应用不存在或无权访问;请用户确认妙搭应用链接 / app_id 是否正确(从 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面取)"
转述给用户。
### 场景 5网络 / 服务端失败infra_error
> 服务暂时不可用,建议稍后重试。
## 敏感文件警告
dry-run 输出会扫描 manifest 里的相对路径,命中以下任一模式时把它们列入 envelope 的 `warnings` 字段advisory不阻断 dry-run
- `.git/`(任意 SCM 内部文件)
- `.env``.env.*`(环境变量 / API key
- `.npmrc` / `.netrc`HTTP 凭据)
- `.ssh/id_rsa*` / `.ssh/id_ed25519*` / `.ssh/id_ecdsa*` / `.ssh/id_dsa*`
- `.aws/credentials` / `.aws/config` / `.docker/config.json` / `.gcloud/...` / `.kube/...`
- `*.pem` / `*.key`(私钥)
**Agent 行为契约**dry-run 看到 `warnings` 非空,**必须停下来向用户报告并询问是否继续**;用户确认后才能调真实的 `apps +html-publish`(去掉 `--dry-run`)。
## 提示
- `--path` **不能等于 cwd**`.` 或 cwd 等价写法均拒)。原因:递归打包 + 互联网公开的组合下cwd 根的项目级文件(`.git/` / `.env` / `node_modules` / `.aws/credentials`)会被一并打包并通过 share URL 公开访问。强制指定具体子目录或文件,如 `./dist` / `./public/` / `./index.html`
- `--path` **必须**是 cwd 内的相对路径(如 `./dist``./index.html`);绝对路径或越界路径(`../``/Users/...`CLI 会直接拒绝。需要发布 cwd 外的目录时,先切到 agent 工作目录再调,**不要**私自 `cd` 绕过
- 目录打包成 tar.gz 时**不做过滤**`.git` / `node_modules` 等会一并打包),让用户传干净的产物目录(如 `./dist`
- **不要**原样把 envelope JSON 转述给用户
## 协同命令
| 场景 | 命令 |
|---|---|
| 创建新应用 | `apps +create` |
| 设置可用范围 | `apps +access-scope-set` |
## 参考
- [lark-apps](../SKILL.md)
- [lark-shared](../../lark-shared/SKILL.md)

View File

@@ -0,0 +1,95 @@
# apps +list
> **⚠️ Hidden 命令(`Hidden: true`)—— 不对 Agent 暴露**:本命令从 `--help` / tab completion / SKILL.md 的 Shortcuts 表中隐去,**Agent 不应主动调用**。
>
> 需要拿现有应用的 `app_id` 时让用户提供 **妙搭应用链接**(如 `https://miaoda.feishu.cn/app/app_xxxxxxxxxxxxx`)然后从 URL 中提取,或者让用户直接给 `app_id` 字符串。详见 [`../SKILL.md`](../SKILL.md) "用户没给 app_id" 一节。
>
> 本文件保留是因为命令仍然功能可用(手动调用),下面内容仅供人类参考。
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
列出当前用户名下的妙搭应用。**cursor 分页**:默认拉一页(`--page-size 20`),通过 `--page-token` 拉下一页。
## 命令
```bash
# 拉第一页(默认 page_size=20
lark-cli apps +list
# 自定义页大小
lark-cli apps +list --page-size 50
# 翻页(拿上一次响应的 page_token
lark-cli apps +list --page-token "eyJQaW5PcmRlciI6..."
# 取 ID 列表(脚本场景)
lark-cli apps +list -q '.data.items[].app_id'
# 按名字找 app_id
lark-cli apps +list -q '.data.items[] | select(.name=="客户调研问卷") | .app_id'
```
## 参数
| 参数 | 必填 | 默认 | 说明 |
|---|---|---|---|
| `--page-size <int>` | ❌ | `20` | 每页条数 |
| `--page-token <str>` | ❌ | `""` | 翻页 cursor从上次响应的 `data.page_token` 拿 |
## 返回值
**成功:**
```json
{
"ok": true,
"data": {
"items": [
{
"app_id": "app_4k5jepcbjmv6m",
"name": "客户调研问卷",
"description": "...",
"icon_url": "...",
"created_at": "2026-05-18T10:00:00Z",
"updated_at": "2026-05-18T10:05:00Z"
}
],
"page_token": "cursor_next_xxx",
"has_more": true
}
}
```
**成功(空列表):**
```json
{ "ok": true, "data": { "items": [], "has_more": false } }
```
**失败:**
```json
{ "ok": false, "error": { "type": "api_error", "message": "...", "hint": "..." } }
```
## 字段语义
- `data.items` 长度可能为 0用户没建过应用
- `data.has_more=true` 表示还有下一页;用 `data.page_token` 作为下次 `--page-token` 传入
- `data.has_more=false``data.page_token` 为空 / 缺省表示已经到末尾
## 用途
本命令保留可供人类操作员手动调用(例如运维 / 调试场景,按 `name` 搜应用 ID。**Agent 不应主动调用**:默认行为是 `apps +create` 新建;要复用现有应用,**让用户给妙搭应用链接或 app_id**,详见 [`../SKILL.md`](../SKILL.md) "用户没给 app_id" 一节。
## 协同命令
| 场景 | 命令 |
|---|---|
| 创建新应用 | `apps +create` |
| 修改应用 | `apps +update` |
## 参考
- [lark-apps](../SKILL.md)
- [lark-shared](../../lark-shared/SKILL.md)

View File

@@ -0,0 +1,86 @@
# apps +update
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
部分更新一个妙搭应用的元信息(名字 / 描述)。**只把传入的字段发给服务端,未传字段保持不变**。
## 命令
```bash
lark-cli apps +update --app-id app_xxx --name "调研问卷 v2"
lark-cli apps +update --app-id app_xxx --description "新描述"
lark-cli apps +update --app-id app_xxx --name "v2" --description "新描述"
```
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--app-id <id>` | ✅ | 应用 ID |
| `--name <str>` | ❌ | 新名字 |
| `--description <str>` | ❌ | 新描述 |
`--name``--description` 至少传一个,否则 Validate 阶段报错。
## 返回值
**成功:**
```json
{
"ok": true,
"data": {
"app_id": "app_4k5jepcbjmv6m",
"name": "调研问卷 v2",
"description": "...",
"icon_url": "https://lf3-static.bytednsdoc.com/.../feisuda/avatar/5.svg",
"created_at": "2026-05-18T10:00:00Z",
"updated_at": "2026-05-18T10:05:00Z"
}
}
```
**失败:**
```json
{
"ok": false,
"error": { "type": "api_error", "message": "...", "hint": "..." }
}
```
## 字段语义
- 响应 `data` 含完整应用对象(所有字段),不只是被改的
- `created_at` / `updated_at` 都是 ISO 8601 UTC 时间字符串
- 失败时优先转述 `error.hint`
## 典型场景
### 场景 1用户说"把应用 X 改名叫 Y"
```bash
lark-cli apps +update --app-id app_xxx --name "Y"
```
> 应用 `{app_id}` 已更新,新名字「{name}」。
### 场景 2缺 `--app-id` 或没传可更新字段
Validate 直接拦截,提示用户加 flag。
### 场景 3失败处理
转述 `error.hint` / `error.message`
## 协同命令
| 场景 | 命令 |
|---|---|
| 找 app_id | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx``/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md` |
| 创建新应用 | `apps +create` |
## 参考
- [lark-apps](../SKILL.md)
- [lark-shared](../../lark-shared/SKILL.md)

View File

@@ -130,8 +130,8 @@ Drive Folder (云空间文件夹)
| 操作 | 需要的 Token | 说明 |
|------|-------------|------|
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id`sheet` 使用 `<sheetId>!<cell>``slides` 使用 `<slide-block-type>!<xml-id>`,且都支持最终解析到对应类型的 wiki URL |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL |
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id`sheet` 使用 `<sheetId>!<cell>``slides` 使用 `<slide-block-type>!<xml-id>`,且都支持最终解析到对应类型的 wiki URLDrive file 不支持局部评论 |
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
| 列出文档评论 | `file_token` | 同添加评论 |
@@ -139,15 +139,16 @@ Drive Folder (云空间文件夹)
### 评论能力边界(关键!)
- `drive +add-comment` 支持两种模式。
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL以及最终解析为 `doc`/`docx` 的 wiki URL。
- 局部评论:传 `--block-id` 时启用;不同文档类型的支持范围与参数格式见 [`drive +add-comment` 行为说明](references/lark-drive-add-comment.md#行为说明)
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL。
- 局部评论:传 `--block-id` 时启用;`docx` 支持文本定位或 block id`sheet` 支持 `<sheetId>!<cell>``slides` 支持 `<slide-block-type>!<xml-id>`wiki URL 解析到这些类型时也支持对应局部评论。Drive file 本次只支持全文评论,不支持局部评论
- Drive file 评论仅支持白名单扩展名:`.md`、`.txt`、`.json`、`.csv`、`.go`、`.js`、`.py`、`.pptx`、`.png`、`.jpg`、`.jpeg`、`.zip`、`.mp3`、`.mp4`。`.pdf`、`.docx`、`.xlsx` 等未在白名单内的普通文件暂不支持CLI 会直接报错提示当前还不支持这种类型的评论。
- Review / 审阅 / 校对 / 逐条指出问题场景优先使用局部评论,不要把多个可定位问题汇总成一条全文评论;具体参数和定位方式见 [`drive +add-comment` 行为说明](references/lark-drive-add-comment.md#行为说明)。
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`CLI 会将其拆分后写入 `anchor.block_id` 和 `anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis` 和 `--full-comment`。
- 评论写入内容(添加评论、回复评论、编辑回复)里的文本不能直接出现 `<`、`>`;提交前必须先转义:`<` -> `&lt;``>` -> `&gt;`。
- 使用 `drive +add-comment` 时shortcut 会对 `type=text` 的文本元素自动做上述转义兜底;如果直接调用 `drive file.comments create_v2`、`drive file.comment.replys create`、`drive file.comment.replys update`,则需要在请求里自行传入已转义的内容。
- 如果 wiki 解析后不是 `doc`/`docx`/`sheet`/`slides`,不要用 `+add-comment`。
- 如果 wiki 解析后不是 `doc`/`docx`/`file`/`sheet`/`slides`,不要用 `+add-comment`。
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。
### 评论查询与统计口径(关键!)
@@ -265,7 +266,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. |
| `+sync` | Two-way local ↔ Drive sync. Reuses `+status` diff buckets, pulls `new_remote`, pushes `new_local`, and resolves `modified` via `--on-conflict=remote-wins|local-wins|keep-both|ask`. `--quick` enables best-effort modified-time diffing (timestamp mismatches can still trigger real pull/push actions), `--on-duplicate-remote` supports `fail|newest|oldest`, and the command is intentionally non-destructive (no delete on either side). |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides |
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/file/sheet/slides, also supports wiki URL resolving to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only |
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming |
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
给文档、电子表格或飞书幻灯片添加评论。底层统一走 `/open-apis/drive/v1/files/:file_token/new_comments``create_v2`)接口;未指定位置时省略 `anchor` 创建全文评论,指定 `--block-id` 时传入 `anchor.block_id` 创建局部评论。支持直接传 docx URL/token、旧版 doc URL仅全文评论、sheet URL、slides URL也支持传最终可解析为 doc/docx/sheet/slides 的 wiki URL。
给文档、受支持的 Drive 普通文件、电子表格或飞书幻灯片添加评论。底层统一走 `/open-apis/drive/v1/files/:file_token/new_comments``create_v2`)接口;未指定位置时省略 `anchor` 创建全文评论,指定 `--block-id` 时传入 `anchor.block_id` 创建局部评论。支持直接传 docx URL/token、旧版 doc URL仅全文评论Drive file URL/token**仅支持白名单扩展名,且只支持全文评论**)、sheet URL、slides URL也支持传最终可解析为 doc/docx/file/sheet/slides 的 wiki URL。
## 命令
@@ -24,13 +24,24 @@ lark-cli drive +add-comment \
--doc "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--content '[{"type":"text","text":"这里需要一段全文评论"}]'
# 给受支持的 Drive 普通文件添加全文评论
# 注意CLI 会先查询 drive metas只有白名单扩展名才允许评论
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/file/<FILE_TOKEN>" \
--content '[{"type":"text","text":"请补充文件说明"}]'
# 裸 token 也支持,但必须显式声明 --type file
lark-cli drive +add-comment \
--doc "<FILE_TOKEN>" --type file \
--content '[{"type":"text","text":"请补充目录说明"}]'
# 给 docx 文档的指定 block 添加局部评论block_id 可通过 docs +fetch --api-version v2 --detail with-ids 获取)
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/docx/<DOC_ID>" \
--block-id "<BLOCK_ID>" \
--content '[{"type":"text","text":"请补充流程说明"}]'
# wiki 链接也支持局部评论,但解析结果必须是 docx
# wiki 链接也支持局部评论解析结果可以是 docx/sheet/slidesblock-id 格式按目标类型传
lark-cli drive +add-comment \
--doc "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--block-id "<BLOCK_ID>" \
@@ -128,8 +139,8 @@ lark-cli drive +add-comment \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--doc` | 是 | 文档 URL / token、sheet / slides URL或可解析到 `doc`/`docx`/`sheet`/`slides` 的 wiki URL |
| `--type` | 裸 token 时必填 | 文档类型:`doc``docx``sheet``slides`。URL 输入时自动识别,无需传 |
| `--doc` | 是 | 文档 URL / token、file / sheet / slides URL或可解析到 `doc`/`docx`/`file`/`sheet`/`slides` 的 wiki URL |
| `--type` | 裸 token 时必填 | 文档类型:`doc``docx``file``sheet``slides`。URL 输入时自动识别,无需传 |
| `--content` | 是 | `reply_elements` JSON 数组字符串。示例:`'[{"type":"text","text":"文本"},{"type":"mention_user","text":"ou_xxx"},{"type":"link","text":"https://example.com"}]'` |
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--block-id` 时也会默认走全文评论(不适用于 sheet |
| `--block-id` | 局部评论时必填 | 目标块 ID可通过 `docs +fetch --api-version v2 --detail with-ids` 获取。**Sheet 评论**:格式为 `<sheetId>!<cell>`(如 `a281f9!D6` |
@@ -138,7 +149,10 @@ lark-cli drive +add-comment \
- **局部评论需要先获取 block ID**:先调用 `docs +fetch --api-version v2 --doc <TOKEN> --detail with-ids` 获取带有 block ID 的文档内容,然后使用 `--block-id` 指定目标块。
- **Review 场景优先局部评论**:审阅、校对、逐条指出问题时,必须先尝试定位到具体 block / 单元格 / slide 元素,并逐问题创建局部评论;不要把所有问题合并成一条全文评论。
- 未传 `--block-id`shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。全文评论支持 `docx`、旧版 `doc` URL以及最终可解析为 `doc`/`docx` 的 wiki URL。
- 未传 `--block-id`shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。全文评论支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终可解析为 `doc`/`docx`/`file` 的 wiki URL。
- **Drive file 评论**:仅支持白名单扩展名的普通文件。当前支持:`.md``.txt``.json``.csv``.go``.js``.py``.pptx``.png``.jpg``.jpeg``.zip``.mp3``.mp4`
- **Drive file 暂不支持**`.pdf``.docx``.xlsx` 等未在白名单内的普通文件会被 CLI 拒绝,并提示“当前还不支持这种类型的评论”。这些类型虽然可能接受 OpenAPI 请求,但在页面评论展示上存在问题。
- **Drive file 只支持全文评论**file 目标不支持局部评论,不允许传 `--block-id``--selection-with-ellipsis`。由于当前 OpenAPI 要求 file 评论传入非空 `anchor.block_id`CLI 会固定传占位值 `test`UI 上仍表现为文件全文评论。
-`--block-id`shortcut 创建**局部评论(划词评论)**;该模式支持 `docx``sheet``slides`,以及最终可解析为这些类型的 wiki URL。
- **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "<sheetId>!<cell>"` 指定单元格(如 `a281f9!D6`sheet 没有全文评论,`--full-comment` 不可用。
- **Slide 评论**:当 `--doc` 为 slides URL、`--type slides`,或 wiki 解析为 slides 时,必须传 `--block-id "<SLIDE_BLOCK_TYPE>!<XML_ELEMENT_ID>"`。CLI 会将其拆分映射到 `anchor.block_id` / `anchor.slide_block_type`。此时 `--full-comment``--selection-with-ellipsis` 不可用。

View File

@@ -15,7 +15,7 @@ metadata:
> **成员管理硬限制:**
> - 如果目标是“部门”,先判断身份,再决定是否继续。
> - `--as bot` 对应 `tenant_access_token`。官方限制:这种身份下不能使用部门 ID (`opendepartmentid`) 添加知识空间成员。
> - 遇到“部门 + --as bot”时禁止先调用 `lark-cli wiki members create` 试错;直接说明该路径不可行。
> - 遇到“部门 + --as bot”时禁止先调用 `lark-cli wiki +member-add` 试错;直接说明该路径不可行。
> - 如果用户明确要求“以 bot 身份运行”,且目标是部门,必须停下说明 bot 路径无法完成,不要静默切到 `--as user`。
## 身份选择:优先使用 user 身份
@@ -32,18 +32,20 @@ metadata:
- 命中 0 条:停下来问用户是名称拼错了还是调用方无权限;**不要**自行改名字重试。
- 用户明确选定后再执行 `lark-cli wiki +delete-space --space-id <ID> --yes`(高风险写操作,必须显式 `--yes`)。
- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门”三类之一,再决定 `member_type`,不要先调 `wiki members create` 再根据报错反推类型。
- 用户说“部门 + bot”这是已知不支持路径。不要继续尝试 `wiki members create --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。
- 用户说“用户 / 群 + 添加成员”:先解析对应 ID再执行 `wiki members create`
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门”三类之一,再决定 `--member-type`,不要先调 `wiki +member-add` 再根据报错反推类型。
- 用户说“部门 + bot”这是已知不支持路径。不要继续尝试 `wiki +member-add --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。
- 用户说“用户 / 群 + 添加成员”:先解析对应 ID再执行 `wiki +member-add`
- 用户说“查看 / 列出空间成员”:用 `wiki +member-list`;该 shortcut 默认只取一页,多成员场景显式加 `--page-all`
- 用户说“移除 / 删除空间成员”:用 `wiki +member-remove`,必须传齐原始授予时的 `--member-type``--member-role`(不知道就先 `wiki +member-list` 查一下)。
## 成员添加流程
- 调用 `lark-cli wiki members create` 前,先把自然语言里的“人 / 群 / 部门”解析成正确的 `member_id`,不要猜格式。
- 用户场景默认优先 `member_type=openid`:用 `lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --format json` 获取 `open_id`
- 群组场景使用 `member_type=openchat`:用 `lark-cli im +chat-search --query "<群名关键词>" --format json` 获取 `chat_id`
- 调用 `lark-cli wiki +member-add` 前,先把自然语言里的“人 / 群 / 部门”解析成正确的 `--member-id`,不要猜格式。
- 用户场景默认优先 `--member-type=openid`:用 `lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --format json` 获取 `open_id`
- 群组场景使用 `--member-type=openchat`:用 `lark-cli im +chat-search --query "<群名关键词>" --format json` 获取 `chat_id`
- `userid` / `unionid` 只在下游明确要求时才使用;先拿到 `open_id`,再调用 `lark-cli api GET /open-apis/contact/v3/users/<open_id> --params '{"user_id_type":"open_id"}' --format json` 读取 `user_id` / `union_id`
- 部门场景使用 `member_type=opendepartmentid`:当前 CLI 没有 shortcut需调用 `lark-cli api POST /open-apis/contact/v3/departments/search --as user --params '{"department_id_type":"open_department_id"}' --data '{"query":"<部门名>"}'` 获取 `open_department_id`
- 只有在目标类型和身份都已确认可行后,才调用 `lark-cli wiki members create`。对于部门场景,这意味着必须是 `--as user`
- 部门场景使用 `--member-type=opendepartmentid`:当前 CLI 没有 shortcut需调用 `lark-cli api POST /open-apis/contact/v3/departments/search --as user --params '{"department_id_type":"open_department_id"}' --data '{"query":"<部门名>"}'` 获取 `open_department_id`
- 只有在目标类型和身份都已确认可行后,才调用 `lark-cli wiki +member-add`。对于部门场景,这意味着必须是 `--as user`
## 目标语义约束
@@ -67,6 +69,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`
| [`+node-copy`](references/lark-wiki-node-copy.md) | Copy a wiki node to a target space or parent node |
| [`+node-get`](references/lark-wiki-node-get.md) | Get a wiki node's details by node_token / obj_token / Lark URL |
| [`+node-delete`](references/lark-wiki-node-delete.md) | Delete a wiki node, polling the async delete task when needed |
| [`+member-add`](references/lark-wiki-member-add.md) | Add a member to a wiki space |
| [`+member-remove`](references/lark-wiki-member-remove.md) | Remove a member from a wiki space |
| [`+member-list`](references/lark-wiki-member-list.md) | List members of a wiki space (supports pagination) |
## API Resources

View File

@@ -0,0 +1,66 @@
# lark-wiki +member-add
Add a member to a wiki space. OpenAPI: `POST /open-apis/wiki/v2/spaces/:space_id/members`. Shortcut over the raw `wiki members create` — adds enum hints, optional `--need-notification`, `my_library` resolution, and a flattened single-member output envelope.
> The underlying `members.create` API is flagged `danger: true` in the schema browser, but adding a member is **not** confirmation-gated (no `--yes`). To revert, call [`+member-remove`](lark-wiki-member-remove.md) with the same `(member_id, member_type, member_role)` tuple.
## Usage
```bash
# Add a user as a regular member
lark-cli wiki +member-add \
--space-id <space_id> \
--member-id <open_id|email|user_id|...> \
--member-type <openid|email|userid|unionid|openchat|opendepartmentid> \
--member-role <admin|member> \
[--need-notification] \
[--as user|bot]
# Personal library (resolves my_library to the per-user real space first)
lark-cli wiki +member-add \
--space-id my_library \
--member-id ou_xxx --member-type openid --member-role member \
--as user
# Preview the call chain without writing
lark-cli wiki +member-add \
--space-id <space_id> --member-id <id> --member-type openid --member-role admin \
--dry-run
```
## Flags
| Flag | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `--space-id` | string | **Yes** | — | Wiki space ID; use `my_library` for the personal document library (user only) |
| `--member-id` | string | **Yes** | — | Member ID; interpretation is decided by `--member-type` |
| `--member-type` | enum | **Yes** | — | `openchat` / `userid` / `email` / `opendepartmentid` / `openid` / `unionid` |
| `--member-role` | enum | **Yes** | — | `admin` (full space administration) / `member` (collaborator) |
| `--need-notification` | bool | No | unset | Send an in-app notification after the grant. **Omitting the flag sends no `need_notification` query at all** — passing `--need-notification=false` is the explicit opt-out |
| `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` |
## Output
```json
{
"space_id": "7160145948494381236",
"member_id": "ou_449b53ad6aee526f7ed311b216aabcef",
"member_type": "openid",
"member_role": "admin",
"type": "user"
}
```
`type` is a read-only enum (`user` / `chat` / `department`) the server attaches; absent when the API omits it.
## Notes
- **Bot + `my_library` is rejected upfront** — `my_library` is a per-user alias with no meaning for a tenant token. Pass an explicit `--space-id` when `--as bot`.
- **Bot + `opendepartmentid` is a known unsupported path on the backend.** The CLI does not pre-block it (the API may evolve), but the call will fail. Use `--as user` for department adds.
- Resolve `--member-id` **before** calling: `lark-cli contact +search-user` for users, `lark-cli im +chat-search` for groups, `lark-cli api POST /open-apis/contact/v3/departments/search` for departments. Do not call `+member-add` first and reverse-engineer the type from the error.
- The role switch (`admin``member`) is not a single update — call [`+member-remove`](lark-wiki-member-remove.md) for the old role first, then `+member-add` with the new one.
- `--dry-run` previews 2 steps when `--space-id my_library` (resolve → add), 1 step otherwise.
## Required Scope
`wiki:member:create`

View File

@@ -0,0 +1,76 @@
# lark-wiki +member-list
List the members of a wiki space. OpenAPI: `GET /open-apis/wiki/v2/spaces/:space_id/members`. **Default fetches a single page** (matches `+space-list` / `+node-list`); pass `--page-all` to walk every page.
## Usage
```bash
# Default: single page
lark-cli wiki +member-list --space-id <space_id>
# Walk every page (capped by --page-limit, default 10)
lark-cli wiki +member-list --space-id <space_id> --page-all
# Walk every page, no cap
lark-cli wiki +member-list --space-id <space_id> --page-all --page-limit 0
# Resume from a specific cursor (single-page fetch regardless of --page-all)
lark-cli wiki +member-list --space-id <space_id> --page-token <TOKEN>
# Personal library
lark-cli wiki +member-list --space-id my_library --as user
# Pretty / table / csv / ndjson output
lark-cli wiki +member-list --space-id <space_id> --format pretty
lark-cli wiki +member-list --space-id <space_id> --format table
```
## Flags
| Flag | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `--space-id` | string | **Yes** | — | Wiki space ID; use `my_library` for the personal document library (user only) |
| `--page-size` | int | No | 50 | Page size, 1-50 |
| `--page-token` | string | No | — | Page cursor; implies single-page fetch (no auto-pagination) |
| `--page-all` | bool | No | `false` | Automatically paginate through all pages (capped by `--page-limit`) |
| `--page-limit` | int | No | 10 | Max pages with `--page-all` (0 = unlimited) |
| `--format` | enum | No | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` |
| `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` |
## Output
```json
{
"ok": true,
"data": {
"space_id": "7160145948494381236",
"members": [
{
"member_id": "ou_449b53ad6aee526f7ed311b216aabcef",
"member_type": "openid",
"member_role": "admin"
},
{
"member_id": "ou_67e5ecb64ce1c0bd94612c17999db411",
"member_type": "openid",
"member_role": "member"
}
],
"has_more": false,
"page_token": ""
},
"meta": { "count": 2 }
}
```
`type` (`user` / `chat` / `department`) is included when the server returns it. When the default single-page fetch (or `--page-all` capped by `--page-limit`) does not exhaust the upstream cursor, `has_more=true` and `page_token=<cursor>` so the caller can resume.
## Notes
- **Bot + `my_library` is rejected upfront** — pass an explicit `--space-id` when `--as bot`.
- Use `member_id` from the output as `--member-id` for [`+member-remove`](lark-wiki-member-remove.md); `member_type` and `member_role` must be passed exactly as listed to remove a grant.
- `--dry-run` previews 2 steps when `--space-id my_library` (resolve → list), 1 step otherwise.
## Required Scope
`wiki:member:retrieve`

View File

@@ -0,0 +1,61 @@
# lark-wiki +member-remove
Remove a member from a wiki space. OpenAPI: `DELETE /open-apis/wiki/v2/spaces/:space_id/members/:member_id`. Unlike most DELETEs, this endpoint **requires a body** carrying `member_type` and `member_role` — the `:member_id` path segment alone is ambiguous without both.
> The underlying `members.delete` API is flagged `danger: true` in the schema browser, but the operation is recoverable — call [`+member-add`](lark-wiki-member-add.md) with the same `(member_id, member_type, member_role)` to restore. No `--yes` gate.
## Usage
```bash
lark-cli wiki +member-remove \
--space-id <space_id> \
--member-id <open_id|email|user_id|...> \
--member-type <openid|email|userid|unionid|openchat|opendepartmentid> \
--member-role <admin|member> \
[--as user|bot]
# Personal library (resolves my_library first)
lark-cli wiki +member-remove \
--space-id my_library \
--member-id ou_xxx --member-type openid --member-role member \
--as user
# Preview the call chain without deleting
lark-cli wiki +member-remove \
--space-id <id> --member-id <id> --member-type openid --member-role admin \
--dry-run
```
## Flags
| Flag | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `--space-id` | string | **Yes** | — | Wiki space ID; use `my_library` for the personal document library (user only) |
| `--member-id` | string | **Yes** | — | Member ID; interpretation is decided by `--member-type` |
| `--member-type` | enum | **Yes** | — | Must **match the original grant**: `openchat` / `userid` / `email` / `opendepartmentid` / `openid` / `unionid` |
| `--member-role` | enum | **Yes** | — | Must **match the original grant**: `admin` / `member` |
| `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` |
## Output
```json
{
"space_id": "7160145948494381236",
"member_id": "ou_449b53ad6aee526f7ed311b216aabcef",
"member_type": "openid",
"member_role": "admin"
}
```
If the API ever omits the member echo, the CLI falls back to surfacing the caller-supplied `(member_id, member_type, member_role)` so scripts still see what was removed.
## Notes
- **`--member-type` and `--member-role` must match the original grant.** Revoking a non-existent `(member_id, type, role)` tuple is a no-op error from the API. If you do not know the current role, run [`+member-list`](lark-wiki-member-list.md) first.
- **Role switch is not a single update.** To move someone between `admin` and `member`, call `+member-remove` with the old role first, then [`+member-add`](lark-wiki-member-add.md) with the new one.
- **Bot + `my_library` is rejected upfront.** Pass an explicit `--space-id` when `--as bot`.
- `--dry-run` previews 2 steps when `--space-id my_library` (resolve → delete), 1 step otherwise.
## Required Scope
`wiki:member:update`

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsAccessScopeGetDryRun pins URL shape and --app-id requirement for the
// read-side companion of +access-scope-set. Response passthrough (scope enum,
// split user/department/chat arrays) is covered by unit tests in shortcuts/apps.
func TestAppsAccessScopeGetDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("HappyPath", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-get",
"--app-id", "app_x",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/access-scope", gjson.Get(result.Stdout, "api.0.url").String())
// GET request: no body and no query params.
assert.False(t, gjson.Get(result.Stdout, "api.0.body").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.params").Exists())
})
t.Run("RejectsMissingAppID", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+access-scope-get", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
// cobra Required failures exit with code 1 (distinct from output.ErrValidation
// at code 2). Message goes to stderr as plain text, but we read combined output
// to stay robust to future runner changes.
result.AssertExitCode(t, 1)
assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "app-id" not set`)
})
}

View File

@@ -0,0 +1,193 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsAccessScopeSetDryRun pins the user-facing scope-string -> server-enum
// mapping (public->All, tenant->Tenant, specific->Range) and the three-way
// mutex between specific / public / tenant.
func TestAppsAccessScopeSetDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("SpecificMapsToRange", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"user","id":"ou_x"},{"type":"chat","id":"oc_x"}]`,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "PUT", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/access-scope", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "Range", gjson.Get(result.Stdout, "api.0.body.scope").String())
assert.Equal(t, "ou_x", gjson.Get(result.Stdout, "api.0.body.users.0").String())
assert.Equal(t, "oc_x", gjson.Get(result.Stdout, "api.0.body.chats.0").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.departments").Exists(),
"empty department list must be omitted")
assert.False(t, gjson.Get(result.Stdout, "api.0.body.apply_config").Exists())
})
t.Run("SpecificWithApplyConfig", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"user","id":"ou_x"}]`,
"--apply-enabled",
"--approver", "ou_y",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.True(t, gjson.Get(result.Stdout, "api.0.body.apply_config.enabled").Bool())
assert.Equal(t, "ou_y", gjson.Get(result.Stdout, "api.0.body.apply_config.approvers.0").String())
})
t.Run("PublicMapsToAll", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "public",
"--require-login=false",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "All", gjson.Get(result.Stdout, "api.0.body.scope").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.require_login").Bool())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.users").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.apply_config").Exists())
})
t.Run("TenantMapsToTenant", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "tenant",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Tenant", gjson.Get(result.Stdout, "api.0.body.scope").String())
// scope is the only body field in tenant mode.
assert.False(t, gjson.Get(result.Stdout, "api.0.body.require_login").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.users").Exists())
})
t.Run("RejectsSpecificMissingTargets", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), "--targets is required")
})
t.Run("RejectsTenantWithExtraFlags", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "tenant",
"--targets", `[]`,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), "no extra flags allowed")
})
t.Run("RejectsBadTargetType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"group","id":"oc_x"}]`,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), "must be one of")
})
t.Run("RejectsApproverWithoutApplyEnabled", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"user","id":"ou_x"}]`,
"--approver", "ou_y",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), "--apply-enabled")
})
}

View File

@@ -0,0 +1,170 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsCreateDryRun pins the request shape and Validate behavior for
// `apps +create`. The shortcut is UAT-only and posts to the registered
// /open-apis/spark/v1 namespace; both are checked here.
func TestAppsCreateDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("HappyPath_HTMLAppType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--name", "Demo",
"--app-type", "HTML",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "Demo", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "HTML", gjson.Get(result.Stdout, "api.0.body.app_type").String())
// Optional fields stay omitted when not provided.
assert.False(t, gjson.Get(result.Stdout, "api.0.body.description").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.icon_url").Exists())
})
t.Run("AllFields", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--name", "Demo",
"--app-type", "HTML",
"--description", "survey app",
"--icon-url", "https://example.com/icon.svg",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Demo", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "HTML", gjson.Get(result.Stdout, "api.0.body.app_type").String())
assert.Equal(t, "survey app", gjson.Get(result.Stdout, "api.0.body.description").String())
assert.Equal(t, "https://example.com/icon.svg", gjson.Get(result.Stdout, "api.0.body.icon_url").String())
})
t.Run("RejectsMissingName", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--app-type", "HTML",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
// cobra Required failures exit with code 1 (distinct from output.ErrValidation
// at code 2). Message goes to stderr as plain text, but we read combined output
// to stay robust to future runner changes.
result.AssertExitCode(t, 1)
assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "name" not set`)
})
t.Run("RejectsBlankName", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--name", " ",
"--app-type", "HTML",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateErrorMessage(result)
assert.Contains(t, msg, "--name is required")
})
t.Run("RejectsMissingAppType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--name", "Demo",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 1)
assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "app-type" not set`)
})
t.Run("RejectsInvalidAppType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--name", "Demo",
"--app-type", "spa",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateErrorMessage(result)
assert.Contains(t, msg, "not supported")
assert.Contains(t, msg, "HTML")
})
t.Run("RejectsLowercaseAppType", func(t *testing.T) {
// app-type is case-sensitive; lowercase "html" must be rejected even though
// it differs from the allowed "HTML" by case alone.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--name", "Demo",
"--app-type", "html",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateErrorMessage(result)
assert.True(t, strings.Contains(msg, `"html"`) && strings.Contains(msg, "not supported"),
"expected case-sensitive rejection, got: %s", msg)
})
}

View File

@@ -0,0 +1,290 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsHTMLPublishDryRun exercises the walker / manifest layer without
// packing or uploading. --path goes through LocalFileIO which bounds reads to
// the runtime cwd, so each sub-test seeds fixtures in a t.TempDir and runs
// the binary with WorkDir set to that dir + relative --path.
//
// Hidden files are intentionally included — the walker is deliberately not
// filtering, so the manifest must reflect everything the user pointed --path
// at. Users are documented to pass clean build output directories (e.g.
// ./dist), not source trees.
func TestAppsHTMLPublishDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("Directory_ReportsManifest", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html><body>hi</body></html>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "logo.svg"), []byte("<svg/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code", gjson.Get(result.Stdout, "api.0.url").String())
// file_count / files / total_size_bytes sit at envelope top level
// (not under api.0.body — manifest is dry-run metadata, not the HTTP body).
assert.Equal(t, int64(2), gjson.Get(result.Stdout, "file_count").Int())
assert.Greater(t, gjson.Get(result.Stdout, "total_size_bytes").Int(), int64(0))
files := gjson.Get(result.Stdout, "files").Array()
require.Len(t, files, 2)
names := []string{files[0].String(), files[1].String()}
assert.Contains(t, names, "index.html")
assert.Contains(t, names, "logo.svg")
})
t.Run("SingleFile_OneEntry", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "page.html"), []byte("<html></html>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "page.html",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int())
assert.Equal(t, "page.html", gjson.Get(result.Stdout, "files.0").String())
})
t.Run("HiddenFilesIncluded", func(t *testing.T) {
// Walker MUST NOT silently filter .git / .DS_Store — that's an explicit
// design decision so users pass clean ./dist trees, not source repos.
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", ".DS_Store"), []byte("noise"), 0o644))
require.NoError(t, os.Mkdir(filepath.Join(dir, "dist", ".git"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(3), gjson.Get(result.Stdout, "file_count").Int(),
"walker must include hidden files; got: %s", result.Stdout)
})
t.Run("EmptyDir_ManifestEmpty", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "file_count").Int())
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "total_size_bytes").Int())
assert.Contains(t, gjson.Get(result.Stdout, "validation_error").String(), "index.html",
"empty dir should report index.html validation_error: %s", result.Stdout)
})
t.Run("MissingIndexHTML_SurfacesValidationError", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "page.html"), []byte("<html/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int())
assert.Equal(t, "page.html", gjson.Get(result.Stdout, "files.0").String())
assert.Contains(t, gjson.Get(result.Stdout, "validation_error").String(), "index.html")
})
t.Run("RejectsMissingAppID", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 1)
assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "app-id" not set`)
})
t.Run("RejectsMissingPath", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 1)
assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "path" not set`)
})
t.Run("WarningsForSensitivePaths", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", ".env"), []byte("SECRET=xxx\n"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
warnings := gjson.Get(result.Stdout, "warnings").Array()
require.NotEmpty(t, warnings, "expected non-empty warnings for .env: %s", result.Stdout)
var found bool
for _, w := range warnings {
if w.String() == ".env" {
found = true
break
}
}
assert.True(t, found, "warnings should list .env, got %v", warnings)
})
t.Run("RejectsPathEqualsCWD", func(t *testing.T) {
// Even with valid index.html in cwd, --path "." must be rejected at
// Validate (so dry-run also rejects) to prevent accidental
// whole-project secrets exfiltration.
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", ".",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), "当前工作目录")
})
t.Run("TrimsAppIDAndPath", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", " app_x ",
"--path", " ./dist ",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int(),
"path trimming must produce the same manifest as untrimmed input")
})
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsListDryRun pins cursor-pagination params: default page_size=20 is
// always written; empty --page-token is omitted; negative page_size is passed
// through unchanged (server is the source of truth for range validation).
func TestAppsListDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultPageSize", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
"empty page_token must be omitted")
})
t.Run("CustomPageSize", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--page-size", "50", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "50", gjson.Get(result.Stdout, "api.0.params.page_size").String())
})
t.Run("WithPageToken", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--page-token", "cursor_abc", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "cursor_abc", gjson.Get(result.Stdout, "api.0.params.page_token").String())
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
})
t.Run("NegativePageSizePassesThrough", func(t *testing.T) {
// By design CLI does not bound page_size; server validates. Test pins that
// invariant so a well-meaning client-side check doesn't sneak in.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--page-size", "-1", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "-1", gjson.Get(result.Stdout, "api.0.params.page_size").String())
})
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsUpdateDryRun pins partial-update semantics: PATCH with only the
// fields the user supplied; --app-id and at-least-one-field are both required.
func TestAppsUpdateDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("PartialFieldsName", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+update",
"--app-id", "app_x",
"--name", "v2",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "PATCH", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "v2", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.description").Exists(),
"description must be omitted when not provided")
})
t.Run("WithDescription", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+update",
"--app-id", "app_x",
"--name", "v2",
"--description", "updated",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "v2", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "updated", gjson.Get(result.Stdout, "api.0.body.description").String())
})
t.Run("RejectsMissingAppID", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+update",
"--name", "v2",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 1)
assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "app-id" not set`)
})
t.Run("RejectsNoFields", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+update",
"--app-id", "app_x",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateErrorMessage(result)
assert.Contains(t, msg, "at least one")
})
}

View File

@@ -0,0 +1,28 @@
# Apps CLI E2E Coverage
## Metrics
- Denominator: 6 leaf commands (all shortcuts)
- Covered: 6 (dry-run only)
- Coverage: 100% (dry-run); 0% (live)
## Summary
- `TestAppsCreateDryRun`: happy path with `--app-type HTML`, all-fields shape, three rejection paths (missing name, missing app-type, invalid app-type, case-sensitive lowercase rejection).
- `TestAppsUpdateDryRun`: partial-field PATCH semantics; `--app-id` and at-least-one-field validation.
- `TestAppsListDryRun`: default `page_size=20`; empty `--page-token` omitted; negative size passed through to server (no client-side bound check).
- `TestAppsAccessScopeSetDryRun`: CLI input `specific`/`public`/`tenant` -> server enum `Range`/`All`/`Tenant`; `apply_config.approvers` shape; four mutex rejection paths.
- `TestAppsAccessScopeGetDryRun`: URL shape; no body/params on GET; `--app-id` required.
- `TestAppsHTMLPublishDryRun`: walker manifest for directory + single file; hidden files intentionally included (design decision); empty dir / missing `index.html` produce envelope `validation_error` field (dry-run exits 0 advisory, not blocking); both required-flag rejections.
Blocked: Live E2E intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Revisit when the server exposes `DELETE /apps/{appId}`.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | apps +create | shortcut | apps_create_dryrun_test.go::TestAppsCreateDryRun | `--name`, `--app-type` (required, case-sensitive, `HTML` only), `--description`, `--icon-url` | live blocked: no +delete to clean up |
| ✓ | apps +update | shortcut | apps_update_dryrun_test.go::TestAppsUpdateDryRun | `--app-id`; at least one of `--name`/`--description` | live blocked: no +delete |
| ✓ | apps +list | shortcut | apps_list_dryrun_test.go::TestAppsListDryRun | `--page-size` default 20; `--page-token` cursor | live blocked: needs tenant fixtures |
| ✓ | apps +access-scope-set | shortcut | apps_access_scope_set_dryrun_test.go::TestAppsAccessScopeSetDryRun | `--scope specific/public/tenant`; `--targets` JSON; `--apply-enabled --approver`; `--require-login` | live blocked: needs real open_ids |
| ✓ | apps +access-scope-get | shortcut | apps_access_scope_get_dryrun_test.go::TestAppsAccessScopeGetDryRun | `--app-id` | live blocked: depends on +access-scope-set state |
| ✓ | apps +html-publish | shortcut | apps_html_publish_dryrun_test.go::TestAppsHTMLPublishDryRun | `--app-id`, `--path` (file or directory containing `index.html`) | live blocked: real upload has side effects; no rollback API |

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/tidwall/gjson"
)
// setAppsDryRunEnv isolates config and supplies stub credentials so dry-run
// short-circuits before identity / scope resolution touches a real keychain.
// Apps shortcuts are UAT-only, so tests pass DefaultAs:"user" to the harness.
func setAppsDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "apps_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "apps_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
// validateErrorMessage extracts the structured error.message from a dry-run
// Validate-stage failure envelope. Repo convention is "stdout first, stderr
// fallback" — markdown / drive_search emit the JSON envelope to stdout (exit
// 0), apps currently emits to stderr (exit 2). Reading both orders shields
// tests from runner-internal routing changes.
func validateErrorMessage(r *clie2e.Result) string {
if msg := gjson.Get(r.Stdout, "error.message").String(); msg != "" {
return msg
}
return gjson.Get(r.Stderr, "error.message").String()
}

View File

@@ -2,8 +2,8 @@
## Metrics
- Denominator: 29 leaf commands
- Covered: 8
- Coverage: 27.6%
- Covered: 9
- Coverage: 31.0%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
@@ -11,18 +11,20 @@
- TestDrive_UploadWorkflow: proves `drive +upload` against the real backend in both create and overwrite modes. First uploads a fresh file into a temporary Drive folder, then re-uploads new bytes with `--file-token` against the returned token, asserts the overwrite keeps the token stable, and finally downloads the file to confirm the remote content changed.
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with `duplicate_remote_path`, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- TestDriveAddCommentDryRun_File: dry-run coverage for `drive +add-comment` on supported Drive file targets; pins the `metas.batch_query -> files/:token/new_comments` request chain, `file_type=file`, and the required placeholder `anchor.block_id`.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
- TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs.
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
- Blocked area: live export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
- Blocked area: live export, permission, subscription, reply, and file comment API flows still need deterministic remote fixtures and filesystem setup.
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` and `TestDriveUploadDryRun_WithFileToken` cover the wiki-target and overwrite request shapes for `drive +upload`; live upload/status/duplicate workflows also use real `+upload` against the backend.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| | drive +add-comment | shortcut | | none | no comment workflow yet |
| | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id` | dry-run coverage in place; opt-in live workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveAddCommentDryRun_File(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+add-comment",
"--doc", "https://example.larksuite.com/file/fileDryRunComment",
"--content", `[{"type":"text","text":"please update README"}]`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("api.0.url=%q, want metas/batch_query\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.request_docs.0.doc_type").String(); got != "file" {
t.Fatalf("api.0.body.request_docs.0.doc_type=%q, want file\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/fileDryRunComment/new_comments" {
t.Fatalf("api.1.url=%q, want new_comments\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.file_type").String(); got != "file" {
t.Fatalf("api.1.body.file_type=%q, want file\nstdout:\n%s", got, out)
}
if !gjson.Get(out, "api.1.body.anchor.block_id").Exists() {
t.Fatalf("api.1.body.anchor.block_id should exist for file comment\nstdout:\n%s", out)
}
if got := gjson.Get(out, "api.1.body.anchor.block_id").String(); got != "test" {
t.Fatalf("api.1.body.anchor.block_id=%q, want test\nstdout:\n%s", got, out)
}
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) {
if os.Getenv("LARK_DRIVE_MD_COMMENT_E2E") == "" {
t.Skip("set LARK_DRIVE_MD_COMMENT_E2E=1 to run the supported file comment workflow")
}
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
fileName := "lark-cli-e2e-drive-comment-" + suffix + ".md"
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--name", fileName,
"--content", "# Comment target\n\nbody\n",
},
DefaultAs: "bot",
})
require.NoError(t, err)
createResult.AssertExitCode(t, 0)
createResult.AssertStdoutStatus(t, true)
fileToken := gjson.Get(createResult.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", fileToken,
"--type", "file",
"--yes",
},
DefaultAs: "bot",
})
clie2e.ReportCleanupFailure(parentT, "delete file comment target "+fileToken, deleteResult, deleteErr)
})
commentResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+add-comment",
"--doc", fileToken,
"--type", "file",
"--content", `[{"type":"text","text":"please update README"}]`,
},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
commentResult.AssertExitCode(t, 0)
commentResult.AssertStdoutStatus(t, true)
commentID := gjson.Get(commentResult.Stdout, "data.comment_id").String()
require.NotEmpty(t, commentID, "stdout:\n%s", commentResult.Stdout)
if got := gjson.Get(commentResult.Stdout, "data.file_type").String(); got != "file" {
t.Fatalf("data.file_type=%q, want file\nstdout:\n%s", got, commentResult.Stdout)
}
if got := gjson.Get(commentResult.Stdout, "data.file_name").String(); got != fileName {
t.Fatalf("data.file_name=%q, want %q\nstdout:\n%s", got, fileName, commentResult.Stdout)
}
if got := gjson.Get(commentResult.Stdout, "data.file_extension").String(); got != ".md" {
t.Fatalf("data.file_extension=%q, want .md\nstdout:\n%s", got, commentResult.Stdout)
}
}