mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac85c3e34d | ||
|
|
daba3c9afd | ||
|
|
e54220ade1 | ||
|
|
d3fbc88527 | ||
|
|
652e96906c | ||
|
|
6cea6c9af0 | ||
|
|
816927f8b8 | ||
|
|
56749e70cb | ||
|
|
8c700aea00 | ||
|
|
42746d6c9d | ||
|
|
94b103dbf6 | ||
|
|
e19e09019c | ||
|
|
3bab9a0692 | ||
|
|
6840bb7415 | ||
|
|
ce485eb3f5 | ||
|
|
c98a49f2a3 | ||
|
|
c02a38f077 | ||
|
|
3a3fc31d0b | ||
|
|
8c73f49e91 | ||
|
|
9272b9da99 | ||
|
|
27a5eeddcc | ||
|
|
0c4eadd41e | ||
|
|
69c34481f5 | ||
|
|
fa45e1c7e4 | ||
|
|
d793790807 | ||
|
|
13411d9a51 | ||
|
|
939b7b6fb6 | ||
|
|
a4c5ec99c8 | ||
|
|
7c54f9b023 | ||
|
|
e6bc292575 | ||
|
|
4aa61db8b2 | ||
|
|
28c66be199 | ||
|
|
0e70b056f8 | ||
|
|
95ffff4212 | ||
|
|
e511404065 | ||
|
|
b8469d2dc6 | ||
|
|
afa084e7a4 | ||
|
|
3354494579 | ||
|
|
2bb69d1942 | ||
|
|
c4fb7006d2 | ||
|
|
583349e572 | ||
|
|
315e0ab50c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -41,3 +41,6 @@ app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
.tmp/
|
||||
cover*.out
|
||||
|
||||
lark-env.sh
|
||||
|
||||
@@ -45,6 +45,7 @@ linters:
|
||||
- path: _test\.go$
|
||||
linters:
|
||||
- bodyclose
|
||||
- bidichk
|
||||
- gocritic
|
||||
- depguard
|
||||
- forbidigo
|
||||
|
||||
83
CHANGELOG.md
83
CHANGELOG.md
@@ -2,6 +2,84 @@
|
||||
|
||||
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
|
||||
|
||||
- **drive/markdown**: Return real tenant URLs for `drive +upload` and `markdown +create` (#992)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **auth**: Return validation error when `--scope` is empty in `auth check` (#999)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **lark-drive**: Improve search evidence guidance (#864)
|
||||
|
||||
## [v1.0.35] - 2026-05-20
|
||||
|
||||
### Features
|
||||
|
||||
- **markdown**: Support wiki node target in `+create` (#883)
|
||||
- **markdown**: Add `+diff` shortcut (#876)
|
||||
- **base**: Add form `+detail` / `+submit` shortcuts (#759)
|
||||
- **skills**: Add incremental skills sync (#965)
|
||||
- **doc**: Warn before overwrite when document contains whiteboard or file blocks (#825)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Clarify media key formats for message media flags (#991)
|
||||
- **im**: Add media-preview reference (#990)
|
||||
- **drive**: Migrate `docs +search` to `drive +search` and fix `creator_ids` owner semantic (#951)
|
||||
- **drive**: Prefer local comments for drive reviews (#981)
|
||||
- **wiki**: Add wiki base fast path (#982)
|
||||
|
||||
## [v1.0.34] - 2026-05-19
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Switch markdown export to V2 `docs_ai` fetch API (#948)
|
||||
- **drive**: Add `+inspect` shortcut for document URL inspection with wiki unwrapping (#947)
|
||||
- **wiki**: Add `+node-get` / `+node-delete` / `+space-create` shortcuts (#904)
|
||||
- **base**: Support Base attachment APIs (#887)
|
||||
- **mail**: Validate `bot` + `mailbox=me` and add dynamic `--as` help tests (#895)
|
||||
- **mail**: Expose draft priority in `--inspect` projection and document `--set-priority` (#779)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **identitydiag**: Harden verify path and tighten status semantics (#961)
|
||||
- **wiki**: Surface real node URL for `+node-create` / `+node-copy` (#960)
|
||||
- **auth**: Split bot and user identity diagnostics (#957)
|
||||
- **base**: Address Base attachment review follow-ups (#958)
|
||||
- **docs**: Clarify `replace_all` selection errors (#954)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Clarify add comment constraints (#967)
|
||||
- **lark-im**: Clarify message activity search (#865)
|
||||
|
||||
### Tests
|
||||
|
||||
- Verify e2e resource cleanup (#949)
|
||||
- **lint**: Exclude `bidichk` from test files (#959)
|
||||
|
||||
## [v1.0.33] - 2026-05-18
|
||||
|
||||
### Features
|
||||
@@ -745,6 +823,11 @@ 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
|
||||
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
|
||||
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 页面和应用 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
|
||||
@@ -47,8 +47,7 @@ func authCheckRun(opts *CheckOptions) error {
|
||||
|
||||
required := strings.Fields(opts.Scope)
|
||||
if len(required) == 0 {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": true, "granted": []string{}, "missing": []string{}})
|
||||
return nil
|
||||
return output.ErrValidation("--scope cannot be empty")
|
||||
}
|
||||
|
||||
config, err := f.Config()
|
||||
|
||||
@@ -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)
|
||||
|
||||
32
cmd/auth/login_brand_filter_test.go
Normal file
32
cmd/auth/login_brand_filter_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,13 +5,11 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -60,73 +58,83 @@ func authStatusRun(opts *StatusOptions) error {
|
||||
"defaultAs": defaultAs,
|
||||
}
|
||||
|
||||
if config.UserOpenId == "" {
|
||||
result["identity"] = "bot"
|
||||
result["note"] = "No user logged in. Only bot (tenant) identity is available for API calls. Run `lark-cli auth login` to log in."
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
result["identity"] = "bot"
|
||||
result["userName"] = config.UserName
|
||||
result["userOpenId"] = config.UserOpenId
|
||||
result["note"] = "Token does not exist or has been cleared. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
status := larkauth.TokenStatus(stored)
|
||||
if status == "expired" {
|
||||
result["identity"] = "bot"
|
||||
result["note"] = "User token has expired. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
|
||||
} else {
|
||||
result["identity"] = "user"
|
||||
}
|
||||
result["userName"] = config.UserName
|
||||
result["userOpenId"] = config.UserOpenId
|
||||
result["tokenStatus"] = status
|
||||
result["scope"] = stored.Scope
|
||||
result["expiresAt"] = time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)
|
||||
result["refreshExpiresAt"] = time.UnixMilli(stored.RefreshExpiresAt).Format(time.RFC3339)
|
||||
result["grantedAt"] = time.UnixMilli(stored.GrantedAt).Format(time.RFC3339)
|
||||
|
||||
// --verify: call the server to confirm token is actually usable.
|
||||
if opts.Verify && status != "expired" {
|
||||
verified, verifyErr := verifyTokenOnServer(f, config)
|
||||
result["verified"] = verified
|
||||
if verifyErr != "" {
|
||||
result["verifyError"] = verifyErr
|
||||
}
|
||||
}
|
||||
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
||||
result["identities"] = diagnostics
|
||||
result["identity"] = effectiveIdentity(diagnostics)
|
||||
addLegacyUserFields(result, diagnostics.User)
|
||||
addEffectiveVerification(result, diagnostics)
|
||||
addStatusNote(result, diagnostics)
|
||||
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyTokenOnServer obtains a valid access token (refreshing if needed)
|
||||
// and calls /authen/v1/user_info to confirm the server accepts it.
|
||||
// Returns (true, "") on success or (false, reason) on failure.
|
||||
func verifyTokenOnServer(f *cmdutil.Factory, config *core.CliConfig) (bool, string) {
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return false, "failed to create HTTP client: " + err.Error()
|
||||
}
|
||||
const (
|
||||
identityUser = "user"
|
||||
identityBot = "bot"
|
||||
identityNone = "none"
|
||||
)
|
||||
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(config, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
return false, "token unusable: " + err.Error()
|
||||
func effectiveIdentity(d identitydiag.Result) string {
|
||||
switch {
|
||||
case d.User.Available:
|
||||
return identityUser
|
||||
case d.Bot.Available:
|
||||
return identityBot
|
||||
default:
|
||||
return identityNone
|
||||
}
|
||||
}
|
||||
|
||||
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
|
||||
if user.OpenID == "" {
|
||||
return
|
||||
}
|
||||
result["userName"] = user.UserName
|
||||
result["userOpenId"] = user.OpenID
|
||||
if user.TokenStatus != "" {
|
||||
result["tokenStatus"] = user.TokenStatus
|
||||
}
|
||||
if user.Scope != "" {
|
||||
result["scope"] = user.Scope
|
||||
}
|
||||
if user.ExpiresAt != "" {
|
||||
result["expiresAt"] = user.ExpiresAt
|
||||
}
|
||||
if user.RefreshExpiresAt != "" {
|
||||
result["refreshExpiresAt"] = user.RefreshExpiresAt
|
||||
}
|
||||
if user.GrantedAt != "" {
|
||||
result["grantedAt"] = user.GrantedAt
|
||||
}
|
||||
}
|
||||
|
||||
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
|
||||
switch result["identity"] {
|
||||
case identityUser:
|
||||
if d.User.Verified != nil {
|
||||
result["verified"] = *d.User.Verified
|
||||
if !*d.User.Verified {
|
||||
result["verifyError"] = d.User.Message
|
||||
}
|
||||
}
|
||||
case identityBot:
|
||||
if d.Bot.Verified != nil {
|
||||
result["verified"] = *d.Bot.Verified
|
||||
if !*d.Bot.Verified {
|
||||
result["verifyError"] = d.Bot.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addStatusNote(result map[string]interface{}, d identitydiag.Result) {
|
||||
switch {
|
||||
case !d.User.Available && d.Bot.Available:
|
||||
result["note"] = "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls. Run `lark-cli auth login` to enable user identity."
|
||||
case d.User.Status == identitydiag.StatusNeedsRefresh:
|
||||
result["note"] = "User identity needs refresh and will be refreshed automatically on the next user API call."
|
||||
case !d.User.Available && !d.Bot.Available:
|
||||
result["note"] = "No usable identity is available. Configure bot credentials or run `lark-cli auth login`."
|
||||
}
|
||||
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
return false, "failed to create SDK client: " + err.Error()
|
||||
}
|
||||
|
||||
if err := larkauth.VerifyUserToken(context.Background(), sdk, token); err != nil {
|
||||
return false, "server rejected token: " + err.Error()
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
96
cmd/auth/status_test.go
Normal file
96
cmd/auth/status_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got statusOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if got.Identities.Bot.Status != "ready" || !got.Identities.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want ready and available", got.Identities.Bot)
|
||||
}
|
||||
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
|
||||
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot",
|
||||
"app_name": "diagnostic bot",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got statusOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if got.Verified == nil || !*got.Verified {
|
||||
t.Fatalf("verified = %v, want true", got.Verified)
|
||||
}
|
||||
if got.Identities.Bot.Verified == nil || !*got.Identities.Bot.Verified {
|
||||
t.Fatalf("bot verified = %v, want true", got.Identities.Bot.Verified)
|
||||
}
|
||||
if got.Identities.Bot.OpenID != "ou_bot" {
|
||||
t.Fatalf("bot open id = %q, want ou_bot", got.Identities.Bot.OpenID)
|
||||
}
|
||||
if got.Identities.User.Status != "missing" {
|
||||
t.Fatalf("user status = %q, want missing", got.Identities.User.Status)
|
||||
}
|
||||
}
|
||||
|
||||
type statusOutput struct {
|
||||
Identity string `json:"identity"`
|
||||
Verified *bool `json:"verified"`
|
||||
Identities struct {
|
||||
Bot statusIdentity `json:"bot"`
|
||||
User statusIdentity `json:"user"`
|
||||
} `json:"identities"`
|
||||
}
|
||||
|
||||
type statusIdentity struct {
|
||||
Status string `json:"status"`
|
||||
Available bool `json:"available"`
|
||||
Verified *bool `json:"verified"`
|
||||
OpenID string `json:"openId"`
|
||||
}
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
@@ -51,7 +51,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
|
||||
// checkResult represents one diagnostic check.
|
||||
type checkResult struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // "pass", "fail", "skip"
|
||||
Status string `json:"status"` // "pass", "warn", "fail", "skip"
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
@@ -118,59 +118,31 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
|
||||
ep := core.ResolveEndpoints(cfg.Brand)
|
||||
|
||||
// ── 3. Token exists ──
|
||||
if cfg.UserOpenId == "" {
|
||||
checks = append(checks, fail("token_exists", "no user logged in", "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
|
||||
if stored == nil {
|
||||
checks = append(checks, fail("token_exists", "no token in keychain for "+cfg.UserOpenId, "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
checks = append(checks, pass("token_exists", fmt.Sprintf("token found for %s (%s)", cfg.UserName, cfg.UserOpenId)))
|
||||
|
||||
// ── 4. Token local validity ──
|
||||
status := larkauth.TokenStatus(stored)
|
||||
switch status {
|
||||
case "valid":
|
||||
checks = append(checks, pass("token_local", "token valid, expires "+time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)))
|
||||
case "needs_refresh":
|
||||
checks = append(checks, pass("token_local", "token needs refresh (will auto-refresh on next call)"))
|
||||
default: // expired
|
||||
checks = append(checks, fail("token_local", "token expired", "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
|
||||
// ── 5. Token server verification ──
|
||||
if opts.Offline {
|
||||
checks = append(checks, skip("token_verified", "skipped (--offline)"))
|
||||
// ── 3. Identity readiness ──
|
||||
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
|
||||
checks = append(checks,
|
||||
identityCheck("bot_identity", diagnostics.Bot),
|
||||
identityCheck("user_identity", diagnostics.User),
|
||||
)
|
||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||
} else {
|
||||
httpClient := mustHTTPClient(f)
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
checks = append(checks, fail("token_verified", "cannot obtain valid token: "+err.Error(), "run: lark-cli auth login --help"))
|
||||
} else {
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
checks = append(checks, fail("token_verified", "SDK init failed: "+err.Error(), ""))
|
||||
} else if err := larkauth.VerifyUserToken(opts.Ctx, sdk, token); err != nil {
|
||||
checks = append(checks, fail("token_verified", "server rejected token: "+err.Error(), "run: lark-cli auth login --help"))
|
||||
} else {
|
||||
checks = append(checks, pass("token_verified", "server confirmed token is valid"))
|
||||
}
|
||||
}
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||
}
|
||||
|
||||
// ── 6 & 7. Endpoint reachability ──
|
||||
// ── 4 & 5. Endpoint reachability ──
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
|
||||
func identityCheck(name string, id identitydiag.Identity) checkResult {
|
||||
if id.Available {
|
||||
return pass(name, id.Message)
|
||||
}
|
||||
return warn(name, id.Message, id.Hint)
|
||||
}
|
||||
|
||||
// networkChecks probes Open API and MCP endpoints concurrently.
|
||||
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
|
||||
if opts.Offline {
|
||||
@@ -232,15 +204,6 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mustHTTPClient returns f.HttpClient() or a default client.
|
||||
func mustHTTPClient(f *cmdutil.Factory) *http.Client {
|
||||
c, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// checkCLIUpdate actively queries the npm registry for the latest version.
|
||||
// Unlike the root-level async check, this does a synchronous fetch with timeout
|
||||
// and works regardless of build version (dev builds included).
|
||||
|
||||
@@ -95,3 +95,59 @@ func TestNetworkChecks_Offline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
err := doctorRun(&DoctorOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Offline: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("doctorRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if !got.OK {
|
||||
t.Fatalf("ok = false, want true; checks = %#v", got.Checks)
|
||||
}
|
||||
assertCheck(t, got.Checks, "bot_identity", "pass")
|
||||
assertCheck(t, got.Checks, "user_identity", "warn")
|
||||
assertCheck(t, got.Checks, "identity_ready", "pass")
|
||||
}
|
||||
|
||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
if check.Name == name {
|
||||
if check.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %q not found in %#v", name, checks)
|
||||
}
|
||||
|
||||
3
go.mod
3
go.mod
@@ -11,6 +11,7 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
@@ -19,6 +20,7 @@ require (
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sync v0.15.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/term v0.27.0
|
||||
golang.org/x/text v0.23.0
|
||||
@@ -61,5 +63,4 @@ require (
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
)
|
||||
|
||||
15
go.sum
15
go.sum
@@ -45,6 +45,7 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -73,6 +74,11 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
@@ -97,6 +103,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
|
||||
@@ -107,8 +115,10 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
@@ -163,7 +173,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -5,6 +5,7 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -66,3 +67,49 @@ func TestAddShortcutIdentityFlag_NoDefault(t *testing.T) {
|
||||
t.Fatalf("default value = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TC-10: AuthTypes=["user"] → usage contains "identity type: user" and NOT "bot".
|
||||
func TestAddShortcutIdentityFlag_UserOnlyAuthTypes(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
|
||||
AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"user"})
|
||||
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("expected --as flag to be visible")
|
||||
}
|
||||
wantUsage := "identity type: user"
|
||||
if flag.Usage != wantUsage {
|
||||
t.Errorf("Usage = %q, want %q", flag.Usage, wantUsage)
|
||||
}
|
||||
if strings.Contains(flag.Usage, "bot") {
|
||||
t.Errorf("Usage should not contain \"bot\" for user-only shortcut, got %q", flag.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TC-11: AuthTypes=["user","bot"] → usage == "identity type: user | bot".
|
||||
func TestAddShortcutIdentityFlag_UserBotAuthTypes(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
|
||||
AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"user", "bot"})
|
||||
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("expected --as flag to be visible")
|
||||
}
|
||||
if got := flag.DefValue; got != "" {
|
||||
t.Fatalf("default value = %q, want empty string", got)
|
||||
}
|
||||
wantUsage := "identity type: user | bot"
|
||||
if flag.Usage != wantUsage {
|
||||
t.Errorf("Usage = %q, want %q", flag.Usage, wantUsage)
|
||||
}
|
||||
}
|
||||
|
||||
325
internal/identitydiag/diagnostics.go
Normal file
325
internal/identitydiag/diagnostics.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package identitydiag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusReady = "ready"
|
||||
StatusNotConfigured = "not_configured"
|
||||
StatusMissing = "missing"
|
||||
StatusNeedsRefresh = "needs_refresh"
|
||||
StatusVerifyFailed = "verify_failed"
|
||||
)
|
||||
|
||||
// verifyTimeout bounds each network call made during --verify so that a
|
||||
// hanging server cannot wedge `auth status --verify` or `doctor`. Mirrors
|
||||
// the 10s timeout used by the doctor endpoint probe.
|
||||
const verifyTimeout = 10 * time.Second
|
||||
|
||||
// Result describes the independently usable bot and user identities.
|
||||
type Result struct {
|
||||
Bot Identity `json:"bot"`
|
||||
User Identity `json:"user"`
|
||||
}
|
||||
|
||||
// Identity is a single identity diagnostic result.
|
||||
type Identity struct {
|
||||
Status string `json:"status"`
|
||||
Available bool `json:"available"`
|
||||
Verified *bool `json:"verified,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
OpenID string `json:"openId,omitempty"`
|
||||
AppName string `json:"appName,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
RefreshExpiresAt string `json:"refreshExpiresAt,omitempty"`
|
||||
GrantedAt string `json:"grantedAt,omitempty"`
|
||||
}
|
||||
|
||||
// Diagnose checks bot and user identities separately. When verify is false,
|
||||
// it only reports local readiness and skips server calls.
|
||||
func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Result {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return Result{
|
||||
Bot: diagnoseBot(ctx, f, cfg, verify),
|
||||
User: diagnoseUser(ctx, f, cfg, verify),
|
||||
}
|
||||
}
|
||||
|
||||
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
|
||||
if cfg == nil || cfg.AppID == "" {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "Bot identity: not configured (missing app config)",
|
||||
Hint: "run: lark-cli config --help",
|
||||
}
|
||||
}
|
||||
if !cfg.CanBot() {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "Bot identity: not configured (bot identity is not available in current credential context)",
|
||||
Hint: "check strict mode or the active credential provider",
|
||||
}
|
||||
}
|
||||
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "Bot identity: not configured (missing app secret or bot token)",
|
||||
Hint: "run: lark-cli config --help",
|
||||
}
|
||||
}
|
||||
|
||||
id := Identity{
|
||||
Status: StatusReady,
|
||||
Available: true,
|
||||
Message: "Bot identity: ready",
|
||||
}
|
||||
if !verify {
|
||||
return id
|
||||
}
|
||||
|
||||
token, err := resolveBotToken(ctx, f, cfg)
|
||||
if err != nil {
|
||||
status := StatusVerifyFailed
|
||||
var unavailable *credential.TokenUnavailableError
|
||||
if errors.As(err, &unavailable) {
|
||||
status = StatusNotConfigured
|
||||
}
|
||||
return Identity{
|
||||
Status: status,
|
||||
Verified: boolPtr(false),
|
||||
Message: "Bot identity: " + StatusMessage(status) + ": " + err.Error(),
|
||||
Hint: "check app credentials or the active credential provider",
|
||||
}
|
||||
}
|
||||
|
||||
info, err := fetchBotInfo(ctx, f, cfg, token)
|
||||
if err != nil {
|
||||
return Identity{
|
||||
Status: StatusVerifyFailed,
|
||||
Verified: boolPtr(false),
|
||||
Message: "Bot identity: verify failed: " + err.Error(),
|
||||
Hint: "check app credentials, scopes, network, or tenant access token configuration",
|
||||
}
|
||||
}
|
||||
|
||||
id.Verified = boolPtr(true)
|
||||
id.OpenID = info.OpenID
|
||||
id.AppName = info.AppName
|
||||
return id
|
||||
}
|
||||
|
||||
func diagnoseUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
|
||||
if cfg == nil || cfg.AppID == "" {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "User identity: not configured (missing app config)",
|
||||
Hint: "run: lark-cli config --help",
|
||||
}
|
||||
}
|
||||
if cfg.UserOpenId == "" {
|
||||
return Identity{
|
||||
Status: StatusMissing,
|
||||
Message: "User identity: missing (no user logged in)",
|
||||
Hint: "run: lark-cli auth login --help",
|
||||
}
|
||||
}
|
||||
|
||||
id := Identity{
|
||||
UserName: cfg.UserName,
|
||||
OpenID: cfg.UserOpenId,
|
||||
}
|
||||
stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
|
||||
if stored == nil {
|
||||
id.Status = StatusMissing
|
||||
id.Message = "User identity: missing (no token in keychain for " + cfg.UserOpenId + ")"
|
||||
id.Hint = "run: lark-cli auth login --help"
|
||||
return id
|
||||
}
|
||||
|
||||
fillTokenFields(&id, stored)
|
||||
switch larkauth.TokenStatus(stored) {
|
||||
case "valid":
|
||||
id.Status = StatusReady
|
||||
id.Available = true
|
||||
id.Message = "User identity: ready"
|
||||
case "needs_refresh":
|
||||
id.Status = StatusNeedsRefresh
|
||||
id.Available = true
|
||||
id.Message = "User identity: needs refresh (will auto-refresh on next user API call)"
|
||||
default:
|
||||
id.Status = StatusMissing
|
||||
id.Message = "User identity: missing (refresh token expired)"
|
||||
id.Hint = "run: lark-cli auth login --help"
|
||||
return id
|
||||
}
|
||||
|
||||
if !verify {
|
||||
return id
|
||||
}
|
||||
|
||||
markVerifyFailed := func(reason, hint string) Identity {
|
||||
id.Status = StatusVerifyFailed
|
||||
id.Available = false
|
||||
id.Verified = boolPtr(false)
|
||||
id.Message = "User identity: verify failed: " + reason
|
||||
if hint != "" {
|
||||
id.Hint = hint
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return markVerifyFailed("create HTTP client: "+err.Error(), "")
|
||||
}
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
return markVerifyFailed("token unusable: "+err.Error(), "run: lark-cli auth login --help")
|
||||
}
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
return markVerifyFailed("SDK init failed: "+err.Error(), "")
|
||||
}
|
||||
verifyCtx, cancel := context.WithTimeout(ctx, verifyTimeout)
|
||||
defer cancel()
|
||||
if err := larkauth.VerifyUserToken(verifyCtx, sdk, token); err != nil {
|
||||
return markVerifyFailed("server rejected token: "+err.Error(), "run: lark-cli auth login --help")
|
||||
}
|
||||
|
||||
id.Verified = boolPtr(true)
|
||||
if id.Status == StatusReady {
|
||||
id.Message = "User identity: ready"
|
||||
} else {
|
||||
id.Message = "User identity: needs refresh (server verification succeeded after refresh)"
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func resolveBotToken(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig) (string, error) {
|
||||
if f == nil || f.Credential == nil {
|
||||
return "", &credential.TokenUnavailableError{Type: credential.TokenTypeTAT}
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, cfg.AppID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", &credential.TokenUnavailableError{Type: credential.TokenTypeTAT}
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
type botInfo struct {
|
||||
OpenID string
|
||||
AppName string
|
||||
}
|
||||
|
||||
func fetchBotInfo(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, token string) (*botInfo, error) {
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create HTTP client: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, verifyTimeout)
|
||||
defer cancel()
|
||||
url := strings.TrimRight(core.ResolveEndpoints(cfg.Brand).Open, "/") + "/open-apis/bot/v3/info"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
// /open-apis/bot/v3/info returns `{code, msg, bot: {...}}` — the bot
|
||||
// payload is under "bot", not "data" as the newer Lark API convention.
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
OpenID string `json:"open_id"`
|
||||
AppName string `json:"app_name"`
|
||||
} `json:"bot"`
|
||||
}
|
||||
parseErr := json.Unmarshal(body, &envelope)
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
// Lark error responses are usually `{code, msg}` envelopes even on
|
||||
// non-2xx — surface them when present so callers see why bot auth
|
||||
// was rejected, not just the bare HTTP code.
|
||||
if parseErr == nil && envelope.Code != 0 {
|
||||
return nil, fmt.Errorf("HTTP %d: [%d] %s", resp.StatusCode, envelope.Code, envelope.Msg)
|
||||
}
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", parseErr)
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
return nil, fmt.Errorf("[%d] %s", envelope.Code, envelope.Msg)
|
||||
}
|
||||
if envelope.Data.OpenID == "" {
|
||||
return nil, errors.New("open_id is empty")
|
||||
}
|
||||
return &botInfo{OpenID: envelope.Data.OpenID, AppName: envelope.Data.AppName}, nil
|
||||
}
|
||||
|
||||
func fillTokenFields(id *Identity, token *larkauth.StoredUAToken) {
|
||||
id.TokenStatus = larkauth.TokenStatus(token)
|
||||
id.Scope = token.Scope
|
||||
id.ExpiresAt = formatMillis(token.ExpiresAt)
|
||||
id.RefreshExpiresAt = formatMillis(token.RefreshExpiresAt)
|
||||
id.GrantedAt = formatMillis(token.GrantedAt)
|
||||
}
|
||||
|
||||
func formatMillis(ms int64) string {
|
||||
if ms <= 0 {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(ms).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func StatusMessage(status string) string {
|
||||
switch status {
|
||||
case StatusNotConfigured:
|
||||
return "not configured"
|
||||
case StatusVerifyFailed:
|
||||
return "verify failed"
|
||||
case StatusNeedsRefresh:
|
||||
return "needs refresh"
|
||||
case StatusMissing:
|
||||
return "missing"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
350
internal/identitydiag/diagnostics_test.go
Normal file
350
internal/identitydiag/diagnostics_test.go
Normal file
@@ -0,0 +1,350 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package identitydiag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func TestDiagnose_NoUserReportsBotReadyAndUserMissing(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.Bot.Status != StatusReady || !got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want ready and available", got.Bot)
|
||||
}
|
||||
if got.User.Status != StatusMissing || got.User.Available {
|
||||
t.Fatalf("user = %#v, want missing and unavailable", got.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_BotIdentityNotConfigured(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.Bot.Status != StatusNotConfigured || got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want not_configured and unavailable", got.Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyBotIdentity(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
stub := &httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot",
|
||||
"app_name": "diagnostic bot",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.Bot.Status != StatusReady || !got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want ready and available", got.Bot)
|
||||
}
|
||||
if got.Bot.Verified == nil || !*got.Bot.Verified {
|
||||
t.Fatalf("bot verified = %v, want true", got.Bot.Verified)
|
||||
}
|
||||
if got.Bot.OpenID != "ou_bot" || got.Bot.AppName != "diagnostic bot" {
|
||||
t.Fatalf("bot info = %#v, want open id and app name", got.Bot)
|
||||
}
|
||||
if got := stub.CapturedHeaders.Get("Authorization"); got != "Bearer test-token" {
|
||||
t.Fatalf("Authorization = %q, want %q", got, "Bearer test-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyUserIdentity(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-user",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_user",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
||||
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
||||
Scope: "offline_access",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot",
|
||||
"app_name": "diagnostic bot",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: larkauth.PathUserInfoV1,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
})
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.User.Status != StatusReady || !got.User.Available {
|
||||
t.Fatalf("user = %#v, want ready and available", got.User)
|
||||
}
|
||||
if got.User.Verified == nil || !*got.User.Verified {
|
||||
t.Fatalf("user verified = %v, want true", got.User.Verified)
|
||||
}
|
||||
if got.User.OpenID != "ou_user" || got.User.UserName != "tester" {
|
||||
t.Fatalf("user = %#v, want user identity details", got.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyBotIdentity_HTTPErrorSurfacesEnvelope(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Status: http.StatusUnauthorized,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991663,
|
||||
"msg": "app ticket invalid",
|
||||
},
|
||||
})
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.Bot.Status != StatusVerifyFailed || got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want verify_failed and unavailable", got.Bot)
|
||||
}
|
||||
if got.Bot.Verified == nil || *got.Bot.Verified {
|
||||
t.Fatalf("bot verified = %v, want false", got.Bot.Verified)
|
||||
}
|
||||
if !strings.Contains(got.Bot.Message, "401") || !strings.Contains(got.Bot.Message, "99991663") {
|
||||
t.Fatalf("bot message = %q, want both HTTP code and envelope code", got.Bot.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyBotIdentity_BusinessErrorCode(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 10013,
|
||||
"msg": "scope not granted",
|
||||
},
|
||||
})
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.Bot.Status != StatusVerifyFailed || got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want verify_failed and unavailable", got.Bot)
|
||||
}
|
||||
if !strings.Contains(got.Bot.Message, "10013") || !strings.Contains(got.Bot.Message, "scope not granted") {
|
||||
t.Fatalf("bot message = %q, want envelope code/msg", got.Bot.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_VerifyUserIdentity_ServerRejects(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-reject",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_user",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
||||
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
||||
Scope: "offline_access",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"bot": map[string]interface{}{"open_id": "ou_bot", "app_name": "bot"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: larkauth.PathUserInfoV1,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991661,
|
||||
"msg": "access token invalid",
|
||||
},
|
||||
})
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, true)
|
||||
if got.User.Status != StatusVerifyFailed || got.User.Available {
|
||||
t.Fatalf("user = %#v, want verify_failed and unavailable", got.User)
|
||||
}
|
||||
if got.User.Verified == nil || *got.User.Verified {
|
||||
t.Fatalf("user verified = %v, want false", got.User.Verified)
|
||||
}
|
||||
if !strings.Contains(got.User.Message, "server rejected token") {
|
||||
t.Fatalf("user message = %q, want 'server rejected token'", got.User.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_UserIdentityExpired(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-expired",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_expired",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(-time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(-time.Minute).UnixMilli(),
|
||||
GrantedAt: now.Add(-24 * time.Hour).UnixMilli(),
|
||||
Scope: "offline_access",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.User.Status != StatusMissing || got.User.Available {
|
||||
t.Fatalf("user = %#v, want missing and unavailable", got.User)
|
||||
}
|
||||
if got.User.Hint == "" {
|
||||
t.Fatalf("user hint is empty, want re-login hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_BotIdentityStrictUserOnly(t *testing.T) {
|
||||
// SupportedIdentities = SupportsUser (1) only — bot path should be
|
||||
// reported as not_configured even though an app secret is present.
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
SupportedIdentities: 1,
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.Bot.Status != StatusNotConfigured || got.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want not_configured and unavailable", got.Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_UserIdentityMissingAppConfig(t *testing.T) {
|
||||
cfg := &core.CliConfig{Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.User.Status != StatusNotConfigured || got.User.Available {
|
||||
t.Fatalf("user = %#v, want not_configured and unavailable", got.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMessage(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
StatusReady: StatusReady,
|
||||
StatusNotConfigured: "not configured",
|
||||
StatusVerifyFailed: "verify failed",
|
||||
StatusNeedsRefresh: "needs refresh",
|
||||
StatusMissing: "missing",
|
||||
"unknown": "unknown",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := StatusMessage(in); got != want {
|
||||
t.Errorf("StatusMessage(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-needs-refresh",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_refresh",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(time.Minute).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
||||
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
||||
Scope: "offline_access",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
got := Diagnose(context.Background(), f, cfg, false)
|
||||
if got.User.Status != StatusNeedsRefresh || !got.User.Available {
|
||||
t.Fatalf("user = %#v, want needs_refresh and available", got.User)
|
||||
}
|
||||
if got.User.TokenStatus != "needs_refresh" {
|
||||
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
|
||||
}
|
||||
}
|
||||
@@ -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": "数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限管理" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.33",
|
||||
"version": "1.0.38",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
55
shortcuts/apps/apps_access_scope_get.go
Normal file
55
shortcuts/apps/apps_access_scope_get.go
Normal 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
|
||||
},
|
||||
}
|
||||
123
shortcuts/apps/apps_access_scope_get_test.go
Normal file
123
shortcuts/apps/apps_access_scope_get_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
208
shortcuts/apps/apps_access_scope_set.go
Normal file
208
shortcuts/apps/apps_access_scope_set.go
Normal 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 / tenant,body 里发后端枚举名。
|
||||
// 后端语义: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
|
||||
}
|
||||
203
shortcuts/apps/apps_access_scope_set_test.go
Normal file
203
shortcuts/apps/apps_access_scope_set_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
79
shortcuts/apps/apps_create.go
Normal file
79
shortcuts/apps/apps_create.go
Normal 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
|
||||
}
|
||||
157
shortcuts/apps/apps_create_test.go
Normal file
157
shortcuts/apps/apps_create_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
192
shortcuts/apps/apps_html_publish.go
Normal file
192
shortcuts/apps/apps_html_publish.go
Normal 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
|
||||
}
|
||||
338
shortcuts/apps/apps_html_publish_test.go
Normal file
338
shortcuts/apps/apps_html_publish_test.go
Normal 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 → 真实 LocalFileIO(cwd-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")
|
||||
}
|
||||
}
|
||||
80
shortcuts/apps/apps_list.go
Normal file
80
shortcuts/apps/apps_list.go
Normal 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
|
||||
}
|
||||
80
shortcuts/apps/apps_list_test.go
Normal file
80
shortcuts/apps/apps_list_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
71
shortcuts/apps/apps_update.go
Normal file
71
shortcuts/apps/apps_update.go
Normal 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
|
||||
}
|
||||
86
shortcuts/apps/apps_update_test.go
Normal file
86
shortcuts/apps/apps_update_test.go
Normal 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
10
shortcuts/apps/common.go
Normal 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"
|
||||
83
shortcuts/apps/html_publish_client.go
Normal file
83
shortcuts/apps/html_publish_client.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
139
shortcuts/apps/html_publish_client_test.go
Normal file
139
shortcuts/apps/html_publish_client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
85
shortcuts/apps/html_publish_tarball.go
Normal file
85
shortcuts/apps/html_publish_tarball.go
Normal 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
|
||||
}
|
||||
193
shortcuts/apps/html_publish_tarball_test.go
Normal file
193
shortcuts/apps/html_publish_tarball_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
47
shortcuts/apps/sensitive_paths.go
Normal file
47
shortcuts/apps/sensitive_paths.go
Normal 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
|
||||
}
|
||||
50
shortcuts/apps/sensitive_paths_test.go
Normal file
50
shortcuts/apps/sensitive_paths_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
shortcuts/apps/shortcuts.go
Normal file
18
shortcuts/apps/shortcuts.go
Normal 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,
|
||||
}
|
||||
}
|
||||
14
shortcuts/apps/shortcuts_test.go
Normal file
14
shortcuts/apps/shortcuts_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
91
shortcuts/apps/walk_html_publish_candidates.go
Normal file
91
shortcuts/apps/walk_html_publish_candidates.go
Normal 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
|
||||
}
|
||||
140
shortcuts/apps/walk_html_publish_candidates_test.go
Normal file
140
shortcuts/apps/walk_html_publish_candidates_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -149,29 +149,26 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_3"]`, `"select_fields":["Status"]`)
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_3"]`)
|
||||
|
||||
uploadAttachmentRT := newBaseTestRuntime(
|
||||
uploadAttachmentRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"record-id": "rec_1",
|
||||
"field-id": "fld_att",
|
||||
"file": "/tmp/report.pdf",
|
||||
"name": "report-final.pdf",
|
||||
},
|
||||
map[string][]string{"file": {"/tmp/report.pdf"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t,
|
||||
BaseRecordUploadAttachment.DryRun(ctx, uploadAttachmentRT),
|
||||
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_att",
|
||||
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
|
||||
"POST /open-apis/drive/v1/medias/upload_all",
|
||||
"bitable_file",
|
||||
"PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
|
||||
"report-final.pdf",
|
||||
`"mime_type":"\u003cdetected_mime_type\u003e"`,
|
||||
`"size":"\u003cfile_size\u003e"`,
|
||||
"deprecated_set_attachment",
|
||||
"POST /open-apis/base/v3/bases/app_x/tables/tbl_1/append_attachments",
|
||||
"report.pdf",
|
||||
`"image_width":"\u003cimage_width_if_image\u003e"`,
|
||||
`"image_height":"\u003cimage_height_if_image\u003e"`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -15,6 +20,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -1589,12 +1595,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Run("upload attachment", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-*.txt")
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-*.png")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if _, err := tmpFile.WriteString("hello attachment"); err != nil {
|
||||
t.Fatalf("WriteString() err=%v", err)
|
||||
img := image.NewRGBA(image.Rect(0, 0, 3, 2))
|
||||
img.Set(0, 0, color.RGBA{R: 255, A: 255})
|
||||
if err := png.Encode(tmpFile, img); err != nil {
|
||||
t.Fatalf("png.Encode() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
@@ -1609,28 +1617,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "existing_tok",
|
||||
"name": "existing.pdf",
|
||||
"size": 2048,
|
||||
"image_width": 640,
|
||||
"image_height": 480,
|
||||
"deprecated_set_attachment": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
@@ -1640,34 +1626,27 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
appendStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "existing_tok",
|
||||
"name": "existing.pdf",
|
||||
"size": 2048,
|
||||
"image_width": 640,
|
||||
"image_height": 480,
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_1",
|
||||
"name": "report.txt",
|
||||
"deprecated_set_attachment": true,
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_1",
|
||||
"name": "base-attachment.png",
|
||||
"size": 73,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
reg.Register(appendStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
@@ -1676,11 +1655,10 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "report.txt",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_1"`) || !strings.Contains(got, `"report.txt"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"file_tok_1"`) || strings.Contains(got, `"updated"`) || strings.Contains(got, `"uploaded"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
|
||||
@@ -1689,19 +1667,13 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("upload body=%s", uploadBody)
|
||||
}
|
||||
|
||||
updateBody := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(updateBody, `"附件"`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"existing_tok"`) ||
|
||||
!strings.Contains(updateBody, `"name":"existing.pdf"`) ||
|
||||
!strings.Contains(updateBody, `"size":2048`) ||
|
||||
!strings.Contains(updateBody, `"image_width":640`) ||
|
||||
!strings.Contains(updateBody, `"image_height":480`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_1"`) ||
|
||||
!strings.Contains(updateBody, `"name":"report.txt"`) ||
|
||||
!strings.Contains(updateBody, `"size":16`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"text/plain"`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
appendBody := string(appendStub.CapturedBody)
|
||||
if !strings.Contains(appendBody, `"rec_x"`) ||
|
||||
!strings.Contains(appendBody, `"fld_att"`) ||
|
||||
!strings.Contains(appendBody, `"file_token":"file_tok_1"`) ||
|
||||
!strings.Contains(appendBody, `"image_width":3`) ||
|
||||
!strings.Contains(appendBody, `"image_height":2`) {
|
||||
t.Fatalf("append body=%s", appendBody)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1728,17 +1700,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -1778,26 +1739,23 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
reg.Register(finishStub)
|
||||
|
||||
updateStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
||||
appendStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id": "rec_x",
|
||||
"fields": map[string]interface{}{
|
||||
"附件": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "file_tok_big",
|
||||
"name": "large-report.bin",
|
||||
"deprecated_set_attachment": true,
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "file_tok_big"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
reg.Register(appendStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
@@ -1806,17 +1764,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "large-report.bin",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_big"`) || !strings.Contains(got, `"large-report.bin"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"file_tok_big"`) || strings.Contains(got, `"updated"`) || strings.Contains(got, `"uploaded"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
|
||||
prepareBody := string(prepareStub.CapturedBody)
|
||||
if !strings.Contains(prepareBody, `"file_name":"large-report.bin"`) ||
|
||||
if !strings.Contains(prepareBody, `"file_name":"`+filepath.Base(tmpFile.Name())+`"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_type":"bitable_file"`) ||
|
||||
!strings.Contains(prepareBody, `"parent_node":"app_x"`) ||
|
||||
!strings.Contains(prepareBody, `"size":20971521`) {
|
||||
@@ -1847,14 +1804,11 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("finish body=%s", finishBody)
|
||||
}
|
||||
|
||||
updateBody := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(updateBody, `"附件"`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
|
||||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
|
||||
!strings.Contains(updateBody, `"size":20971521`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"application/octet-stream"`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
appendBody := string(appendStub.CapturedBody)
|
||||
if !strings.Contains(appendBody, `"rec_x"`) ||
|
||||
!strings.Contains(appendBody, `"fld_att"`) ||
|
||||
!strings.Contains(appendBody, `"file_token":"file_tok_big"`) {
|
||||
t.Fatalf("append body=%s", appendBody)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1928,6 +1882,434 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "base-name-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() err=%v", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf("Close() err=%v", err)
|
||||
}
|
||||
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
||||
|
||||
err = runShortcut(t, BaseRecordUploadAttachment, []string{
|
||||
"+record-upload-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file", "./" + filepath.Base(tmpFile.Name()),
|
||||
"--name", "renamed.txt",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--name is no longer supported") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download attachment includes extra query parameter", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
extra := `{"bitablePerm":{"tableId":"tbl_x","attachments":{"fld_att":{"rec_x":["box_a"]}}}}`
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "box_a",
|
||||
"name": "pic.png",
|
||||
"size": 7,
|
||||
"extra_info": extra,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
downloadStub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download?" + url.Values{"extra": []string{extra}}.Encode(),
|
||||
RawBody: []byte("payload"),
|
||||
ContentType: "image/png",
|
||||
}
|
||||
reg.Register(downloadStub)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--file-token", "box_a",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "pic.png")); err != nil {
|
||||
t.Fatalf("expected downloaded file: %v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 1 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
got, _ := gotItems[0].(map[string]interface{})
|
||||
if got["file_token"] != "box_a" || got["saved_path"] == "" || got["extra_info_used"] != nil {
|
||||
t.Fatalf("download output=%#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download all row attachments when file token omitted", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_b/download",
|
||||
RawBody: []byte("payload-b"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file a.txt: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "b.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file b.txt: %v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 2 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download without file token requires output directory", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
|
||||
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "file.txt",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--output must be an existing directory") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "same.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_a", "name": "same.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "same.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_b/download",
|
||||
RawBody: []byte("payload-b"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "same_box_a.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file same_box_a.txt: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "same_box_b.txt")); err != nil {
|
||||
t.Fatalf("expected downloaded file same_box_b.txt: %v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 2 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download duplicate requested file token only once", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--file-token", "box_a",
|
||||
"--file-token", "box_a",
|
||||
"--output", "a.txt",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
gotItems, _ := data["downloaded"].([]interface{})
|
||||
if len(gotItems) != 1 {
|
||||
t.Fatalf("downloaded=%#v", data["downloaded"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download all preflights local target conflicts before writing", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("downloads", "b.txt"), []byte("existing"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() err=%v", err)
|
||||
}
|
||||
|
||||
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "output file already exists: downloads/b.txt") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err == nil {
|
||||
t.Fatalf("a.txt should not be written after preflight conflict")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download reports progress when later attachment fails", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{
|
||||
"fld_att": []interface{}{
|
||||
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
||||
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_a/download",
|
||||
RawBody: []byte("payload-a"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/box_b/download",
|
||||
Status: 500,
|
||||
RawBody: []byte("server error"),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
if err := os.Mkdir("downloads", 0700); err != nil {
|
||||
t.Fatalf("Mkdir() err=%v", err)
|
||||
}
|
||||
|
||||
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "download failed after 1 attachment(s) succeeded and 1 failed") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured error, got %T %v", err, err)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
downloaded, _ := detail["downloaded"].([]map[string]interface{})
|
||||
failed, _ := detail["failed"].([]map[string]interface{})
|
||||
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
|
||||
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
|
||||
t.Fatalf("expected first file to remain: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remove attachment", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
||||
},
|
||||
})
|
||||
removeStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/remove_attachments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
"rec_x": map[string]interface{}{"fld_att": []interface{}{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(removeStub)
|
||||
|
||||
if err := runShortcut(t, BaseRecordRemoveAttachment, []string{
|
||||
"+record-remove-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--field-id", "fld_att",
|
||||
"--file-token", "box_a",
|
||||
"--file-token", "box_b",
|
||||
"--yes",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, `"removed"`) || strings.Contains(got, `"updated"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(removeStub.CapturedBody)
|
||||
if !strings.Contains(body, `"rec_x"`) ||
|
||||
!strings.Contains(body, `"fld_att"`) ||
|
||||
!strings.Contains(body, `"file_token":"box_a"`) ||
|
||||
!strings.Contains(body, `"file_token":"box_b"`) {
|
||||
t.Fatalf("remove body=%s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
||||
|
||||
44
shortcuts/base/base_form_detail.go
Normal file
44
shortcuts/base/base_form_detail.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseFormDetail = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+form-detail",
|
||||
Description: "Get form detail by share token",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:form:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "share-token", Desc: "Form share token (share_token)", Required: true},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/tables/forms/detail").
|
||||
Body(map[string]interface{}{
|
||||
"share_token": runtime.Str("share-token"),
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body := map[string]interface{}{
|
||||
"share_token": runtime.Str("share-token"),
|
||||
}
|
||||
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
baseV3Path("bases", "tables", "forms", "detail"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
334
shortcuts/base/base_form_submit.go
Normal file
334
shortcuts/base/base_form_submit.go
Normal file
@@ -0,0 +1,334 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
uploadAttachConcurrency = 5
|
||||
)
|
||||
|
||||
var BaseFormSubmit = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+form-submit",
|
||||
Description: "Submit a form (fill and submit form data)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:form:update", "docs:document.media:upload"},
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "share-token", Desc: "Form share token (required), extracted from the form share link", Required: true},
|
||||
{Name: "base-token", Desc: "Base token (required when --json contains attachments, used for uploading attachments to Base Drive Media)"},
|
||||
{Name: "json", Desc: `JSON object containing "fields" (field values) and "attachments" (attachment file paths). Example: '{"fields":{"Rating":5,"Review":"Good"},"attachments":{"Attachment":["./a.pdf","./b.png"]}}'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
|
||||
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
|
||||
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFormSubmit(runtime)
|
||||
},
|
||||
DryRun: dryRunFormSubmit,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeFormSubmit(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func validateFormSubmit(runtime *common.RuntimeContext) error {
|
||||
// 校验 --json 结构:提取 "fields" 和 "attachments"
|
||||
pc := newParseCtx(runtime)
|
||||
raw, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fields, _ := raw["fields"].(map[string]interface{})
|
||||
attachments, hasAttachments := raw["attachments"]
|
||||
|
||||
if !hasAttachments && fields == nil {
|
||||
return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
|
||||
}
|
||||
|
||||
if hasAttachments {
|
||||
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
|
||||
if runtime.Str("base-token") == "" {
|
||||
return common.FlagErrorf("--base-token is required when --json contains \"attachments\"")
|
||||
}
|
||||
|
||||
attMap, ok := attachments.(map[string]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
|
||||
}
|
||||
for fieldName, value := range attMap {
|
||||
paths, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
}
|
||||
for i, item := range paths {
|
||||
if _, ok := item.(string); !ok {
|
||||
return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseFormSubmitJSON 将 --json 解析为字段和附件映射。
|
||||
func parseFormSubmitJSON(runtime *common.RuntimeContext) (map[string]interface{}, map[string][]string, error) {
|
||||
pc := newParseCtx(runtime)
|
||||
raw, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
fields, _ := raw["fields"].(map[string]interface{})
|
||||
if fields == nil {
|
||||
fields = make(map[string]interface{})
|
||||
}
|
||||
|
||||
var attMap map[string][]string
|
||||
if attachments, ok := raw["attachments"]; ok {
|
||||
attObj, ok := attachments.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, nil, common.FlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
|
||||
}
|
||||
if len(attObj) > 0 {
|
||||
attMap = make(map[string][]string, len(attObj))
|
||||
for fieldName, value := range attObj {
|
||||
paths, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return nil, nil, common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
}
|
||||
filePaths := make([]string, 0, len(paths))
|
||||
for _, item := range paths {
|
||||
if s, ok := item.(string); ok {
|
||||
filePaths = append(filePaths, s)
|
||||
} else {
|
||||
return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
|
||||
}
|
||||
}
|
||||
if len(filePaths) > 0 {
|
||||
attMap[fieldName] = filePaths
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields, attMap, nil
|
||||
}
|
||||
|
||||
func dryRunFormSubmit(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
fields, attachmentMap, err := parseFormSubmitJSON(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
||||
}
|
||||
|
||||
if len(attachmentMap) > 0 {
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Form submit with attachments: upload local files per field → merge with fields → submit")
|
||||
|
||||
for fieldName, filePaths := range attachmentMap {
|
||||
for _, p := range filePaths {
|
||||
fileName := filepath.Base(p)
|
||||
dry = dry.POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc(fmt.Sprintf("Upload attachment for field %q: %s", fieldName, fileName)).
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseFormAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"extra": baseFormAttachmentExtra(runtime.Str("share-token")),
|
||||
"file": "@" + p,
|
||||
"size": "<file_size>",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
body := buildFormSubmitBody(runtime, fields)
|
||||
dry = dry.POST("/open-apis/base/v3/bases/tables/forms/submit").
|
||||
Body(body).
|
||||
Desc("Submit form with uploaded attachment tokens merged with fields")
|
||||
return dry
|
||||
}
|
||||
|
||||
body := buildFormSubmitBody(runtime, fields)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/tables/forms/submit").
|
||||
Body(body)
|
||||
}
|
||||
|
||||
func buildFormSubmitBody(runtime *common.RuntimeContext, content map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"share_token": runtime.Str("share-token"),
|
||||
"content": content,
|
||||
}
|
||||
}
|
||||
|
||||
func executeFormSubmit(runtime *common.RuntimeContext) error {
|
||||
fields, attachmentMap, err := parseFormSubmitJSON(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 上传附件并合并到字段中
|
||||
if len(attachmentMap) > 0 {
|
||||
baseToken := runtime.Str("base-token")
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)")
|
||||
}
|
||||
|
||||
// Step 1: 收集所有唯一路径(跨字段去重)
|
||||
allPaths := collectUniquePaths(attachmentMap)
|
||||
if len(allPaths) == 0 {
|
||||
return common.FlagErrorf("attachments in --json contains no valid file paths")
|
||||
}
|
||||
|
||||
// Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
|
||||
sizeMap := make(map[string]int64, len(allPaths))
|
||||
for _, filePath := range allPaths {
|
||||
if _, err := validate.SafeInputPath(filePath); err != nil {
|
||||
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
||||
}
|
||||
return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
|
||||
}
|
||||
if !fileInfo.Mode().IsRegular() {
|
||||
return output.ErrValidation("attachment file %s is not a regular file", filePath)
|
||||
}
|
||||
sizeMap[filePath] = fileInfo.Size()
|
||||
}
|
||||
|
||||
// Step 3: 并行上传,构建路径 → 附件结果映射
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading %d unique attachment(s)...\n", len(allPaths))
|
||||
resultMap, err := uploadAttachmentsParallel(runtime, allPaths, baseFormAttachmentUploadTarget(baseToken, runtime.Str("share-token")), sizeMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4: 根据共享结果映射,按字段组装单元格
|
||||
for fieldName, filePaths := range attachmentMap {
|
||||
cell := make([]interface{}, 0, len(filePaths))
|
||||
for _, p := range filePaths {
|
||||
if att, ok := resultMap[p]; ok {
|
||||
cell = append(cell, att)
|
||||
}
|
||||
}
|
||||
fields[fieldName] = cell
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploaded %d unique file(s) into %d field(s)\n", len(resultMap), len(attachmentMap))
|
||||
}
|
||||
|
||||
body := buildFormSubmitBody(runtime, fields)
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
baseV3Path("bases", "tables", "forms", "submit"),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectUniquePaths 收集所有字段中的文件路径,返回去重后的有序列表。
|
||||
func collectUniquePaths(attachmentMap map[string][]string) []string {
|
||||
seen := make(map[string]bool, len(attachmentMap)*4)
|
||||
var order []string
|
||||
for _, filePaths := range attachmentMap {
|
||||
for _, p := range filePaths {
|
||||
if !seen[p] {
|
||||
seen[p] = true
|
||||
order = append(order, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func baseFormAttachmentUploadTarget(baseToken, shareToken string) baseAttachmentUploadTarget {
|
||||
return baseAttachmentUploadTarget{
|
||||
ParentType: baseFormAttachmentParentType,
|
||||
ParentNode: baseToken,
|
||||
Extra: baseFormAttachmentExtra(shareToken),
|
||||
}
|
||||
}
|
||||
|
||||
func baseFormAttachmentExtra(shareToken string) string {
|
||||
extra, err := json.Marshal(map[string]string{"share_token": shareToken})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(extra)
|
||||
}
|
||||
|
||||
// uploadAttachmentsParallel 并发上传文件,返回路径 → 附件对象的映射。
|
||||
func uploadAttachmentsParallel(runtime *common.RuntimeContext, paths []string, target baseAttachmentUploadTarget, sizeMap map[string]int64) (map[string]interface{}, error) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
resultMap = make(map[string]interface{}, len(paths))
|
||||
)
|
||||
|
||||
g, _ := errgroup.WithContext(runtime.Ctx())
|
||||
g.SetLimit(uploadAttachConcurrency) // 限制并发数
|
||||
|
||||
for _, filePath := range paths {
|
||||
fp := filePath // 捕获循环变量
|
||||
g.Go(func() error {
|
||||
fileName := filepath.Base(fp)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Uploading: %s\n", fileName)
|
||||
|
||||
att, err := uploadSingleAttachment(runtime, fp, fileName, sizeMap[fp], target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
resultMap[fp] = att
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resultMap, nil
|
||||
}
|
||||
|
||||
// uploadSingleAttachment 上传单个文件,返回附件单元格项。
|
||||
// 前置条件:文件已通过校验(存在、常规文件、大小在限制内)。
|
||||
func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) {
|
||||
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
|
||||
}
|
||||
return att, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,27 +8,44 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
baseAttachmentUploadMaxFileSize int64 = 2 * 1024 * 1024 * 1024
|
||||
baseAttachmentParentType = "bitable_file"
|
||||
baseFormAttachmentParentType = "bitable_tmp_point"
|
||||
baseAttachmentMaxBatchSize = 50
|
||||
baseAttachmentGetMaxRecords = 10
|
||||
)
|
||||
|
||||
type baseAttachmentUploadTarget struct {
|
||||
ParentType string
|
||||
ParentNode string
|
||||
Extra string
|
||||
}
|
||||
|
||||
var BaseRecordUploadAttachment = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-upload-attachment",
|
||||
Description: "Upload a local file to a Base attachment field and write it into the target record",
|
||||
Description: "Upload one or more local files and append the returned file_token values to a Base attachment cell",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:record:update", "base:field:read", "docs:document.media:upload"},
|
||||
AuthTypes: authTypes(),
|
||||
@@ -37,34 +54,99 @@ var BaseRecordUploadAttachment = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
fieldRefFlag(true),
|
||||
{Name: "file", Desc: "local file path (max 2GB; files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "name", Desc: "attachment file name (default: local file name)"},
|
||||
{Name: "file", Type: "string_array", Desc: "local file path; repeat to append multiple attachments in one cell; max 50 files, max 2GB each; files > 20MB use multipart upload automatically", Required: true},
|
||||
{Name: "name", Desc: "deprecated; attachment names are derived from local file basenames", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +record-upload-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file ./report.pdf`,
|
||||
`Repeat --file to append multiple attachments: --file ./report.pdf --file ./screenshot.png`,
|
||||
`Reuse returned file_token values for download/remove`,
|
||||
},
|
||||
DryRun: dryRunRecordUploadAttachment,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordUploadAttachment(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordUploadAttachment(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
var BaseRecordDownloadAttachment = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-download-attachment",
|
||||
Description: "Download Base record attachments by record-id, optionally filtering by file-token",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:record:read", "docs:document.media:download"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
{Name: "file-token", Type: "string_array", Desc: "attachment file_token returned by Base; repeat to download selected files; omit to download all attachments in the record", Required: false},
|
||||
{Name: "output", Desc: "local save path; with exactly one file token this may be a file path; with multiple or omitted file tokens this must be an existing directory", Required: true},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +record-download-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --file-token <file_token> --output ./downloads/`,
|
||||
`Omit --file-token to download every attachment in the record.`,
|
||||
`Base attachments should be downloaded with this command; other download commands may fail for Base attachment files.`,
|
||||
`With one --file-token, --output may be a file path or directory; with multiple or omitted --file-token values, --output must be an existing directory.`,
|
||||
},
|
||||
DryRun: dryRunRecordDownloadAttachment,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordDownloadAttachment(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordDownloadAttachment(ctx, runtime)
|
||||
},
|
||||
}
|
||||
|
||||
var BaseRecordRemoveAttachment = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-remove-attachment",
|
||||
Description: "Remove one or more file_token values from a Base record attachment cell",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"base:record:update", "base:field:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
fieldRefFlag(true),
|
||||
{Name: "file-token", Type: "string_array", Desc: "attachment file_token to remove from the target cell; repeat to remove multiple attachments; max 50 tokens", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +record-remove-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file-token <file_token> --yes`,
|
||||
`Repeat --file-token to remove multiple attachments from the same cell in one call.`,
|
||||
`This is a high-risk write command and requires --yes.`,
|
||||
},
|
||||
DryRun: dryRunRecordRemoveAttachment,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordRemoveAttachment(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordRemoveAttachment(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
filePath := runtime.Str("file")
|
||||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||||
if fileName == "" {
|
||||
files := runtime.StrArray("file")
|
||||
filePath := "<file>"
|
||||
fileName := "<local_file_name>"
|
||||
if len(files) > 0 {
|
||||
filePath = files[0]
|
||||
fileName = filepath.Base(filePath)
|
||||
}
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("4-step orchestration: validate attachment field → read existing record attachments → upload file to Base → patch merged attachment array").
|
||||
Desc("3-step orchestration: validate attachment field → upload local file(s) to Base → append uploaded file token(s) to the attachment cell").
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
||||
Desc("[1] Read target field and ensure it is an attachment field").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime)).
|
||||
Set("field_id", runtime.Str("field-id")).
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[2] Read current record to preserve existing attachments in the target cell").
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
Set("field_id", runtime.Str("field-id"))
|
||||
if baseAttachmentShouldUseMultipart(runtime.FileIO(), filePath) {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Desc("[3a] Initialize multipart attachment upload to the current Base").
|
||||
Desc("[2a] Initialize multipart attachment upload to the current Base").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
@@ -72,7 +154,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Desc("[3b] Upload attachment parts (repeated)").
|
||||
Desc("[2b] Upload attachment parts (repeated for each large file)").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
@@ -80,14 +162,14 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Desc("[3c] Finalize multipart attachment upload and get file token").
|
||||
Desc("[2c] Finalize multipart attachment upload and get file token").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
} else {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
|
||||
Desc("[2] Upload local file(s) to the current Base as attachment media (multipart/form-data)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseAttachmentParentType,
|
||||
@@ -97,46 +179,87 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
})
|
||||
}
|
||||
return dry.
|
||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
Desc("[4] Update the target attachment cell with existing attachments plus the uploaded file token").
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/append_attachments").
|
||||
Desc("[3] Append uploaded file token(s) to the target attachment cell").
|
||||
Body(map[string]interface{}{
|
||||
"<attachment_field_name>": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "<existing_file_token>",
|
||||
"name": "<existing_file_name>",
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"file_token": "<uploaded_file_token>",
|
||||
"name": fileName,
|
||||
"mime_type": "<detected_mime_type>",
|
||||
"size": "<file_size>",
|
||||
"deprecated_set_attachment": true,
|
||||
"attachments": map[string]interface{}{
|
||||
runtime.Str("record-id"): map[string]interface{}{
|
||||
runtime.Str("field-id"): []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_token": "<uploaded_file_token>",
|
||||
"image_width": "<image_width_if_image>",
|
||||
"image_height": "<image_height_if_image>",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
filePath := runtime.Str("file")
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return output.ErrValidation("file operations require a FileIO provider")
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
return output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
|
||||
}
|
||||
func dryRunRecordDownloadAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: read Base attachment metadata → download each requested attachment file").
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/get_attachments").
|
||||
Desc("[1] Read attachment metadata for the record").
|
||||
Body(map[string]interface{}{"record_id_list": []string{runtime.Str("record-id")}}).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime)).
|
||||
GET("/open-apis/drive/v1/medias/:file_token/download").
|
||||
Desc("[2] Download attachment media through the Base attachment flow").
|
||||
Set("file_token", "<file_token>").
|
||||
Set("output", runtime.Str("output")).
|
||||
Params(map[string]interface{}{"extra": "<extra_info_if_present>"})
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(filePath)
|
||||
func dryRunRecordRemoveAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), runtime.Str("field-id"), fileTokenPatchItems(runtime.StrArray("file-token")))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/remove_attachments").
|
||||
Desc("Remove attachment file token(s) from the target attachment cell").
|
||||
Body(body).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func validateRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("name") {
|
||||
return common.FlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
|
||||
}
|
||||
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, path := range files {
|
||||
if _, err := validateAttachmentInputFile(runtime, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRecordDownloadAttachment(runtime *common.RuntimeContext) error {
|
||||
tokens, err := normalizeOptionalDownloadAttachmentFileTokens(runtime.StrArray("file-token"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
info, statErr := runtime.FileIO().Stat(runtime.Str("output"))
|
||||
if statErr != nil || !info.IsDir() {
|
||||
return common.FlagErrorf("--output must be an existing directory when downloading multiple attachments or when --file-token is omitted")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRecordRemoveAttachment(runtime *common.RuntimeContext) error {
|
||||
_, err := normalizeAttachmentFileTokens(runtime.StrArray("file-token"))
|
||||
return err
|
||||
}
|
||||
|
||||
func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id"))
|
||||
@@ -146,44 +269,175 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
||||
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
}
|
||||
resolvedFieldID := fieldID(field)
|
||||
if resolvedFieldID == "" {
|
||||
resolvedFieldID = runtime.Str("field-id")
|
||||
}
|
||||
|
||||
record, err := fetchBaseRecord(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("record-id"))
|
||||
appendItems := make([]interface{}, 0, len(files))
|
||||
for _, filePath := range files {
|
||||
fileInfo, err := validateAttachmentInputFile(runtime, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileName := filepath.Base(filePath)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field))
|
||||
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, fileInfo.Size(), baseAttachmentUploadTarget{
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: runtime.Str("base-token"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appendItems = append(appendItems, attachmentAppendItem(attachment))
|
||||
}
|
||||
|
||||
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), resolvedFieldID, appendItems)
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "append_attachments"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field))
|
||||
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
attachments, err := mergeRecordAttachments(record, fieldName(field), attachment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
fieldName(field): attachments,
|
||||
}
|
||||
data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"record": data,
|
||||
"attachment": attachment,
|
||||
"attachments": attachments,
|
||||
"updated": true,
|
||||
}, nil)
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordRemoveAttachment(runtime *common.RuntimeContext) error {
|
||||
tokens, err := normalizeAttachmentFileTokens(runtime.StrArray("file-token"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
||||
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
}
|
||||
resolvedFieldID := fieldID(field)
|
||||
if resolvedFieldID == "" {
|
||||
resolvedFieldID = runtime.Str("field-id")
|
||||
}
|
||||
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), resolvedFieldID, fileTokenPatchItems(tokens))
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "remove_attachments"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRecordDownloadAttachment(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tokens, err := normalizeOptionalDownloadAttachmentFileTokens(runtime.StrArray("file-token"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachments, err := fetchBaseAttachments(runtime, runtime.Str("base-token"), baseTableID(runtime), []string{runtime.Str("record-id")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := selectAttachmentDownloadItems(attachments, runtime.Str("record-id"), tokens)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := planAttachmentDownloadTargets(runtime, items, runtime.Str("output"), len(tokens) != 1 || len(items) > 1, runtime.Bool("overwrite"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
downloaded := make([]map[string]interface{}, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
saved, err := downloadBaseAttachment(ctx, runtime, target.Item, target.TargetPath, runtime.Bool("overwrite"))
|
||||
if err != nil {
|
||||
failed := attachmentDownloadFailure(target, err)
|
||||
return attachmentDownloadProgressError(err, downloaded, []map[string]interface{}{failed})
|
||||
}
|
||||
downloaded = append(downloaded, saved)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"downloaded": downloaded}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAttachmentInputFile(runtime *common.RuntimeContext, filePath string) (fileio.FileInfo, error) {
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return nil, output.ErrValidation("file operations require a FileIO provider")
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return nil, output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
return nil, output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return nil, output.ErrValidation("file path is a directory: %s", filePath)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return nil, output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
|
||||
}
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
func normalizeAttachmentFiles(files []string) ([]string, error) {
|
||||
return normalizeStringList(files, stringListNormalizeOptions{
|
||||
typeError: "attachment files must be a string array",
|
||||
emptyError: "provide at least one --file",
|
||||
itemName: "attachment file",
|
||||
duplicateName: "attachment file",
|
||||
limitName: "attachment file count",
|
||||
max: baseAttachmentMaxBatchSize,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeAttachmentFileTokens(tokens []string) ([]string, error) {
|
||||
return normalizeStringList(tokens, stringListNormalizeOptions{
|
||||
typeError: "attachment file tokens must be a string array",
|
||||
emptyError: "provide at least one --file-token",
|
||||
itemName: "attachment file token",
|
||||
duplicateName: "attachment file token",
|
||||
limitName: "attachment file token count",
|
||||
max: baseAttachmentMaxBatchSize,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeOptionalDownloadAttachmentFileTokens(tokens []string) ([]string, error) {
|
||||
if len(tokens) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
normalized := make([]string, 0, len(tokens))
|
||||
for index, token := range tokens {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, common.FlagErrorf("attachment file token %d must not be empty", index+1)
|
||||
}
|
||||
normalized = append(normalized, token)
|
||||
}
|
||||
normalized = dedupeStringsPreserveOrder(normalized)
|
||||
if len(normalized) > baseAttachmentMaxBatchSize {
|
||||
return nil, common.FlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func dedupeStringsPreserveOrder(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func baseAttachmentShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
|
||||
if fio == nil {
|
||||
return false
|
||||
}
|
||||
info, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -195,84 +449,53 @@ func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fie
|
||||
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil)
|
||||
}
|
||||
|
||||
func fetchBaseRecord(runtime *common.RuntimeContext, baseToken, tableIDValue, recordID string) (map[string]interface{}, error) {
|
||||
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "records", recordID), nil, nil)
|
||||
func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValue string, recordIDs []string) (map[string]interface{}, error) {
|
||||
if len(recordIDs) == 0 {
|
||||
return nil, output.ErrValidation("provide at least one record id")
|
||||
}
|
||||
if len(recordIDs) > baseAttachmentGetMaxRecords {
|
||||
return nil, output.ErrValidation("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "get_attachments"), nil, map[string]interface{}{
|
||||
"record_id_list": recordIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachments, _ := data["attachments"].(map[string]interface{})
|
||||
if attachments == nil {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
return attachments, nil
|
||||
}
|
||||
|
||||
func mergeRecordAttachments(record map[string]interface{}, fieldName string, uploaded map[string]interface{}) ([]interface{}, error) {
|
||||
fields, _ := record["fields"].(map[string]interface{})
|
||||
if fields == nil {
|
||||
return []interface{}{uploaded}, nil
|
||||
}
|
||||
current, exists := fields[fieldName]
|
||||
if !exists || util.IsNil(current) {
|
||||
return []interface{}{uploaded}, nil
|
||||
}
|
||||
items, ok := current.([]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record field %q has unexpected attachment payload type %T", fieldName, current)
|
||||
}
|
||||
merged := make([]interface{}, 0, len(items)+1)
|
||||
for _, item := range items {
|
||||
attachment, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record field %q contains unexpected attachment item type %T", fieldName, item)
|
||||
}
|
||||
merged = append(merged, normalizeAttachmentForPatch(attachment))
|
||||
}
|
||||
merged = append(merged, uploaded)
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]interface{} {
|
||||
normalized := map[string]interface{}{}
|
||||
if fileToken, _ := attachment["file_token"].(string); fileToken != "" {
|
||||
normalized["file_token"] = fileToken
|
||||
}
|
||||
if name, _ := attachment["name"].(string); name != "" {
|
||||
normalized["name"] = name
|
||||
}
|
||||
if mimeType, _ := attachment["mime_type"].(string); mimeType != "" {
|
||||
normalized["mime_type"] = mimeType
|
||||
}
|
||||
if size, ok := attachment["size"]; ok && !util.IsNil(size) {
|
||||
normalized["size"] = size
|
||||
}
|
||||
if imageWidth, ok := attachment["image_width"]; ok && !util.IsNil(imageWidth) {
|
||||
normalized["image_width"] = imageWidth
|
||||
}
|
||||
if imageHeight, ok := attachment["image_height"]; ok && !util.IsNil(imageHeight) {
|
||||
normalized["image_height"] = imageHeight
|
||||
}
|
||||
normalized["deprecated_set_attachment"] = true
|
||||
return normalized
|
||||
}
|
||||
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (map[string]interface{}, error) {
|
||||
mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentNode := baseToken
|
||||
var (
|
||||
fileToken string
|
||||
)
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
parentNode := target.ParentNode
|
||||
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentType: target.ParentType,
|
||||
ParentNode: &parentNode,
|
||||
Extra: target.Extra,
|
||||
})
|
||||
} else {
|
||||
fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: parentNode,
|
||||
ParentType: target.ParentType,
|
||||
ParentNode: target.ParentNode,
|
||||
Extra: target.Extra,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
@@ -280,15 +503,51 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName,
|
||||
}
|
||||
|
||||
attachment := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"name": fileName,
|
||||
"mime_type": mimeType,
|
||||
"size": fileSize,
|
||||
"deprecated_set_attachment": true,
|
||||
"file_token": fileToken,
|
||||
"name": fileName,
|
||||
"mime_type": mimeType,
|
||||
"size": fileSize,
|
||||
}
|
||||
if width, height, ok := detectAttachmentImageDimensions(runtime.FileIO(), filePath, mimeType); ok {
|
||||
attachment["image_width"] = width
|
||||
attachment["image_height"] = height
|
||||
} else if attachmentImageDimensionsWarningEnabled(mimeType) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Warning: image dimensions unavailable for %s; attachment may display as square\n", fileName)
|
||||
}
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
func attachmentAppendItem(attachment map[string]interface{}) map[string]interface{} {
|
||||
item := map[string]interface{}{
|
||||
"file_token": attachment["file_token"],
|
||||
}
|
||||
if width, ok := attachment["image_width"]; ok && !util.IsNil(width) {
|
||||
item["image_width"] = width
|
||||
}
|
||||
if height, ok := attachment["image_height"]; ok && !util.IsNil(height) {
|
||||
item["image_height"] = height
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func fileTokenPatchItems(tokens []string) []interface{} {
|
||||
items := make([]interface{}, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
items = append(items, map[string]interface{}{"file_token": token})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func buildSingleCellAttachmentsBody(recordID, fieldID string, items []interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"attachments": map[string]interface{}{
|
||||
recordID: map[string]interface{}{
|
||||
fieldID: items,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (string, error) {
|
||||
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName)))); byExt != "" {
|
||||
return stripMIMEParams(byExt), nil
|
||||
@@ -311,6 +570,309 @@ func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (str
|
||||
return detectAttachmentMIMEFromContent(buf[:n]), nil
|
||||
}
|
||||
|
||||
func detectAttachmentImageDimensions(fio fileio.FileIO, filePath string, mimeType string) (int, int, bool) {
|
||||
if fio == nil || !strings.HasPrefix(mimeType, "image/") {
|
||||
return 0, 0, false
|
||||
}
|
||||
f, err := fio.Open(filePath)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
defer f.Close()
|
||||
cfg, _, err := image.DecodeConfig(f)
|
||||
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return cfg.Width, cfg.Height, true
|
||||
}
|
||||
|
||||
func attachmentImageDimensionsWarningEnabled(mimeType string) bool {
|
||||
switch mimeType {
|
||||
case "image/gif", "image/jpeg", "image/png":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type baseAttachmentDownloadItem struct {
|
||||
RecordID string
|
||||
FieldID string
|
||||
FileToken string
|
||||
Name string
|
||||
Size interface{}
|
||||
ExtraInfo string
|
||||
MimeType string
|
||||
RawPayload map[string]interface{}
|
||||
}
|
||||
|
||||
type baseAttachmentDownloadTarget struct {
|
||||
Item baseAttachmentDownloadItem
|
||||
TargetPath string
|
||||
ResolvedPath string
|
||||
}
|
||||
|
||||
func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID string, tokens []string) ([]baseAttachmentDownloadItem, error) {
|
||||
recordRaw, ok := attachments[recordID]
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q has no attachment metadata; verify the record-id", recordID)
|
||||
}
|
||||
fields, ok := recordRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
|
||||
}
|
||||
byToken := map[string]baseAttachmentDownloadItem{}
|
||||
fieldIDs := make([]string, 0, len(fields))
|
||||
for currentFieldID := range fields {
|
||||
fieldIDs = append(fieldIDs, currentFieldID)
|
||||
}
|
||||
sort.Strings(fieldIDs)
|
||||
for _, currentFieldID := range fieldIDs {
|
||||
rawList := fields[currentFieldID]
|
||||
items, ok := rawList.([]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
|
||||
}
|
||||
for _, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
|
||||
}
|
||||
fileToken, _ := item["file_token"].(string)
|
||||
if fileToken == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := byToken[fileToken]; exists {
|
||||
continue
|
||||
}
|
||||
name, _ := item["name"].(string)
|
||||
extraInfo, _ := item["extra_info"].(string)
|
||||
mimeType, _ := item["mime_type"].(string)
|
||||
byToken[fileToken] = baseAttachmentDownloadItem{
|
||||
RecordID: recordID,
|
||||
FieldID: currentFieldID,
|
||||
FileToken: fileToken,
|
||||
Name: name,
|
||||
Size: item["size"],
|
||||
ExtraInfo: extraInfo,
|
||||
MimeType: mimeType,
|
||||
RawPayload: item,
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make([]baseAttachmentDownloadItem, 0, len(tokens))
|
||||
if len(tokens) == 0 {
|
||||
for _, item := range byToken {
|
||||
result = append(result, item)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, output.ErrValidation("record %q has no attachments to download", recordID)
|
||||
}
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
leftName := strings.ToLower(baseAttachmentDownloadName(result[i]))
|
||||
rightName := strings.ToLower(baseAttachmentDownloadName(result[j]))
|
||||
if leftName != rightName {
|
||||
return leftName < rightName
|
||||
}
|
||||
return result[i].FileToken < result[j].FileToken
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
for _, token := range tokens {
|
||||
item, ok := byToken[token]
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func planAttachmentDownloadTargets(runtime *common.RuntimeContext, items []baseAttachmentDownloadItem, outputPath string, outputIsDir bool, overwrite bool) ([]baseAttachmentDownloadTarget, error) {
|
||||
names := downloadTargetNames(items, outputIsDir || outputPathLooksDirectory(runtime, outputPath))
|
||||
targets := make([]baseAttachmentDownloadTarget, 0, len(items))
|
||||
seen := map[string]baseAttachmentDownloadItem{}
|
||||
for _, item := range items {
|
||||
targetName := names[item.FileToken]
|
||||
targetPath := outputPath
|
||||
if targetName != "" {
|
||||
targetPath = filepath.Join(outputPath, targetName)
|
||||
}
|
||||
resolved, err := runtime.ResolveSavePath(targetPath)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if previous, exists := seen[resolved]; exists {
|
||||
return nil, output.ErrValidation("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
|
||||
}
|
||||
seen[resolved] = item
|
||||
if !overwrite {
|
||||
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||
}
|
||||
}
|
||||
targets = append(targets, baseAttachmentDownloadTarget{
|
||||
Item: item,
|
||||
TargetPath: targetPath,
|
||||
ResolvedPath: resolved,
|
||||
})
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func downloadTargetNames(items []baseAttachmentDownloadItem, outputIsDir bool) map[string]string {
|
||||
if !outputIsDir {
|
||||
return nil
|
||||
}
|
||||
nameCounts := make(map[string]int, len(items))
|
||||
for _, item := range items {
|
||||
nameCounts[baseAttachmentDownloadName(item)]++
|
||||
}
|
||||
names := make(map[string]string, len(items))
|
||||
for _, item := range items {
|
||||
name := baseAttachmentDownloadName(item)
|
||||
if nameCounts[name] > 1 {
|
||||
name = attachmentNameWithTokenSuffix(name, item.FileToken)
|
||||
}
|
||||
names[item.FileToken] = name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func baseAttachmentDownloadName(item baseAttachmentDownloadItem) string {
|
||||
name := filepath.Base(strings.TrimSpace(item.Name))
|
||||
if name == "" || name == "." || name == string(filepath.Separator) {
|
||||
name = item.FileToken
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func attachmentNameWithTokenSuffix(name, fileToken string) string {
|
||||
ext := filepath.Ext(name)
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
if stem == "" {
|
||||
stem = name
|
||||
}
|
||||
return stem + "_" + safeAttachmentFileTokenSuffix(fileToken) + ext
|
||||
}
|
||||
|
||||
func safeAttachmentFileTokenSuffix(fileToken string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range fileToken {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
b.WriteByte('_')
|
||||
}
|
||||
suffix := strings.Trim(b.String(), "_")
|
||||
if suffix == "" {
|
||||
return "file"
|
||||
}
|
||||
return suffix
|
||||
}
|
||||
|
||||
func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, item baseAttachmentDownloadItem, targetPath string, overwrite bool) (map[string]interface{}, error) {
|
||||
if _, err := runtime.ResolveSavePath(targetPath); err != nil {
|
||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
query := larkcore.QueryParams{}
|
||||
if item.ExtraInfo != "" {
|
||||
query.Set("extra", item.ExtraInfo)
|
||||
}
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/download", validate.EncodePathSegment(item.FileToken)),
|
||||
QueryParams: query,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork("download failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if !overwrite {
|
||||
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||
}
|
||||
}
|
||||
result, err := runtime.FileIO().Save(targetPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return nil, common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
savedPath, _ := runtime.ResolveSavePath(targetPath)
|
||||
if savedPath == "" {
|
||||
savedPath = targetPath
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"record_id": item.RecordID,
|
||||
"field_id": item.FieldID,
|
||||
"file_token": item.FileToken,
|
||||
"name": item.Name,
|
||||
"size": item.Size,
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": result.Size(),
|
||||
"content_type": resp.Header.Get("Content-Type"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"record_id": target.Item.RecordID,
|
||||
"field_id": target.Item.FieldID,
|
||||
"file_token": target.Item.FileToken,
|
||||
"name": target.Item.Name,
|
||||
"target_path": target.TargetPath,
|
||||
"resolved_path": target.ResolvedPath,
|
||||
"error": err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
func attachmentDownloadProgressError(err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
|
||||
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: msg,
|
||||
Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.",
|
||||
Detail: map[string]interface{}{
|
||||
"downloaded": downloaded,
|
||||
"failed": failed,
|
||||
},
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitInternal,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "io",
|
||||
Message: msg,
|
||||
Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.",
|
||||
Detail: map[string]interface{}{
|
||||
"downloaded": downloaded,
|
||||
"failed": failed,
|
||||
},
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func outputPathLooksDirectory(runtime *common.RuntimeContext, outputPath string) bool {
|
||||
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, string(filepath.Separator)) {
|
||||
return true
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(outputPath)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func stripMIMEParams(value string) string {
|
||||
if i := strings.IndexByte(value, ';'); i != -1 {
|
||||
value = value[:i]
|
||||
|
||||
@@ -5,6 +5,9 @@ package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
@@ -82,6 +85,42 @@ func TestDetectAttachmentMIMETypeFallsBackToContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentImageDimensions(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 3))
|
||||
img.Set(0, 0, color.RGBA{G: 255, A: 255})
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("png.Encode() error = %v", err)
|
||||
}
|
||||
fio := attachmentTestFileIO{openFile: newAttachmentTestFile(buf.Bytes())}
|
||||
|
||||
width, height, ok := detectAttachmentImageDimensions(fio, "image.png", "image/png")
|
||||
if !ok || width != 4 || height != 3 {
|
||||
t.Fatalf("detectAttachmentImageDimensions() = (%d,%d,%v), want (4,3,true)", width, height, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentImageDimensionsWarningEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
mimeType string
|
||||
want bool
|
||||
}{
|
||||
{mimeType: "image/gif", want: true},
|
||||
{mimeType: "image/jpeg", want: true},
|
||||
{mimeType: "image/png", want: true},
|
||||
{mimeType: "image/webp", want: false},
|
||||
{mimeType: "application/pdf", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.mimeType, func(t *testing.T) {
|
||||
if got := attachmentImageDimensionsWarningEnabled(tt.mimeType); got != tt.want {
|
||||
t.Fatalf("attachmentImageDimensionsWarningEnabled(%q) = %v, want %v", tt.mimeType, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeWrapsOpenError(t *testing.T) {
|
||||
fio := attachmentTestFileIO{openErr: os.ErrNotExist}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseRecordBatchUpdate,
|
||||
BaseRecordShareLinkCreate,
|
||||
BaseRecordUploadAttachment,
|
||||
BaseRecordDownloadAttachment,
|
||||
BaseRecordRemoveAttachment,
|
||||
BaseRecordDelete,
|
||||
BaseRecordHistoryList,
|
||||
BaseBaseGet,
|
||||
@@ -68,10 +70,12 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseFormsList,
|
||||
BaseFormUpdate,
|
||||
BaseFormGet,
|
||||
BaseFormDetail,
|
||||
BaseFormQuestionsCreate,
|
||||
BaseFormQuestionsDelete,
|
||||
BaseFormQuestionsUpdate,
|
||||
BaseFormQuestionsList,
|
||||
BaseFormSubmit,
|
||||
BaseDashboardList,
|
||||
BaseDashboardGet,
|
||||
BaseDashboardCreate,
|
||||
|
||||
63
shortcuts/common/drive_meta.go
Normal file
63
shortcuts/common/drive_meta.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
// DriveMeta is the subset of drive metas/batch_query fields used by shortcuts.
|
||||
type DriveMeta struct {
|
||||
Title string
|
||||
URL string
|
||||
}
|
||||
|
||||
// FetchDriveMeta looks up document metadata via the drive metas batch_query API.
|
||||
func FetchDriveMeta(runtime *RuntimeContext, token, docType string, withURL bool) (DriveMeta, error) {
|
||||
body := map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": token,
|
||||
"doc_type": docType,
|
||||
},
|
||||
},
|
||||
}
|
||||
if withURL {
|
||||
body["with_url"] = true
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return DriveMeta{}, err
|
||||
}
|
||||
|
||||
metas := GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return DriveMeta{}, nil
|
||||
}
|
||||
meta, _ := metas[0].(map[string]interface{})
|
||||
return DriveMeta{
|
||||
Title: GetString(meta, "title"),
|
||||
URL: GetString(meta, "url"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FetchDriveMetaTitle looks up the document title via the drive metas batch_query API.
|
||||
func FetchDriveMetaTitle(runtime *RuntimeContext, token, docType string) (string, error) {
|
||||
meta, err := FetchDriveMeta(runtime, token, docType, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return meta.Title, nil
|
||||
}
|
||||
|
||||
// FetchDriveMetaURL looks up the document access URL via the drive metas batch_query API.
|
||||
func FetchDriveMetaURL(runtime *RuntimeContext, token, docType string) (string, error) {
|
||||
meta, err := FetchDriveMeta(runtime, token, docType, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return meta.URL, nil
|
||||
}
|
||||
162
shortcuts/common/drive_meta_test.go
Normal file
162
shortcuts/common/drive_meta_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
var driveMetaTestSeq atomic.Int64
|
||||
|
||||
func TestFetchDriveMetaTitle(t *testing.T) {
|
||||
t.Run("returns title from batch_query response", func(t *testing.T) {
|
||||
runtime, reg := newDriveMetaTestRuntime(t)
|
||||
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": "doxcnABC", "doc_type": "docx", "title": "My Document"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
title, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchDriveMetaTitle() error: %v", err)
|
||||
}
|
||||
if title != "My Document" {
|
||||
t.Errorf("title = %q, want %q", title, "My Document")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns empty string when metas is empty", func(t *testing.T) {
|
||||
runtime, reg := newDriveMetaTestRuntime(t)
|
||||
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{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
title, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchDriveMetaTitle() error: %v", err)
|
||||
}
|
||||
if title != "" {
|
||||
t.Errorf("title = %q, want empty string", title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns empty string when meta has no title", func(t *testing.T) {
|
||||
runtime, reg := newDriveMetaTestRuntime(t)
|
||||
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": "doxcnABC", "doc_type": "docx"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
title, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchDriveMetaTitle() error: %v", err)
|
||||
}
|
||||
if title != "" {
|
||||
t.Errorf("title = %q, want empty string", title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("propagates API error", func(t *testing.T) {
|
||||
runtime, reg := newDriveMetaTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991668,
|
||||
"msg": "permission denied",
|
||||
},
|
||||
})
|
||||
|
||||
_, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
|
||||
if err == nil {
|
||||
t.Fatal("FetchDriveMetaTitle() expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFetchDriveMetaURL(t *testing.T) {
|
||||
runtime, reg := newDriveMetaTestRuntime(t)
|
||||
stub := &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": "boxcnABC",
|
||||
"doc_type": "file",
|
||||
"title": "report.pdf",
|
||||
"url": "https://tenant.example.com/file/boxcnABC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
got, err := FetchDriveMetaURL(runtime, "boxcnABC", "file")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchDriveMetaURL() error: %v", err)
|
||||
}
|
||||
if got != "https://tenant.example.com/file/boxcnABC" {
|
||||
t.Fatalf("url = %q, want tenant URL", got)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode captured body: %v", err)
|
||||
}
|
||||
if body["with_url"] != true {
|
||||
t.Fatalf("with_url = %#v, want true", body["with_url"])
|
||||
}
|
||||
}
|
||||
|
||||
func newDriveMetaTestRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: fmt.Sprintf("drive-meta-test-%d", driveMetaTestSeq.Add(1)), AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
runtime := &RuntimeContext{
|
||||
ctx: context.Background(),
|
||||
Config: cfg,
|
||||
Factory: f,
|
||||
resolvedAs: core.AsBot,
|
||||
}
|
||||
return runtime, reg
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
96
shortcuts/common/permission_grant_test.go
Normal file
96
shortcuts/common/permission_grant_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -55,3 +56,79 @@ func BuildResourceURL(brand core.LarkBrand, kind, token string) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ResourceRef holds the parsed type and token from a Lark resource URL.
|
||||
type ResourceRef struct {
|
||||
Type string // e.g. "docx", "bitable", "wiki", "sheet", etc.
|
||||
Token string // the token extracted from the URL path
|
||||
}
|
||||
|
||||
// urlPathToType maps URL path prefixes to resource types.
|
||||
// Longer prefixes must come first to avoid false matches
|
||||
// (e.g. "/drive/folder/" before a hypothetical "/drive/").
|
||||
// Aliases (e.g. "/bitable/" → "bitable") must come after the
|
||||
// canonical prefix to keep the list deterministic.
|
||||
var urlPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnote/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
}
|
||||
|
||||
// ParseResourceURL parses a Lark/Feishu URL and extracts the resource type
|
||||
// and token from the URL path. It is the inverse of BuildResourceURL.
|
||||
//
|
||||
// Supported path patterns:
|
||||
//
|
||||
// /docx/TOKEN -> {Type: "docx", Token: TOKEN}
|
||||
// /doc/TOKEN -> {Type: "doc", Token: TOKEN}
|
||||
// /sheets/TOKEN -> {Type: "sheet", Token: TOKEN}
|
||||
// /base/TOKEN -> {Type: "bitable", Token: TOKEN}
|
||||
// /wiki/TOKEN -> {Type: "wiki", Token: TOKEN}
|
||||
// /file/TOKEN -> {Type: "file", Token: TOKEN}
|
||||
// /drive/folder/TOKEN -> {Type: "folder", Token: TOKEN}
|
||||
// /mindnote/TOKEN -> {Type: "mindnote", Token: TOKEN}
|
||||
// /slides/TOKEN -> {Type: "slides", Token: TOKEN}
|
||||
//
|
||||
// Returns (ResourceRef{}, false) when the URL does not match any known pattern.
|
||||
func ParseResourceURL(rawURL string) (ResourceRef, bool) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return ResourceRef{}, false
|
||||
}
|
||||
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ResourceRef{}, false
|
||||
}
|
||||
|
||||
path := u.Path
|
||||
|
||||
for _, mapping := range urlPathToType {
|
||||
if !strings.HasPrefix(path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := path[len(mapping.Prefix):]
|
||||
// Trim trailing slashes and stop at the next path segment boundary.
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return ResourceRef{}, false
|
||||
}
|
||||
return ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
|
||||
return ResourceRef{}, false
|
||||
}
|
||||
|
||||
@@ -9,6 +9,102 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestParseResourceURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rawURL string
|
||||
wantType string
|
||||
wantToken string
|
||||
wantOK bool
|
||||
}{
|
||||
// All 9 supported types
|
||||
{"docx", "https://xxx.feishu.cn/docx/doxcnABC", "docx", "doxcnABC", true},
|
||||
{"doc", "https://xxx.feishu.cn/doc/doccnABC", "doc", "doccnABC", true},
|
||||
{"sheet", "https://xxx.feishu.cn/sheets/shtcnABC", "sheet", "shtcnABC", true},
|
||||
{"bitable via /base/", "https://xxx.feishu.cn/base/bascnABC", "bitable", "bascnABC", true},
|
||||
{"bitable via /bitable/", "https://xxx.feishu.cn/bitable/bascnABC", "bitable", "bascnABC", true},
|
||||
{"wiki", "https://xxx.feishu.cn/wiki/wikcnABC", "wiki", "wikcnABC", true},
|
||||
{"file", "https://xxx.feishu.cn/file/boxcnABC", "file", "boxcnABC", true},
|
||||
{"folder", "https://xxx.feishu.cn/drive/folder/fldcnABC", "folder", "fldcnABC", true},
|
||||
{"mindnote", "https://xxx.feishu.cn/mindnote/mncnABC", "mindnote", "mncnABC", true},
|
||||
{"slides", "https://xxx.feishu.cn/slides/slkcnABC", "slides", "slkcnABC", true},
|
||||
|
||||
// Lark domain
|
||||
{"lark docx", "https://xxx.larksuite.com/docx/doxcnABC", "docx", "doxcnABC", true},
|
||||
{"lark wiki", "https://xxx.larksuite.com/wiki/wikcnABC", "wiki", "wikcnABC", true},
|
||||
|
||||
// With query parameters
|
||||
{"with query", "https://xxx.feishu.cn/docx/doxcnABC?from=wiki", "docx", "doxcnABC", true},
|
||||
{"with fragment", "https://xxx.feishu.cn/docx/doxcnABC#section", "docx", "doxcnABC", true},
|
||||
|
||||
// With trailing slash
|
||||
{"trailing slash", "https://xxx.feishu.cn/docx/doxcnABC/", "docx", "doxcnABC", true},
|
||||
|
||||
// With extra path segments after token
|
||||
{"extra path", "https://xxx.feishu.cn/docx/doxcnABC/edit", "docx", "doxcnABC", true},
|
||||
|
||||
// Non-Lark host with Lark-like path (host validation is the caller's responsibility)
|
||||
{"non-lark host with lark path", "https://google.com/docx/doxcnABC", "docx", "doxcnABC", true},
|
||||
|
||||
// Negative cases
|
||||
{"unrecognized path", "https://xxx.feishu.cn/calendar/calABC", "", "", false},
|
||||
{"non-lark host unrecognized path", "https://example.com/page", "", "", false},
|
||||
{"empty input", "", "", "", false},
|
||||
{"bare token", "doxcnABC", "", "", false},
|
||||
{"invalid url parse", "://not-a-valid-url", "", "", false},
|
||||
{"matching prefix but empty token", "https://xxx.feishu.cn/docx/", "", "", false},
|
||||
{"matching prefix but whitespace-only token", "https://xxx.feishu.cn/docx/ ", "", "", false},
|
||||
{"whitespace-only input", " ", "", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ref, ok := ParseResourceURL(tt.rawURL)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ParseResourceURL(%q) ok = %v, want %v", tt.rawURL, ok, tt.wantOK)
|
||||
}
|
||||
if ok {
|
||||
if ref.Type != tt.wantType {
|
||||
t.Errorf("ParseResourceURL(%q) Type = %q, want %q", tt.rawURL, ref.Type, tt.wantType)
|
||||
}
|
||||
if ref.Token != tt.wantToken {
|
||||
t.Errorf("ParseResourceURL(%q) Token = %q, want %q", tt.rawURL, ref.Token, tt.wantToken)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseResourceURL_RoundTrip verifies that ParseResourceURL is the inverse
|
||||
// of BuildResourceURL for all supported types.
|
||||
func TestParseResourceURL_RoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
types := []string{"docx", "doc", "sheet", "bitable", "wiki", "file", "folder", "mindnote", "slides"}
|
||||
token := "testTOKEN123"
|
||||
|
||||
for _, kind := range types {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
built := BuildResourceURL(core.BrandFeishu, kind, token)
|
||||
if built == "" {
|
||||
t.Fatalf("BuildResourceURL returned empty for kind %q", kind)
|
||||
}
|
||||
ref, ok := ParseResourceURL(built)
|
||||
if !ok {
|
||||
t.Fatalf("ParseResourceURL(%q) returned ok=false", built)
|
||||
}
|
||||
if ref.Type != kind {
|
||||
t.Errorf("round-trip type mismatch: got %q, want %q", ref.Type, kind)
|
||||
}
|
||||
if ref.Token != token {
|
||||
t.Errorf("round-trip token mismatch: got %q, want %q", ref.Token, token)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResourceURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -103,13 +103,15 @@ func (ctx *RuntimeContext) fetchBotInfo() (*BotInfo, error) {
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("fetch bot info: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
// /open-apis/bot/v3/info returns `{code, msg, bot: {...}}` — the bot
|
||||
// payload is under "bot", not "data" as the newer Lark API convention.
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
OpenID string `json:"open_id"`
|
||||
AppName string `json:"app_name"`
|
||||
} `json:"data"`
|
||||
} `json:"bot"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.RawBody, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("fetch bot info: unmarshal: %w", err)
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestFetchBotInfo_Success(t *testing.T) {
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot_abc123",
|
||||
"app_name": "TestBot",
|
||||
},
|
||||
@@ -86,7 +86,7 @@ func TestFetchBotInfo_ShortcutHeaders(t *testing.T) {
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot_header",
|
||||
"app_name": "HeaderBot",
|
||||
},
|
||||
@@ -119,7 +119,7 @@ func TestFetchBotInfo_OnceSemantics(t *testing.T) {
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot_once",
|
||||
"app_name": "OnceBot",
|
||||
},
|
||||
@@ -183,7 +183,7 @@ func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "",
|
||||
"app_name": "EmptyBot",
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ package doc
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -118,7 +119,7 @@ func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
if needsSelectionV1[mode] && selEllipsis == "" && selTitle == "" {
|
||||
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
|
||||
return common.FlagErrorf(selectionRequiredMessageV1(mode))
|
||||
}
|
||||
if err := validateSelectionByTitleV1(selTitle); err != nil {
|
||||
return err
|
||||
@@ -127,6 +128,14 @@ func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectionRequiredMessageV1(mode string) string {
|
||||
msg := fmt.Sprintf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
|
||||
if mode == "replace_all" {
|
||||
msg += ". If you intended to replace the entire document body, use --mode overwrite instead."
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func validateSelectionByTitleV1(title string) error {
|
||||
if title == "" {
|
||||
return nil
|
||||
@@ -160,6 +169,16 @@ func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
|
||||
}
|
||||
|
||||
// Overwrite replaces the entire document, silently discarding any
|
||||
// whiteboard or file-attachment blocks that cannot be re-created from
|
||||
// Markdown. Pre-fetch the current content and warn when such blocks
|
||||
// are present so the caller can take a backup before proceeding.
|
||||
if runtime.Str("mode") == "overwrite" {
|
||||
if w := warnOverwriteResourceBlocks(runtime); w != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
|
||||
}
|
||||
}
|
||||
|
||||
// Surface callout type= hint so users know to switch to background-color/
|
||||
// border-color when they want a colored callout. Non-blocking, advisory.
|
||||
if md := runtime.Str("markdown"); md != "" {
|
||||
@@ -197,3 +216,74 @@ func buildUpdateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// resourceBlockRe matches the opening of a <whiteboard …> or <file …> tag
|
||||
// (followed by whitespace, > or /) to avoid false positives on tag names like
|
||||
// <file-view> or prose that merely mentions the word "whiteboard".
|
||||
var resourceBlockRe = regexp.MustCompile(`<(whiteboard|file)[\s/>]`)
|
||||
|
||||
// warnOverwriteResourceBlocks pre-fetches the current document and returns a
|
||||
// non-empty warning string when the document contains whiteboard or file
|
||||
// attachment blocks that would be permanently deleted by an overwrite. Returns
|
||||
// an empty string (no warning) when the document is clean or the fetch fails
|
||||
// (we never block the overwrite on a best-effort check).
|
||||
//
|
||||
// This function is not unit-tested because it depends on an external MCP call
|
||||
// (fetch-doc). The pure detection logic lives in checkOverwriteResourceBlocks,
|
||||
// which has full table-driven coverage.
|
||||
//
|
||||
// Performance: this adds one extra fetch-doc round-trip to every --mode overwrite
|
||||
// call, even when the document has no resource blocks. The cost is intentional:
|
||||
// the guard is best-effort and silent on failure, so the latency is bounded and
|
||||
// the trade-off is acceptable to avoid silent data loss.
|
||||
func warnOverwriteResourceBlocks(runtime *common.RuntimeContext) string {
|
||||
args := map[string]interface{}{
|
||||
"doc_id": runtime.Str("doc"),
|
||||
// skip_task_detail reduces response payload by omitting per-block task
|
||||
// metadata, making the pre-fetch faster and cheaper.
|
||||
"skip_task_detail": true,
|
||||
}
|
||||
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
|
||||
if err != nil {
|
||||
// Fetch failed — silently skip the guard rather than blocking overwrite.
|
||||
return ""
|
||||
}
|
||||
md, _ := result["markdown"].(string)
|
||||
return checkOverwriteResourceBlocks(md)
|
||||
}
|
||||
|
||||
// checkOverwriteResourceBlocks scans Markdown for resource block tags that
|
||||
// cannot survive an overwrite: <whiteboard …> and <file …>. Returns a
|
||||
// warning string listing the counts if any are found, empty string otherwise.
|
||||
func checkOverwriteResourceBlocks(markdown string) string {
|
||||
matches := resourceBlockRe.FindAllStringSubmatch(markdown, -1)
|
||||
whiteboards, files := 0, 0
|
||||
for _, m := range matches {
|
||||
switch m[1] {
|
||||
case "whiteboard":
|
||||
whiteboards++
|
||||
case "file":
|
||||
files++
|
||||
}
|
||||
}
|
||||
var found []string
|
||||
if whiteboards == 1 {
|
||||
found = append(found, "1 whiteboard block")
|
||||
} else if whiteboards > 1 {
|
||||
found = append(found, fmt.Sprintf("%d whiteboard blocks", whiteboards))
|
||||
}
|
||||
if files == 1 {
|
||||
found = append(found, "1 file attachment block")
|
||||
} else if files > 1 {
|
||||
found = append(found, fmt.Sprintf("%d file attachment blocks", files))
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"the document contains %s that cannot be reconstructed from Markdown; "+
|
||||
"overwrite will permanently delete them. "+
|
||||
"Consider fetching a backup with `docs +fetch` before overwriting.",
|
||||
strings.Join(found, " and "),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package doc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,33 @@ func TestValidCommandsV2(t *testing.T) {
|
||||
|
||||
// ── V1 tests ──
|
||||
|
||||
func TestSelectionRequiredMessageV1ReplaceAllSuggestsOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := selectionRequiredMessageV1("replace_all")
|
||||
for _, needle := range []string{
|
||||
"--replace_all mode requires --selection-with-ellipsis or --selection-by-title",
|
||||
"replace the entire document body",
|
||||
"--mode overwrite",
|
||||
} {
|
||||
if !strings.Contains(msg, needle) {
|
||||
t.Fatalf("message missing %q: %s", needle, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionRequiredMessageV1OtherModesDoNotSuggestOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := selectionRequiredMessageV1("replace_range")
|
||||
if strings.Contains(msg, "--mode overwrite") {
|
||||
t.Fatalf("replace_range message should not suggest overwrite: %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "--replace_range mode requires --selection-with-ellipsis or --selection-by-title") {
|
||||
t.Fatalf("unexpected message: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWhiteboardCreateMarkdown(t *testing.T) {
|
||||
t.Run("blank whiteboard tags", func(t *testing.T) {
|
||||
markdown := "<whiteboard type=\"blank\"></whiteboard>\n<whiteboard type=\"blank\"></whiteboard>"
|
||||
@@ -55,6 +83,72 @@ func TestIsWhiteboardCreateMarkdown(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckOverwriteResourceBlocks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
wantWarn bool
|
||||
wantSubs []string
|
||||
}{
|
||||
{
|
||||
name: "empty markdown is clean",
|
||||
markdown: "",
|
||||
wantWarn: false,
|
||||
},
|
||||
{
|
||||
name: "plain prose is clean",
|
||||
markdown: "## Heading\n\nsome text",
|
||||
wantWarn: false,
|
||||
},
|
||||
{
|
||||
name: "single whiteboard triggers warning",
|
||||
markdown: `<whiteboard token="abc123"/>`,
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"1 whiteboard block", "overwrite"},
|
||||
},
|
||||
{
|
||||
name: "multiple whiteboards counted",
|
||||
markdown: "<whiteboard token=\"a\"/>\n<whiteboard token=\"b\"/>",
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"2 whiteboard blocks"},
|
||||
},
|
||||
{
|
||||
name: "single file attachment triggers warning",
|
||||
markdown: `<file token="tok" name="report.pdf"/>`,
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"1 file attachment block"},
|
||||
},
|
||||
{
|
||||
name: "multiple file attachments counted",
|
||||
markdown: "<file token=\"a\"/>\n<file token=\"b\"/>\n<file token=\"c\"/>",
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"3 file attachment blocks"},
|
||||
},
|
||||
{
|
||||
name: "whiteboard and file together both counted",
|
||||
markdown: "<whiteboard token=\"wb\"/>\n<file token=\"f\"/>",
|
||||
wantWarn: true,
|
||||
wantSubs: []string{"1 whiteboard block", "1 file attachment block"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := checkOverwriteResourceBlocks(tt.markdown)
|
||||
if (got != "") != tt.wantWarn {
|
||||
t.Fatalf("checkOverwriteResourceBlocks(%q) = %q, wantWarn=%v", tt.markdown, got, tt.wantWarn)
|
||||
}
|
||||
for _, sub := range tt.wantSubs {
|
||||
if !strings.Contains(got, sub) {
|
||||
t.Errorf("expected warning to contain %q, got: %s", sub, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWhiteboardResult(t *testing.T) {
|
||||
t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
@@ -101,3 +195,35 @@ func TestNormalizeWhiteboardResult(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateSelectionByTitleV1(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
title string
|
||||
wantErr bool
|
||||
errSub string
|
||||
}{
|
||||
{name: "empty title is valid", title: "", wantErr: false},
|
||||
{name: "single heading is valid", title: "## Section", wantErr: false},
|
||||
{name: "h1 heading is valid", title: "# Top", wantErr: false},
|
||||
{name: "deep heading is valid", title: "### Sub-section", wantErr: false},
|
||||
{name: "missing hash prefix is invalid", title: "No hash", wantErr: true, errSub: "'#'"},
|
||||
{name: "multiline title is invalid", title: "## First\n## Second", wantErr: true, errSub: "single"},
|
||||
{name: "title with embedded carriage return is invalid", title: "## Title\r## Next", wantErr: true, errSub: "single"},
|
||||
{name: "leading-space heading is valid after trim", title: " ## Section", wantErr: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateSelectionByTitleV1(tt.title)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateSelectionByTitleV1(%q) error = %v, wantErr = %v", tt.title, err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr && tt.errSub != "" && !strings.Contains(err.Error(), tt.errSub) {
|
||||
t.Errorf("expected error to contain %q, got: %v", tt.errSub, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,7 @@ var DriveExport = common.Shortcut{
|
||||
Scopes: []string{
|
||||
"docs:document.content:read",
|
||||
"docs:document:export",
|
||||
"docx:document:readonly",
|
||||
"drive:drive.metadata:readonly",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -52,16 +54,15 @@ var DriveExport = common.Shortcut{
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from docs content
|
||||
// directly instead of the Drive export task API.
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
GET("/open-apis/docs/v1/content").
|
||||
Params(map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
@@ -101,28 +102,38 @@ var DriveExport = common.Shortcut{
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk.
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
"/open-apis/docs/v1/content",
|
||||
map[string]interface{}{
|
||||
"doc_token": spec.Token,
|
||||
"doc_type": "docx",
|
||||
"content_type": "markdown",
|
||||
},
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.DoAPIJSONWithLogID(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
@@ -130,7 +141,7 @@ var DriveExport = common.Shortcut{
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -141,7 +152,7 @@ var DriveExport = common.Shortcut{
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len([]byte(common.GetString(data, "content"))),
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -228,34 +228,6 @@ func parseDriveExportStatus(ticket string, data map[string]interface{}) driveExp
|
||||
return status
|
||||
}
|
||||
|
||||
// fetchDriveMetaTitle looks up the document title so exported files can use a
|
||||
// human-readable default name when possible.
|
||||
func fetchDriveMetaTitle(runtime *common.RuntimeContext, token, docType 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": token,
|
||||
"doc_type": docType,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
metas := common.GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
meta, _ := metas[0].(map[string]interface{})
|
||||
return common.GetString(meta, "title"), nil
|
||||
}
|
||||
|
||||
// saveContentToOutputDir validates the target path, enforces overwrite policy,
|
||||
// and writes the payload atomically via FileIO.Save.
|
||||
func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, payload []byte, overwrite bool) (string, error) {
|
||||
|
||||
@@ -81,16 +81,19 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
|
||||
func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
fetchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# hello\n",
|
||||
"document": map[string]interface{}{
|
||||
"content": "# hello\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(fetchStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
@@ -118,6 +121,14 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
|
||||
}
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
@@ -132,16 +143,19 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
|
||||
func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
fetchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# custom\n",
|
||||
"document": map[string]interface{}{
|
||||
"content": "# custom\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(fetchStub)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
@@ -158,6 +172,14 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
|
||||
}
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
@@ -179,7 +201,7 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "markdown",
|
||||
wantURL: "/open-apis/docs/v1/content",
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
wantFileName: `"file_name": "notes.md"`,
|
||||
args: []string{
|
||||
"+export",
|
||||
@@ -233,16 +255,19 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
|
||||
|
||||
func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
fetchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"content": "# fallback\n",
|
||||
"document": map[string]interface{}{
|
||||
"content": "# fallback\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(fetchStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
@@ -267,6 +292,14 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
|
||||
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
|
||||
}
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
@@ -279,6 +312,76 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownRejectsMissingDocumentObject(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing document object, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "missing document object") {
|
||||
t.Fatalf("error message = %q, want mention of missing document object", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing document.content, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "missing document.content") {
|
||||
t.Fatalf("error message = %q, want mention of missing document.content", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -31,6 +31,7 @@ var DriveImport = common.Shortcut{
|
||||
{Name: "type", Desc: "target document type (docx, sheet, bitable)", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
|
||||
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
|
||||
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
@@ -38,6 +39,7 @@ var DriveImport = common.Shortcut{
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -46,11 +48,15 @@ var DriveImport = common.Shortcut{
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
@@ -76,6 +82,7 @@ var DriveImport = common.Shortcut{
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
|
||||
@@ -51,6 +51,7 @@ type driveImportSpec struct {
|
||||
DocType string
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string // existing bitable token to import data into (only for type=bitable)
|
||||
}
|
||||
|
||||
func (s driveImportSpec) FileExtension() string {
|
||||
@@ -67,7 +68,7 @@ func (s driveImportSpec) TargetFileName() string {
|
||||
|
||||
// CreateTaskBody builds the request body expected by /drive/v1/import_tasks.
|
||||
func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
body := map[string]interface{}{
|
||||
"file_extension": s.FileExtension(),
|
||||
"file_token": fileToken,
|
||||
"type": s.DocType,
|
||||
@@ -79,6 +80,12 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{}
|
||||
"mount_key": s.FolderToken,
|
||||
},
|
||||
}
|
||||
|
||||
if s.DocType == "bitable" && s.TargetToken != "" {
|
||||
body["token"] = s.TargetToken
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// uploadMediaForImport uploads the source file to the temporary import media
|
||||
@@ -232,6 +239,15 @@ func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.TargetToken) != "" {
|
||||
if spec.DocType != "bitable" {
|
||||
return output.ErrValidation("--target-token is only supported when --type is bitable")
|
||||
}
|
||||
if err := validate.ResourceName(spec.TargetToken, "--target-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,19 @@ func TestValidateDriveImportSpec(t *testing.T) {
|
||||
spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"},
|
||||
wantErr: "unsupported file extension",
|
||||
},
|
||||
{
|
||||
name: "target-token rejected for non-bitable type",
|
||||
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "sheet", TargetToken: "bascnxxx"},
|
||||
wantErr: "--target-token is only supported when --type is bitable",
|
||||
},
|
||||
{
|
||||
name: "target-token accepted for bitable",
|
||||
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "bitable", TargetToken: "bascnxxx"},
|
||||
},
|
||||
{
|
||||
name: "target-token empty for bitable still ok",
|
||||
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "bitable"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -84,6 +84,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./base-import.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -148,6 +149,7 @@ func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./large.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -197,6 +199,7 @@ func TestDriveImportDryRunReturnsErrorForUnsafePath(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "../outside.md"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -250,6 +253,7 @@ func TestDriveImportDryRunReturnsErrorForOversizedMarkdown(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./large.md"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -296,6 +300,7 @@ func TestDriveImportDryRunReturnsErrorForDirectoryInput(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./folder-input"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -366,6 +371,165 @@ func TestDriveImportCreateTaskBodyKeepsEmptyMountKeyForRoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportCreateTaskBodyWithTargetToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := driveImportSpec{
|
||||
FilePath: "/tmp/data.xlsx",
|
||||
DocType: "bitable",
|
||||
TargetToken: "bascnxxxxx",
|
||||
}
|
||||
|
||||
body := spec.CreateTaskBody("file_token_test")
|
||||
|
||||
// point stays the same as default (mount_type=1)
|
||||
point, ok := body["point"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("point = %#v, want map", body["point"])
|
||||
}
|
||||
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
|
||||
t.Fatalf("mount_type = %v (%T), want 1", mt, mt)
|
||||
}
|
||||
|
||||
// token is injected at body top-level
|
||||
if tt, _ := body["token"].(string); tt != "bascnxxxxx" {
|
||||
t.Fatalf("token = %q, want %q", tt, "bascnxxxxx")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportCreateTaskBodyTargetTokenIgnoredForNonBitable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := driveImportSpec{
|
||||
FilePath: "/tmp/data.xlsx",
|
||||
DocType: "sheet",
|
||||
TargetToken: "bascnxxxxx",
|
||||
FolderToken: "fld_test",
|
||||
}
|
||||
|
||||
body := spec.CreateTaskBody("file_token_test")
|
||||
point, ok := body["point"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("point = %#v, want map", body["point"])
|
||||
}
|
||||
|
||||
// Non-bitable should use default folder mount (type=1), ignoring TargetToken
|
||||
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
|
||||
t.Fatalf("mount_type = %v (%T), want 1 (folder mount)", mt, mt)
|
||||
}
|
||||
if _, exists := point["target_token"]; exists {
|
||||
t.Fatal("target_token should not be present for non-bitable type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunWithTargetToken(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./data.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "bitable"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("target-token", "bascntarget123"); err != nil {
|
||||
t.Fatalf("set --target-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 3 {
|
||||
t.Fatalf("expected 3 API calls, got %d", len(got.API))
|
||||
}
|
||||
|
||||
// The import task body (API[1]) should contain target_token in point
|
||||
importTaskBody := got.API[1].Body
|
||||
point, ok := importTaskBody["point"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("point = %#v, want map", importTaskBody["point"])
|
||||
}
|
||||
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
|
||||
t.Fatalf("dry-run mount_type = %v (%T), want 1 (unchanged)", mt, mt)
|
||||
}
|
||||
if tt, _ := importTaskBody["token"].(string); tt != "bascntarget123" {
|
||||
t.Fatalf("dry-run token = %q, want %q", tt, "bascntarget123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunTargetTokenRejectedForSheet(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./data.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "sheet"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("target-token", "bascnxxx"); err != nil {
|
||||
t.Fatalf("set --target-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got.Error == "" || !strings.Contains(got.Error, "--target-token is only supported when --type is bitable") {
|
||||
t.Fatalf("dry-run error = %q, want target-token validation error", got.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// driveImportMockEnv mounts the three stubs needed for a full +import run:
|
||||
// media upload_all -> import_tasks (create) -> import_tasks/<ticket> (poll).
|
||||
// Returns nothing; caller asserts on stdout via decodeDriveEnvelope.
|
||||
|
||||
183
shortcuts/drive/drive_inspect.go
Normal file
183
shortcuts/drive/drive_inspect.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var DriveInspect = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+inspect",
|
||||
Description: "Inspect a Lark document URL to get its type, title, and canonical token (with wiki unwrapping)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:drive.metadata:readonly"},
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{
|
||||
Name: "url",
|
||||
Desc: "Lark/Feishu document URL (docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides)",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Name: "type",
|
||||
Desc: "document type (required when --url is a bare token; auto-detected for URLs)",
|
||||
Enum: []string{"doc", "docx", "sheet", "bitable", "wiki", "file", "folder", "mindnote", "slides"},
|
||||
},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
if raw == "" {
|
||||
return output.ErrValidation("--url cannot be empty")
|
||||
}
|
||||
|
||||
_, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
// Not a recognized URL pattern.
|
||||
if strings.Contains(raw, "://") {
|
||||
return output.ErrValidation("unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw)
|
||||
}
|
||||
// Bare token: --type is required.
|
||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
||||
return output.ErrValidation("--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
ref, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
ref = common.ResourceRef{
|
||||
Type: strings.TrimSpace(runtime.Str("type")),
|
||||
Token: raw,
|
||||
}
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
|
||||
if ref.Type == "wiki" {
|
||||
dry.Desc("2-step: inspect wiki node, then batch query metadata")
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Inspect wiki node to get underlying document").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
dry.POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("[2] Batch query document metadata (title)").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{"doc_token": "<obj_token from step 1>", "doc_type": "<obj_type from step 1>"},
|
||||
},
|
||||
})
|
||||
return dry
|
||||
}
|
||||
|
||||
dry.Desc("1-step: batch query document metadata")
|
||||
dry.POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{"doc_token": ref.Token, "doc_type": ref.Type},
|
||||
},
|
||||
})
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
raw := strings.TrimSpace(runtime.Str("url"))
|
||||
|
||||
// Step 1: Parse URL to extract {type, token}.
|
||||
ref, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
// Bare token: use --type.
|
||||
ref = common.ResourceRef{
|
||||
Type: strings.TrimSpace(runtime.Str("type")),
|
||||
Token: raw,
|
||||
}
|
||||
}
|
||||
|
||||
inputURL := raw
|
||||
docType := ref.Type
|
||||
docToken := ref.Token
|
||||
|
||||
var wikiNode map[string]interface{}
|
||||
|
||||
// Step 2: If type is "wiki", unwrap via get_node API.
|
||||
if docType == "wiki" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken))
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": docToken},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
objType := common.GetString(node, "obj_type")
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
spaceID := common.GetString(node, "space_id")
|
||||
nodeToken := common.GetString(node, "node_token")
|
||||
|
||||
if objType == "" || objToken == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
|
||||
}
|
||||
|
||||
wikiNode = map[string]interface{}{
|
||||
"space_id": spaceID,
|
||||
"node_token": nodeToken,
|
||||
"obj_token": objToken,
|
||||
"obj_type": objType,
|
||||
}
|
||||
|
||||
docType = objType
|
||||
docToken = objToken
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki unwrapped to %s: %s\n", docType, common.MaskToken(docToken))
|
||||
}
|
||||
|
||||
// Step 3: Call batch_query to verify and get title.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, docToken, docType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4: Build the resolved URL.
|
||||
resolvedURL := common.BuildResourceURL(runtime.Config.Brand, docType, docToken)
|
||||
|
||||
// Step 5: Build output.
|
||||
result := map[string]interface{}{
|
||||
"input_url": inputURL,
|
||||
"type": docType,
|
||||
"title": title,
|
||||
"token": docToken,
|
||||
"url": resolvedURL,
|
||||
}
|
||||
if wikiNode != nil {
|
||||
result["wiki_node"] = wikiNode
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Type: %s\n", docType)
|
||||
if title != "" {
|
||||
fmt.Fprintf(w, "Title: %s\n", title)
|
||||
}
|
||||
fmt.Fprintf(w, "Token: %s\n", docToken)
|
||||
if resolvedURL != "" {
|
||||
fmt.Fprintf(w, "URL: %s\n", resolvedURL)
|
||||
}
|
||||
if wikiNode != nil {
|
||||
fmt.Fprintf(w, "Wiki: space_id=%s, node_token=%s\n", wikiNode["space_id"], wikiNode["node_token"])
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
466
shortcuts/drive/drive_inspect_test.go
Normal file
466
shortcuts/drive/drive_inspect_test.go
Normal file
@@ -0,0 +1,466 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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"
|
||||
)
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestDriveInspectValidate_EmptyURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty --url, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_UnsupportedURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://google.com/some/page")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported URL, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_NonLarkHostWithLarkPath(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://google.com/docx/doxcnLooksValid")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for non-Lark host with Lark-like path (host validation removed), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_BareTokenWithoutType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "doxcnBareToken")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare token without --type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_BareTokenWithType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "doxcnBareToken")
|
||||
_ = cmd.Flags().Set("type", "docx")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_ValidDocxURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_ValidWikiURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/wiki/wikcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestDriveInspectDryRun_DocxURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
dry := DriveInspect.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API step, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Errorf("API URL = %q, want /open-apis/drive/v1/metas/batch_query", got.API[0].URL)
|
||||
}
|
||||
// Verify body contains request_docs with the correct token and type.
|
||||
reqDocs, ok := got.API[0].Body["request_docs"].([]interface{})
|
||||
if !ok || len(reqDocs) != 1 {
|
||||
t.Fatalf("expected request_docs with 1 entry, got %v", got.API[0].Body["request_docs"])
|
||||
}
|
||||
doc, _ := reqDocs[0].(map[string]interface{})
|
||||
if doc["doc_token"] != "doxcnABC" {
|
||||
t.Errorf("doc_token = %v, want doxcnABC", doc["doc_token"])
|
||||
}
|
||||
if doc["doc_type"] != "docx" {
|
||||
t.Errorf("doc_type = %v, want docx", doc["doc_type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_WikiURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/wiki/wikcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
dry := DriveInspect.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("expected 2 API steps, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Errorf("step 1 URL = %q, want /open-apis/wiki/v2/spaces/get_node", got.API[0].URL)
|
||||
}
|
||||
// Verify step 1 params contain the wiki token.
|
||||
if got.API[0].Params["token"] != "wikcnABC" {
|
||||
t.Errorf("step 1 params.token = %v, want wikcnABC", got.API[0].Params["token"])
|
||||
}
|
||||
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Errorf("step 2 URL = %q, want /open-apis/drive/v1/metas/batch_query", got.API[1].URL)
|
||||
}
|
||||
// Verify step 2 body contains request_docs placeholder.
|
||||
if got.API[1].Body["request_docs"] == nil {
|
||||
t.Error("step 2 body should contain request_docs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_BareTokenWithType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "doxcnBareToken")
|
||||
_ = cmd.Flags().Set("type", "docx")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
dry := DriveInspect.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API step, got %d", len(got.API))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestDriveInspectExecute_DocxURL(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
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": "doxcnABC", "doc_type": "docx", "title": "Test Doc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/docx/doxcnABC",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["type"] != "docx" {
|
||||
t.Errorf("type = %v, want docx", data["type"])
|
||||
}
|
||||
if data["token"] != "doxcnABC" {
|
||||
t.Errorf("token = %v, want doxcnABC", data["token"])
|
||||
}
|
||||
if data["title"] != "Test Doc" {
|
||||
t.Errorf("title = %v, want Test Doc", data["title"])
|
||||
}
|
||||
if _, ok := data["wiki_node"]; ok {
|
||||
t.Error("wiki_node should not be present for non-wiki URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_WikiURL(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "doxcnUnwrapped",
|
||||
"space_id": "space123",
|
||||
"node_token": "wikcnNodeToken",
|
||||
"title": "Wiki Doc",
|
||||
"node_type": "origin",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
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": "doxcnUnwrapped", "doc_type": "docx", "title": "Wiki Doc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/wiki/wikcnABC",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["type"] != "docx" {
|
||||
t.Errorf("type = %v, want docx (unwrapped from wiki)", data["type"])
|
||||
}
|
||||
if data["token"] != "doxcnUnwrapped" {
|
||||
t.Errorf("token = %v, want doxcnUnwrapped", data["token"])
|
||||
}
|
||||
if data["title"] != "Wiki Doc" {
|
||||
t.Errorf("title = %v, want Wiki Doc", data["title"])
|
||||
}
|
||||
wikiNode, ok := data["wiki_node"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("wiki_node should be present for wiki URL")
|
||||
}
|
||||
if wikiNode["space_id"] != "space123" {
|
||||
t.Errorf("wiki_node.space_id = %v, want space123", wikiNode["space_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_WikiGetNodeIncompleteData(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "",
|
||||
"obj_token": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/wiki/wikcnABC",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for incomplete wiki node data, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_BareTokenWithType(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
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": "doxcnBare", "doc_type": "docx", "title": "Bare Doc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "doxcnBare",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["type"] != "docx" {
|
||||
t.Errorf("type = %v, want docx", data["type"])
|
||||
}
|
||||
if data["token"] != "doxcnBare" {
|
||||
t.Errorf("token = %v, want doxcnBare", data["token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991668,
|
||||
"msg": "permission denied",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/docx/doxcnABC",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for batch_query failure, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := driveTestConfig()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
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": "doxcnABC", "doc_type": "docx", "title": "Test Doc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||
"+inspect",
|
||||
"--url", "https://xxx.feishu.cn/docx/doxcnABC",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Pretty format outputs to stdout as text, not JSON envelope.
|
||||
// Just verify it didn't error.
|
||||
_ = stdout
|
||||
}
|
||||
@@ -604,9 +604,9 @@ func TestDriveUploadSmallFileToWiki(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
|
||||
func TestDriveUploadUsesMetaURLForExplorerParent(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-explorer-fallback-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
AppID: "drive-upload-explorer-meta-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
|
||||
@@ -615,12 +615,21 @@ func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
// upload_all only ever returns file_token; url is never present —
|
||||
// this exercises the fallback path unconditionally for explorer
|
||||
// parents.
|
||||
"data": map[string]interface{}{"file_token": "file_explorer_small"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "file_explorer_small", "doc_type": "file", "url": "https://tenant.example.com/file/file_explorer_small"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
origDir, _ := os.Getwd()
|
||||
@@ -641,14 +650,14 @@ func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got, want := data["url"], "https://www.feishu.cn/file/file_explorer_small"; got != want {
|
||||
t.Fatalf("data.url = %#v, want %q (brand-standard fallback)", got, want)
|
||||
if got, want := data["url"], "https://tenant.example.com/file/file_explorer_small"; got != want {
|
||||
t.Fatalf("data.url = %#v, want %q (metadata URL)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
|
||||
func TestDriveUploadUsesMetaURLForWikiParent(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-wiki-no-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
AppID: "drive-upload-wiki-meta-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
|
||||
@@ -660,6 +669,18 @@ func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
|
||||
"data": map[string]interface{}{"file_token": "file_wiki_small"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "file_wiki_small", "doc_type": "file", "url": "https://tenant.example.com/file/file_wiki_small"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
@@ -677,8 +698,8 @@ func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if _, ok := data["url"]; ok {
|
||||
t.Fatalf("data.url should be omitted for wiki-hosted files (no standalone URL); got %#v", data["url"])
|
||||
if got, want := data["url"], "https://tenant.example.com/file/file_wiki_small"; got != want {
|
||||
t.Fatalf("data.url = %#v, want %q (metadata URL)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1078,14 +1099,15 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Body["parent_type"] != driveUploadParentTypeWiki {
|
||||
t.Fatalf("parent_type = %#v, want %q", got.API[0].Body["parent_type"], driveUploadParentTypeWiki)
|
||||
@@ -1093,6 +1115,12 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
if got.API[0].Body["parent_node"] != "wikcn_dryrun_upload_target" {
|
||||
t.Fatalf("parent_node = %#v, want %q", got.API[0].Body["parent_node"], "wikcn_dryrun_upload_target")
|
||||
}
|
||||
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[1].URL)
|
||||
}
|
||||
if got.API[1].Body["with_url"] != true {
|
||||
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
|
||||
@@ -1168,18 +1196,25 @@ func TestDriveUploadDryRunIncludesFileToken(t *testing.T) {
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
|
||||
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
|
||||
}
|
||||
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[1].URL)
|
||||
}
|
||||
if got.API[1].Body["with_url"] != true {
|
||||
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadDryRunBotOverwriteSkipsPermissionGrantHint(t *testing.T) {
|
||||
@@ -1222,8 +1257,8 @@ func TestDriveUploadDryRunBotOverwriteSkipsPermissionGrantHint(t *testing.T) {
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
|
||||
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ var DriveSearch = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
|
||||
|
||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I created (uses current user's open_id)"},
|
||||
{Name: "creator-ids", Desc: "comma-separated creator open_ids; mutually exclusive with --mine"},
|
||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
||||
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||
|
||||
{Name: "edited-since", Desc: "start of [my edited] time window (e.g. 7d, 1m, 1y, 2026-04-01, RFC3339, unix seconds)"},
|
||||
{Name: "edited-until", Desc: "end of [my edited] time window"},
|
||||
@@ -108,7 +108,7 @@ var DriveSearch = common.Shortcut{
|
||||
Tips: []string{
|
||||
"Time flags accept relative (e.g. 7d, 1m, 1y), absolute (2026-04-01, RFC3339), or unix seconds.",
|
||||
"my_edit_time and my_comment_time are hour-aggregated server-side; sub-hour inputs are snapped and a notice is printed to stderr.",
|
||||
"Use --mine for a quick \"docs I created\" filter. For other people, use --creator-ids ou_xxx,ou_yyy.",
|
||||
"Use --mine for a quick \"docs I own\" filter (owner semantic, not original creator). For other people, use --creator-ids ou_xxx,ou_yyy.",
|
||||
"--folder-tokens limits to doc-only search; --space-ids limits to wiki-only. They cannot be combined.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
var DriveTaskResult = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+task_result",
|
||||
Description: "Poll async task result for import, export, drive move/delete, wiki move, or wiki delete-space operations",
|
||||
Description: "Poll async task result for import, export, drive move/delete, wiki move, wiki delete-space, or wiki delete-node operations",
|
||||
Risk: "read",
|
||||
// This shortcut multiplexes multiple backend APIs with different scope
|
||||
// requirements, so scenario-specific prechecks are handled in Validate.
|
||||
@@ -28,8 +28,8 @@ var DriveTaskResult = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
|
||||
{Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, or wiki_delete_space tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, or wiki_delete_space", Required: true},
|
||||
{Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, wiki_delete_space, or wiki_delete_node tasks)", Required: false},
|
||||
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, wiki_delete_space, or wiki_delete_node", Required: true},
|
||||
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -40,9 +40,10 @@ var DriveTaskResult = common.Shortcut{
|
||||
"task_check": true,
|
||||
"wiki_move": true,
|
||||
"wiki_delete_space": true,
|
||||
"wiki_delete_node": true,
|
||||
}
|
||||
if !validScenarios[scenario] {
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space", scenario)
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario)
|
||||
}
|
||||
|
||||
// Validate required params based on scenario
|
||||
@@ -54,7 +55,7 @@ var DriveTaskResult = common.Shortcut{
|
||||
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
case "task_check", "wiki_move", "wiki_delete_space":
|
||||
case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node":
|
||||
if runtime.Str("task-id") == "" {
|
||||
return output.ErrValidation("--task-id is required for %s scenario", scenario)
|
||||
}
|
||||
@@ -108,6 +109,11 @@ var DriveTaskResult = common.Shortcut{
|
||||
Desc("[1] Query wiki delete-space task result").
|
||||
Set("task_id", taskID).
|
||||
Params(map[string]interface{}{"task_type": "delete_space"})
|
||||
case "wiki_delete_node":
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[1] Query wiki delete-node task result").
|
||||
Set("task_id", taskID).
|
||||
Params(map[string]interface{}{"task_type": "delete_node"})
|
||||
}
|
||||
|
||||
return dry
|
||||
@@ -136,6 +142,8 @@ var DriveTaskResult = common.Shortcut{
|
||||
result, err = queryWikiMoveTask(runtime, taskID)
|
||||
case "wiki_delete_space":
|
||||
result, err = queryWikiDeleteSpaceTask(runtime, taskID)
|
||||
case "wiki_delete_node":
|
||||
result, err = queryWikiDeleteNodeTask(runtime, taskID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -236,7 +244,7 @@ func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeC
|
||||
switch scenario {
|
||||
case "import", "export", "task_check":
|
||||
required = []string{"drive:drive.metadata:readonly"}
|
||||
case "wiki_move", "wiki_delete_space":
|
||||
case "wiki_move", "wiki_delete_space", "wiki_delete_node":
|
||||
required = []string{"wiki:space:read"}
|
||||
}
|
||||
|
||||
@@ -540,3 +548,64 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma
|
||||
"status_msg": label,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// queryWikiDeleteNodeTask returns the normalized status of an async wiki
|
||||
// delete-node task. For historical reasons the gateway stashes delete-node
|
||||
// status under the generic `simple_task_result` key (NOT `delete_node_result`),
|
||||
// and that object only carries `status` — there is no `status_msg`, so the
|
||||
// label falls back to the status code. Mirrors queryWikiDeleteSpaceTask;
|
||||
// intentionally duplicated here (rather than importing the wiki package) to
|
||||
// keep drive from depending on shortcuts/wiki.
|
||||
func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
|
||||
return nil, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "delete_node"},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
task := common.GetMap(data, "task")
|
||||
if task == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
}
|
||||
|
||||
resolvedTaskID := common.GetString(task, "task_id")
|
||||
if resolvedTaskID == "" {
|
||||
resolvedTaskID = taskID
|
||||
}
|
||||
|
||||
result := common.GetMap(task, "simple_task_result")
|
||||
var status string
|
||||
if result != nil {
|
||||
status = common.GetString(result, "status")
|
||||
}
|
||||
|
||||
// Keep in sync with wiki.parseWikiAsyncTaskStatus / wikiAsyncTaskStatus
|
||||
// classification (intentionally duplicated to avoid a drive→wiki import —
|
||||
// see the doc comment above). If the success/failed/processing rules change
|
||||
// there, mirror the change here.
|
||||
lowered := strings.ToLower(strings.TrimSpace(status))
|
||||
ready := lowered == "success"
|
||||
failed := lowered == "failure" || lowered == "failed"
|
||||
|
||||
resolvedStatus := strings.TrimSpace(status)
|
||||
if resolvedStatus == "" {
|
||||
resolvedStatus = "processing"
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"scenario": "wiki_delete_node",
|
||||
"task_id": resolvedTaskID,
|
||||
"ready": ready,
|
||||
"failed": failed,
|
||||
"status": resolvedStatus,
|
||||
"status_msg": resolvedStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -417,10 +417,10 @@ func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) {
|
||||
func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// wiki_move and wiki_delete_space both read wiki task status, so both must
|
||||
// require wiki:space:read. A single table keeps this invariant explicit
|
||||
// without duplicating near-identical test functions per scenario.
|
||||
for _, scenario := range []string{"wiki_move", "wiki_delete_space"} {
|
||||
// wiki_move, wiki_delete_space and wiki_delete_node all read wiki task
|
||||
// status, so all must require wiki:space:read. A single table keeps this
|
||||
// invariant explicit without duplicating near-identical test functions.
|
||||
for _, scenario := range []string{"wiki_move", "wiki_delete_space", "wiki_delete_node"} {
|
||||
t.Run(scenario+"/rejects missing scope", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly")
|
||||
@@ -518,6 +518,105 @@ func TestDriveTaskResultWikiDeleteSpaceSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultDryRunWikiDeleteNodeIncludesTaskTypeParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +task_result"}
|
||||
cmd.Flags().String("scenario", "", "")
|
||||
cmd.Flags().String("ticket", "", "")
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
if err := cmd.Flags().Set("scenario", "wiki_delete_node"); err != nil {
|
||||
t.Fatalf("set --scenario: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("task-id", "task_del_node_1"); err != nil {
|
||||
t.Fatalf("set --task-id: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveTaskResult.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Params["task_type"] != "delete_node" {
|
||||
t.Fatalf("wiki delete-node params = %#v, want task_type=delete_node", got.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultWikiDeleteNodeSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/task_del_node_1",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
// Gateway returns delete-node status under the generic
|
||||
// simple_task_result key (NOT delete_node_result), and it
|
||||
// carries only `status` (no status_msg).
|
||||
"simple_task_result": map[string]interface{}{
|
||||
"status": "success",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "wiki_delete_node",
|
||||
"--task-id", "task_del_node_1",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["scenario"] != "wiki_delete_node" || data["task_id"] != "task_del_node_1" {
|
||||
t.Fatalf("unexpected wiki_delete_node envelope: %#v", data)
|
||||
}
|
||||
if data["ready"] != true || data["failed"] != false || data["status"] != "success" {
|
||||
t.Fatalf("unexpected readiness fields: %#v", data)
|
||||
}
|
||||
// simple_task_result has no status_msg; label must fall back to status.
|
||||
if data["status_msg"] != "success" {
|
||||
t.Fatalf("status_msg = %#v, want fallback to status", data["status_msg"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveTaskResultRejectsUnknownScenarioListsWikiDeleteNode(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveTaskResult, []string{
|
||||
"+task_result",
|
||||
"--scenario", "bogus",
|
||||
"--task-id", "task_x",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "wiki_delete_node") {
|
||||
t.Fatalf("expected unsupported-scenario error listing wiki_delete_node, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveTaskResultScopesDriveScenariosRequireDriveScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ var DriveUpload = common.Shortcut{
|
||||
Command: "+upload",
|
||||
Description: "Upload a local file to Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:file:upload"},
|
||||
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
@@ -124,11 +124,22 @@ var DriveUpload = common.Shortcut{
|
||||
body["file_token"] = spec.FileToken
|
||||
}
|
||||
d := common.NewDryRunAPI().
|
||||
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload)").
|
||||
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload), then fetch the real Drive URL via metadata").
|
||||
POST("/open-apis/drive/v1/files/upload_all").
|
||||
Body(body)
|
||||
d.POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("Fetch the uploaded file's real access URL").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": "<file_token from upload response>",
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
"with_url": true,
|
||||
})
|
||||
if runtime.IsBot() && !isOverwrite {
|
||||
d.Desc("After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
|
||||
d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
@@ -165,13 +176,10 @@ var DriveUpload = common.Shortcut{
|
||||
if uploadResult.Version != "" {
|
||||
out["version"] = uploadResult.Version
|
||||
}
|
||||
// wiki-hosted files have no standalone /file/<token> URL — only the
|
||||
// wiki node URL, which the upload response doesn't carry. Skip the
|
||||
// fallback for parent_type=wiki rather than emit a link that 404s.
|
||||
if target.ParentType == driveUploadParentTypeExplorer {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "file", uploadResult.FileToken); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
if u, metaErr := common.FetchDriveMetaURL(runtime, uploadResult.FileToken, "file"); metaErr == nil && strings.TrimSpace(u) != "" {
|
||||
out["url"] = u
|
||||
} else if metaErr != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: uploaded file URL lookup failed: %v\n", metaErr)
|
||||
}
|
||||
if !isOverwrite {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, uploadResult.FileToken, "file"); grant != nil {
|
||||
|
||||
@@ -29,5 +29,6 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveSearch,
|
||||
DriveInspect,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+search",
|
||||
"+inspect",
|
||||
}
|
||||
|
||||
if len(got) != len(want) {
|
||||
|
||||
@@ -29,11 +29,11 @@ var ImMessagesReply = common.Shortcut{
|
||||
{Name: "content", Desc: "(one of --content/--text/--markdown/--image/--file/--video/--audio required) message content JSON"},
|
||||
{Name: "text", Desc: "plain text message (auto-wrapped as JSON)"},
|
||||
{Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"},
|
||||
{Name: "image", Desc: "image_key, local file path"},
|
||||
{Name: "file", Desc: "file_key, local file path"},
|
||||
{Name: "video", Desc: "video file_key, local file path; must be used together with --video-cover"},
|
||||
{Name: "video-cover", Desc: "video cover image_key, local file path; required when using --video"},
|
||||
{Name: "audio", Desc: "audio file_key, local file path"},
|
||||
{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
|
||||
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
|
||||
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
|
||||
},
|
||||
|
||||
@@ -33,11 +33,11 @@ var ImMessagesSend = common.Shortcut{
|
||||
{Name: "text", Desc: "plain text message (auto-wrapped as JSON)"},
|
||||
{Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
|
||||
{Name: "image", Desc: "image_key, local file path"},
|
||||
{Name: "file", Desc: "file_key, local file path"},
|
||||
{Name: "video", Desc: "video file_key, local file path; must be used together with --video-cover"},
|
||||
{Name: "video-cover", Desc: "video cover image_key, local file path; required when using --video"},
|
||||
{Name: "audio", Desc: "audio file_key, local file path"},
|
||||
{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
|
||||
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
|
||||
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
chatFlag := runtime.Str("chat-id")
|
||||
|
||||
@@ -6,9 +6,11 @@ package im
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestValidateMediaFlagPath(t *testing.T) {
|
||||
@@ -49,3 +51,37 @@ func TestValidateMediaFlagPath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMMediaFlagDescriptionsDocumentPathRestrictions(t *testing.T) {
|
||||
shortcuts := []struct {
|
||||
name string
|
||||
flags []common.Flag
|
||||
}{
|
||||
{name: "messages-send", flags: ImMessagesSend.Flags},
|
||||
{name: "messages-reply", flags: ImMessagesReply.Flags},
|
||||
}
|
||||
mediaFlags := []string{"image", "file", "video", "video-cover", "audio"}
|
||||
for _, sc := range shortcuts {
|
||||
for _, flagName := range mediaFlags {
|
||||
t.Run(sc.name+"/"+flagName, func(t *testing.T) {
|
||||
desc := findFlagDesc(t, sc.flags, flagName)
|
||||
for _, want := range []string{"URL", "cwd-relative local path", "absolute paths", ".. are rejected"} {
|
||||
if !strings.Contains(desc, want) {
|
||||
t.Fatalf("%s --%s description = %q, want it to mention %q", sc.name, flagName, desc, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findFlagDesc(t *testing.T, flags []common.Flag, name string) string {
|
||||
t.Helper()
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name {
|
||||
return flag.Desc
|
||||
}
|
||||
}
|
||||
t.Fatalf("flag %q not found", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -166,6 +166,7 @@ type DraftProjection struct {
|
||||
LargeAttachmentsSummary []LargeAttachmentSummary `json:"large_attachments_summary,omitempty"`
|
||||
InlineSummary []PartSummary `json:"inline_summary,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
type Patch struct {
|
||||
|
||||
@@ -140,9 +140,53 @@ func Project(snapshot *DraftSnapshot) DraftProjection {
|
||||
|
||||
proj.LargeAttachmentsSummary = projectLargeAttachments(snapshot.Headers, htmlBody)
|
||||
|
||||
proj.Priority = parsePriorityFromHeaders(snapshot.Headers)
|
||||
|
||||
return proj
|
||||
}
|
||||
|
||||
// parsePriorityFromHeaders derives the read-side priority projection from
|
||||
// EML headers. It mirrors the write-side helper helpers.go:parsePriority
|
||||
// (which translates --set-priority high|normal|low into set_header /
|
||||
// remove_header X-Cli-Priority ops). Lookup order is case-insensitive
|
||||
// via headerValue:
|
||||
// 1. X-Cli-Priority (CLI/OAPI-specific header recognised by
|
||||
// mail-data-access headersToPbBodyExtra)
|
||||
// 2. X-Priority (RFC standard, fallback for IMAP-回灌 historical drafts)
|
||||
//
|
||||
// When neither header is present (including after the write-side translates
|
||||
// --set-priority normal into remove_header X-Cli-Priority), this returns
|
||||
// "normal" — absence of a priority header is the standard email convention
|
||||
// for normal priority. Agents cannot distinguish "explicitly normal" from
|
||||
// "never set" — known limitation.
|
||||
func parsePriorityFromHeaders(headers []Header) string {
|
||||
if v := headerValue(headers, "X-Cli-Priority"); v != "" {
|
||||
return mapPriorityValue(v)
|
||||
}
|
||||
if v := headerValue(headers, "X-Priority"); v != "" {
|
||||
return mapPriorityValue(v)
|
||||
}
|
||||
return "normal"
|
||||
}
|
||||
|
||||
// mapPriorityValue normalises a raw priority header value to the projection
|
||||
// vocabulary {"high","normal","low","unknown"}. The accepted input table is
|
||||
// kept in sync with backend gopkg/mail_priority.PriorityValueToType so that
|
||||
// CLI read-side projection observes the same set of values the server
|
||||
// recognises on write.
|
||||
func mapPriorityValue(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "high", "1 (highest)":
|
||||
return "high"
|
||||
case "3", "normal", "3 (normal)":
|
||||
return "normal"
|
||||
case "5", "low", "5 (lowest)":
|
||||
return "low"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// projectLargeAttachments extracts large attachment info from the draft.
|
||||
// It first tries the server-format header (X-Lark-Large-Attachment) which
|
||||
// carries filename and size directly. Falls back to merging CLI-format
|
||||
|
||||
@@ -178,6 +178,170 @@ func TestSplitAtQuoteFalsePositivePlainText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Priority projection (X-Cli-Priority primary, X-Priority fallback)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestProjectPriorityXCliPriorityHigh(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority high
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: 1
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "high" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "high")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityFallbackXPriorityLow(t *testing.T) {
|
||||
// Only the standard X-Priority header is present (e.g. an IMAP-回灌
|
||||
// historical draft). The fallback path should kick in.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority low (fallback)
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Priority: 5
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "low" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "low")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityBothAbsentNormal(t *testing.T) {
|
||||
// Neither header is present — default priority is normal.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: no priority
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "normal" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityXCliPriorityOutlookStyleHigh(t *testing.T) {
|
||||
// X-Cli-Priority set to the Outlook-style string "high" (any case).
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority high (string)
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: High
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "high" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "high")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityUnmappedValueUnknown(t *testing.T) {
|
||||
// Value outside the recognised mapping table (e.g. "urgent") falls
|
||||
// back to "unknown".
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority urgent
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: urgent
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "unknown" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityXCliPriorityWinsOverXPriority(t *testing.T) {
|
||||
// X-Cli-Priority must take precedence over X-Priority when both are
|
||||
// set (defensive: agent or upstream may write both).
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: both headers
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: 1
|
||||
X-Priority: 5
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "high" {
|
||||
t.Fatalf("Priority = %q, want %q (X-Cli-Priority must win)", proj.Priority, "high")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityNormalThree(t *testing.T) {
|
||||
// X-Cli-Priority=3 → "normal" (rare in CLI write path since
|
||||
// `--set-priority normal` actually removes the header, but this case
|
||||
// covers e.g. a draft set by another OAPI client that wrote 3).
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority three
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Cli-Priority: 3
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "normal" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityFallbackXPriorityNormalString(t *testing.T) {
|
||||
// IMAP-回灌 / external client writes the RFC-standard `X-Priority: Normal`
|
||||
// string. The fallback path must project this as "normal" — symmetric with
|
||||
// how `X-Priority: High` / `Low` are already handled.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority normal (fallback)
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Priority: Normal
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "normal" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPriorityOutlookStyleThreeNormal(t *testing.T) {
|
||||
// Outlook-style `3 (Normal)` parenthesised form — symmetric with the
|
||||
// already-supported `1 (Highest)` / `5 (Lowest)`.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: priority three (normal)
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
X-Priority: 3 (Normal)
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
hello
|
||||
`)
|
||||
proj := Project(snapshot)
|
||||
if proj.Priority != "normal" {
|
||||
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMissingInlineCIDReportedAsProjectionWarning(t *testing.T) {
|
||||
// Missing CID references should NOT prevent parsing; they are reported
|
||||
// as warnings in Project() instead.
|
||||
|
||||
@@ -2602,3 +2602,14 @@ func buildCalendarBody(runtime *common.RuntimeContext, senderEmail string, toAdd
|
||||
senderEmail, toAddrs, ccAddrs,
|
||||
)
|
||||
}
|
||||
|
||||
// validateBotMailboxNotMe rejects the combination of bot identity with --mailbox me.
|
||||
// bot uses tenant access token; "me" cannot be resolved to a user mailbox under TAT.
|
||||
func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
|
||||
if runtime.IsBot() && runtime.Str("mailbox") == "me" {
|
||||
return output.ErrValidation(
|
||||
"--as bot does not support --mailbox me: bot identity uses a tenant token and cannot resolve \"me\" to a user mailbox; " +
|
||||
"pass an explicit email address, e.g. --mailbox alice@example.com")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -293,6 +293,9 @@ func executeDraftInspect(runtime *common.RuntimeContext, mailboxID, draftID stri
|
||||
if len(projection.Warnings) > 0 {
|
||||
fmt.Fprintf(w, "warnings: %s\n", sanitizeForTerminal(strings.Join(projection.Warnings, "; ")))
|
||||
}
|
||||
if projection.Priority != "" {
|
||||
fmt.Fprintf(w, "priority: %s\n", sanitizeForTerminal(projection.Priority))
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -553,6 +556,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
"`add_inline`/`replace_inline`/`remove_inline` are for CID-based inline images",
|
||||
"`replace_inline` keeps the original filename and content_type when those fields are omitted",
|
||||
"protected headers require `allow_protected_header_edits=true`",
|
||||
"--set-priority high|normal|low controls draft priority via X-Cli-Priority header (CLI/OAPI specific). high → set_header X-Cli-Priority=1; low → set_header X-Cli-Priority=5; normal → remove_header X-Cli-Priority. Backend mail-data-access headersToPbBodyExtra recognizes X-Cli-Priority but not standard X-Priority/Importance for OAPI flow.",
|
||||
},
|
||||
"command_example": "lark-cli mail +draft-edit --print-patch-template",
|
||||
"patch_file_example": "lark-cli mail +draft-edit --draft-id d_xxx --patch-file ./patch.json",
|
||||
|
||||
@@ -26,6 +26,9 @@ var MailMessage = common.Shortcut{
|
||||
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(runtime)
|
||||
messageID := runtime.Str("message-id")
|
||||
|
||||
@@ -34,6 +34,9 @@ var MailMessages = common.Shortcut{
|
||||
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(runtime)
|
||||
messageIDs := splitByComma(runtime.Str("message-ids"))
|
||||
|
||||
130
shortcuts/mail/mail_shortcut_validation_test.go
Normal file
130
shortcuts/mail/mail_shortcut_validation_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// assertValidationError fails the test unless err is a *output.ExitError with
|
||||
// ExitValidation code whose message contains wantSubstr.
|
||||
func assertValidationError(t *testing.T, err error, wantSubstr string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("expected exit code %d (ExitValidation), got %d", output.ExitValidation, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
|
||||
t.Errorf("expected detail type \"validation\", got %+v", exitErr.Detail)
|
||||
}
|
||||
if wantSubstr != "" && !strings.Contains(exitErr.Error(), wantSubstr) {
|
||||
t.Errorf("expected error message to contain %q, got: %v", wantSubstr, exitErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// assertValidatePasses fails the test if err is a validation error; other
|
||||
// errors (e.g. API call failures from missing tokens) are acceptable because
|
||||
// we only care that the Validate callback passed.
|
||||
func assertValidatePasses(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Code == output.ExitValidation {
|
||||
t.Fatalf("Validate callback should have passed but returned validation error: %v", exitErr)
|
||||
}
|
||||
// Non-validation errors (auth/API failures) are expected without HTTP mocks.
|
||||
}
|
||||
|
||||
// TC-1: +message --as bot --mailbox me → ErrValidation
|
||||
func TestMailMessageBotMailboxMeReturnsValidationError(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessage, []string{
|
||||
"+message", "--as", "bot", "--mailbox", "me", "--message-id", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidationError(t, err, "does not support --mailbox me")
|
||||
}
|
||||
|
||||
// TC-2: +message --as bot --mailbox explicit → Validate passes
|
||||
func TestMailMessageBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessage, []string{
|
||||
"+message", "--as", "bot", "--mailbox", "alice@example.com", "--message-id", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
|
||||
// TC-3: +message --as user --mailbox me → Validate passes
|
||||
func TestMailMessageUserMailboxMePassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessage, []string{
|
||||
"+message", "--as", "user", "--mailbox", "me", "--message-id", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
|
||||
// TC-4: +messages --as bot (default mailbox=me) → ErrValidation
|
||||
func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--as", "bot", "--message-ids", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidationError(t, err, "does not support --mailbox me")
|
||||
}
|
||||
|
||||
// TC-5: +messages --as bot --mailbox explicit → Validate passes
|
||||
func TestMailMessagesBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", "msg_xxx",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
|
||||
// TC-6: +thread --as bot (default mailbox=me) → ErrValidation
|
||||
func TestMailThreadBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailThread, []string{
|
||||
"+thread", "--as", "bot", "--thread-id", "thread_xxx",
|
||||
}, f, stdout)
|
||||
assertValidationError(t, err, "does not support --mailbox me")
|
||||
}
|
||||
|
||||
// TC-7: +thread --as bot --mailbox explicit → Validate passes
|
||||
func TestMailThreadBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailThread, []string{
|
||||
"+thread", "--as", "bot", "--mailbox", "alice@example.com", "--thread-id", "thread_xxx",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
|
||||
// TC-8: +triage --as bot (default mailbox=me) → ErrValidation
|
||||
func TestMailTriageBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--as", "bot",
|
||||
}, f, stdout)
|
||||
assertValidationError(t, err, "does not support --mailbox me")
|
||||
}
|
||||
|
||||
// TC-9: +triage --as bot --mailbox explicit → Validate passes
|
||||
func TestMailTriageBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--as", "bot", "--mailbox", "alice@example.com",
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
@@ -75,6 +75,9 @@ var MailTemplateCreate = common.Shortcut{
|
||||
return api
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateBotMailboxNotMe(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return output.ErrValidation("--name is required")
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@ var MailTemplateUpdate = common.Shortcut{
|
||||
if runtime.Bool("print-patch-template") {
|
||||
return nil
|
||||
}
|
||||
if err := validateBotMailboxNotMe(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTemplateID(runtime.Str("template-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ var MailThread = common.Shortcut{
|
||||
{Name: "include-spam-trash", Type: "bool", Desc: "Also return messages from SPAM and TRASH folders (excluded by default)"},
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(runtime)
|
||||
threadID := runtime.Str("thread-id")
|
||||
|
||||
@@ -64,6 +64,9 @@ var MailTriage = common.Shortcut{
|
||||
{Name: "labels", Type: "bool", Desc: "include label IDs in output"},
|
||||
{Name: "print-filter-schema", Type: "bool", Desc: "print --filter field reference and exit"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailbox := resolveMailboxID(runtime)
|
||||
query := runtime.Str("query")
|
||||
|
||||
@@ -24,10 +24,16 @@ import (
|
||||
const markdownSinglePartSizeLimit = common.MaxDriveMediaUploadSinglePartSize
|
||||
const markdownEmptyContentError = "empty markdown content is not supported; cannot create or overwrite an empty file"
|
||||
|
||||
const (
|
||||
markdownUploadParentTypeExplorer = "explorer"
|
||||
markdownUploadParentTypeWiki = "wiki"
|
||||
)
|
||||
|
||||
type markdownUploadSpec struct {
|
||||
FileToken string
|
||||
FileName string
|
||||
FolderToken string
|
||||
WikiToken string
|
||||
FilePath string
|
||||
Content string
|
||||
ContentSet bool
|
||||
@@ -45,6 +51,25 @@ type markdownMultipartSession struct {
|
||||
BlockNum int
|
||||
}
|
||||
|
||||
type markdownUploadTarget struct {
|
||||
ParentType string
|
||||
ParentNode string
|
||||
}
|
||||
|
||||
func (spec markdownUploadSpec) Target() markdownUploadTarget {
|
||||
if spec.WikiToken != "" {
|
||||
return markdownUploadTarget{
|
||||
ParentType: markdownUploadParentTypeWiki,
|
||||
ParentNode: spec.WikiToken,
|
||||
}
|
||||
}
|
||||
// An empty explorer parent node uploads to the user's Drive root folder.
|
||||
return markdownUploadTarget{
|
||||
ParentType: markdownUploadParentTypeExplorer,
|
||||
ParentNode: spec.FolderToken,
|
||||
}
|
||||
}
|
||||
|
||||
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
|
||||
switch {
|
||||
case spec.ContentSet && spec.FileSet:
|
||||
@@ -53,14 +78,32 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
|
||||
return common.FlagErrorf("specify exactly one of --content or --file")
|
||||
}
|
||||
|
||||
if runtime.Changed("folder-token") && strings.TrimSpace(spec.FolderToken) == "" {
|
||||
if markdownFlagExplicitlyEmpty(runtime, "folder-token") {
|
||||
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
|
||||
}
|
||||
if markdownFlagExplicitlyEmpty(runtime, "wiki-token") {
|
||||
return common.FlagErrorf("--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
|
||||
}
|
||||
targets := 0
|
||||
if spec.FolderToken != "" {
|
||||
targets++
|
||||
}
|
||||
if spec.WikiToken != "" {
|
||||
targets++
|
||||
}
|
||||
if targets > 1 {
|
||||
return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive")
|
||||
}
|
||||
if spec.FolderToken != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
if spec.WikiToken != "" {
|
||||
if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if requireName && spec.ContentSet {
|
||||
if strings.TrimSpace(spec.FileName) == "" {
|
||||
@@ -92,6 +135,10 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
|
||||
return nil
|
||||
}
|
||||
|
||||
func markdownFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName string) bool {
|
||||
return runtime.Changed(flagName) && strings.TrimSpace(runtime.Str(flagName)) == ""
|
||||
}
|
||||
|
||||
func validateMarkdownFileName(name, flagName string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
@@ -137,11 +184,19 @@ func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, f
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, output.ErrNetwork("download failed: %s", err)
|
||||
return nil, wrapMarkdownDownloadError(err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func wrapMarkdownDownloadError(err error) error {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
|
||||
func validateNonEmptyMarkdownSize(size int64) error {
|
||||
if size == 0 {
|
||||
return output.ErrValidation("%s", markdownEmptyContentError)
|
||||
@@ -170,6 +225,24 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (*http.Response, string, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
req.QueryParams = larkcore.QueryParams{
|
||||
"version": []string{strings.TrimSpace(version)},
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", wrapMarkdownDownloadError(err)
|
||||
}
|
||||
return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil
|
||||
}
|
||||
|
||||
func markdownDryRunFileField(spec markdownUploadSpec) string {
|
||||
if spec.FilePath != "" {
|
||||
return "@" + spec.FilePath
|
||||
@@ -179,12 +252,13 @@ func markdownDryRunFileField(spec markdownUploadSpec) string {
|
||||
|
||||
func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
target := spec.Target()
|
||||
|
||||
if !multipart {
|
||||
body := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"size": fileSize,
|
||||
"file": markdownDryRunFileField(spec),
|
||||
}
|
||||
@@ -205,8 +279,8 @@ func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart boo
|
||||
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"size": fileSize,
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
@@ -241,6 +315,7 @@ func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart boo
|
||||
|
||||
func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
|
||||
fileName := strings.TrimSpace(spec.FileName)
|
||||
target := spec.Target()
|
||||
if fileName == "" && spec.FileSet {
|
||||
fileName = finalMarkdownFileName(spec)
|
||||
}
|
||||
@@ -267,8 +342,8 @@ func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart
|
||||
Desc("[2] Overwrite file contents with multipart/form-data upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.FileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"size": fileSize,
|
||||
"file": markdownDryRunFileField(spec),
|
||||
"file_token": spec.FileToken,
|
||||
@@ -280,8 +355,8 @@ func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart
|
||||
Desc("[2] Initialize multipart overwrite upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.FileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"size": fileSize,
|
||||
"file_token": spec.FileToken,
|
||||
}).
|
||||
@@ -326,10 +401,11 @@ func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUpload
|
||||
}
|
||||
|
||||
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
target := spec.Target()
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", "explorer")
|
||||
fd.AddField("parent_node", spec.FolderToken)
|
||||
fd.AddField("parent_type", target.ParentType)
|
||||
fd.AddField("parent_node", target.ParentNode)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
if spec.FileToken != "" {
|
||||
fd.AddField("file_token", spec.FileToken)
|
||||
@@ -357,10 +433,11 @@ func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSp
|
||||
}
|
||||
|
||||
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
target := spec.Target()
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"size": fileSize,
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
|
||||
@@ -5,6 +5,7 @@ package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
@@ -16,19 +17,25 @@ var MarkdownCreate = common.Shortcut{
|
||||
Command: "+create",
|
||||
Description: "Create a Markdown file in Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:file:upload"},
|
||||
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "folder-token", Desc: "target Drive folder token (default: root folder)"},
|
||||
{Name: "folder-token", Desc: "target Drive folder token (default: root folder; mutually exclusive with --wiki-token)"},
|
||||
{Name: "wiki-token", Desc: "target wiki node token (uploads under that wiki node; mutually exclusive with --folder-token)"},
|
||||
{Name: "name", Desc: "file name with .md suffix; required with --content, optional with --file"},
|
||||
{Name: "content", Desc: "Markdown content", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "file", Desc: "local .md file path"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
|
||||
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
@@ -39,6 +46,7 @@ var MarkdownCreate = common.Shortcut{
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
@@ -48,12 +56,25 @@ var MarkdownCreate = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return markdownUploadDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
|
||||
dry := markdownUploadDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
|
||||
dry.POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("Fetch the created Markdown file's real access URL").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": "<file_token from upload response>",
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
"with_url": true,
|
||||
})
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
@@ -79,8 +100,10 @@ var MarkdownCreate = common.Shortcut{
|
||||
"file_name": finalMarkdownFileName(spec),
|
||||
"size_bytes": fileSize,
|
||||
}
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "file", result.FileToken); u != "" {
|
||||
if u, metaErr := common.FetchDriveMetaURL(runtime, result.FileToken, "file"); metaErr == nil && strings.TrimSpace(u) != "" {
|
||||
out["url"] = u
|
||||
} else if metaErr != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: created Markdown file URL lookup failed: %v\n", metaErr)
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, result.FileToken, "file"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user