mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
46 Commits
feat/artif
...
v1.0.40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b25ff1ced5 | ||
|
|
aea9f37f58 | ||
|
|
ac06eaa0f4 | ||
|
|
282c27784d | ||
|
|
f2a4c95665 | ||
|
|
cb5055eb46 | ||
|
|
9d4233bfe3 | ||
|
|
708cbc2b31 | ||
|
|
6d1f9980fa | ||
|
|
6e3e120ec8 | ||
|
|
ce5b4f24e1 | ||
|
|
4b2223194b | ||
|
|
4582dfd281 | ||
|
|
5c01a7f7f0 | ||
|
|
d5d2fee848 | ||
|
|
ffcf7781b4 | ||
|
|
fbe4cc689a | ||
|
|
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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -42,3 +42,5 @@ app.log
|
||||
/server-demo
|
||||
.tmp/
|
||||
cover*.out
|
||||
|
||||
lark-env.sh
|
||||
|
||||
122
CHANGELOG.md
122
CHANGELOG.md
@@ -2,6 +2,121 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.40] - 2026-05-25
|
||||
|
||||
### Features
|
||||
|
||||
- **wiki**: Add exponential backoff retry for `+node-create` lock contention (#1012)
|
||||
- **auth**: Add `auth qrcode` subcommand and update auth docs/hints (#968)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **wiki**: Rename `+node-get --token` to `--node-token`, keep alias (#1074)
|
||||
- **output**: Classify wiki lock-contention error (131009) with retry hint (#1014)
|
||||
- **contact**: Add actionable hint when fanout search all-fail with no API code (#1054)
|
||||
- **permission**: Annotate auto-grant permission failures with `required_scope` and `console_url` (#1045)
|
||||
- **validation**: Use `ErrValidation` instead of `fmt.Errorf` in `Validate` paths (#1001)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Add 云盘/云存储 alias alongside 云空间 for agent clarity (#1073)
|
||||
- **task**: Refresh `lark-task` shortcut docs (#1057)
|
||||
|
||||
## [v1.0.39] - 2026-05-22
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+export` shortcut to export slides (#988)
|
||||
- **sidecar**: Support multi-client identity isolation in `server-demo` via per-client HMAC keys, preventing UAT cross-contamination when multiple CLI sandboxes share one sidecar (#934)
|
||||
- **im**: Support Markdown image rendering in post content (#893)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scope**: Add 22 new scope entries to scope priorities (#1050)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Update location `full_address` guidance (#754)
|
||||
- **apps**: Refine `lark-apps` skill description and surface, document `index.html` / `--path` hard constraints (#1040)
|
||||
|
||||
## [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 +860,13 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
|
||||
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
|
||||
[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 页面和应用 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthList(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthCheck(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"only delivers final turn messages",
|
||||
"--no-wait --json",
|
||||
"send the verification URL to the user as your final message",
|
||||
"send the verification URL (or QR code) to the user as your final message",
|
||||
"run --device-code in a later step",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -47,9 +47,10 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
|
||||
Long: `Device Flow authorization login.
|
||||
|
||||
For AI agents: this command blocks until the user completes authorization in the
|
||||
browser. If your harness only delivers final turn messages, use --no-wait --json,
|
||||
send the verification URL to the user as your final message, end the turn, then
|
||||
run --device-code in a later step after the user confirms authorization.`,
|
||||
browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json,
|
||||
send the verification URL (or QR code) to the user as your final message, end the turn, then
|
||||
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
|
||||
to generate QR codes (supports ASCII and PNG formats).`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return output.ErrWithHint(output.ExitValidation, "command_denied",
|
||||
@@ -68,7 +69,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 +146,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 +177,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 +215,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"
|
||||
@@ -269,7 +276,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the URL the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
|
||||
"hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL so the user can either scan or copy the link.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. Prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
@@ -452,6 +459,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncLoginUserToProfile persists the logged-in user info into the named profile.
|
||||
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
@@ -477,6 +485,7 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// findProfileByName returns the AppConfig matching profileName, or nil.
|
||||
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].ProfileName() == profileName {
|
||||
@@ -490,7 +499,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 +518,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 +540,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 +548,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 +559,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)
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 只会把最终回复发给用户,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 和 verification_url,把 verification_url 作为本轮最终消息原样发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 `lark-cli auth login --device-code <code>` 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL,把它视为不可修改的 opaque string;不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url,把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code <code>\" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output);仅当用户明确要求时才使用 ASCII(--ascii)。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL,再将二维码图片置于 URL 下方完整展示,确保用户可扫码或复制链接。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string,不要做任何修改(包括 URL 编码/解码、添加空格或标点),建议用仅包含该 URL 的代码块单独输出。",
|
||||
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
@@ -95,7 +95,7 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness only delivers final turn messages, use `lark-cli auth login --no-wait --json` to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run `lark-cli auth login --device-code <code>` in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
|
||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code <code>\" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL so the user can either scan or copy the link.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. Prefer a fenced code block containing only the raw URL.",
|
||||
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
@@ -114,6 +114,7 @@ var loginMsgEn = &loginMsg{
|
||||
HintFooter: " lark-cli auth login --help",
|
||||
}
|
||||
|
||||
// getLoginMsg returns the login message bundle for the given language.
|
||||
func getLoginMsg(lang string) *loginMsg {
|
||||
if lang == "en" {
|
||||
return loginMsgEn
|
||||
@@ -125,5 +126,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))
|
||||
}
|
||||
@@ -945,12 +945,20 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
||||
}
|
||||
hint, _ := data["hint"].(string)
|
||||
for _, want := range []string{
|
||||
"exactly as returned by the CLI",
|
||||
"MUST generate QR code AND display it",
|
||||
"lark-cli auth qrcode",
|
||||
"Prefer PNG QR code (--output)",
|
||||
"use ASCII (--ascii) only when the user explicitly requests it",
|
||||
"This is a required step, do NOT skip it",
|
||||
"CRITICAL",
|
||||
"You MUST include the QR image in your response",
|
||||
"Generating the file alone is NOT enough",
|
||||
"image tags, inline images, or file attachments",
|
||||
"Display order",
|
||||
"place the QR code image below the URL",
|
||||
"opaque string",
|
||||
"Do not URL-encode or decode it",
|
||||
"do not add %20, spaces, or punctuation",
|
||||
"do not wrap it as Markdown link text",
|
||||
"fenced code block containing only the raw URL",
|
||||
"cannot be modified",
|
||||
"Prefer a fenced code block",
|
||||
"final message of the turn",
|
||||
"return control to the user",
|
||||
"do not block on --device-code in the same turn",
|
||||
@@ -1054,12 +1062,18 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *
|
||||
"结束本轮",
|
||||
"用户回复已完成授权",
|
||||
"不要在同一轮里展示 URL 后立刻阻塞执行 --device-code",
|
||||
"逐字原样转发 CLI 返回的 URL",
|
||||
"必须生成二维码并展示",
|
||||
"lark-cli auth qrcode",
|
||||
"优先生成 PNG 二维码(--output)",
|
||||
"仅当用户明确要求时才使用 ASCII(--ascii)",
|
||||
"生成后必须在回复中展示图片",
|
||||
"仅生成文件不算完成",
|
||||
"image 标签或内联图片",
|
||||
"二维码图片置于 URL 下方完整展示",
|
||||
"URL 输出规则",
|
||||
"opaque string",
|
||||
"不要做 URL 编码或解码",
|
||||
"不要补 `%20`、空格或标点",
|
||||
"不要改写成 Markdown 链接",
|
||||
"只包含该 URL 的代码块单独输出",
|
||||
"不要做任何修改",
|
||||
"仅包含该 URL 的代码块",
|
||||
} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
|
||||
@@ -1077,7 +1091,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 +1101,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 {
|
||||
|
||||
142
cmd/auth/qrcode.go
Normal file
142
cmd/auth/qrcode.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// QRCodeOptions holds inputs for auth qrcode command.
|
||||
type QRCodeOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
URL string
|
||||
Size int
|
||||
ASCII bool
|
||||
Output string
|
||||
}
|
||||
|
||||
// NewCmdAuthQRCode creates the auth qrcode subcommand.
|
||||
func NewCmdAuthQRCode(f *cmdutil.Factory, runF func(*QRCodeOptions) error) *cobra.Command {
|
||||
opts := &QRCodeOptions{Factory: f, Size: 256}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "qrcode <url>",
|
||||
Short: "Generate QR code for verification URL",
|
||||
Long: `Generate a QR code image or ASCII representation for a verification URL.
|
||||
|
||||
This command is designed for AI agents to generate QR codes for OAuth authorization URLs.
|
||||
|
||||
For PNG output, the --output flag is required to specify the output file path (must be a relative path within the current directory).
|
||||
For ASCII output, the result is printed to stdout with fixed size.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.URL = args[0]
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runQRCode(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
|
||||
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runQRCode executes the auth qrcode command.
|
||||
func runQRCode(opts *QRCodeOptions) error {
|
||||
if opts.URL == "" {
|
||||
return output.Errorf(output.ExitValidation, "missing_url", "url is required")
|
||||
}
|
||||
|
||||
if opts.ASCII {
|
||||
var out io.Writer = os.Stdout
|
||||
if opts.Factory != nil {
|
||||
out = opts.Factory.IOStreams.Out
|
||||
}
|
||||
return generateASCIIQRCode(opts.URL, out)
|
||||
}
|
||||
|
||||
if opts.Output == "" {
|
||||
return output.Errorf(output.ExitValidation, "missing_output", "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.")
|
||||
}
|
||||
|
||||
if opts.Size < 32 {
|
||||
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at least 32, got %d", opts.Size))
|
||||
}
|
||||
|
||||
if opts.Size > 1024 {
|
||||
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at most 1024, got %d", opts.Size))
|
||||
}
|
||||
|
||||
safePath, err := validate.SafeOutputPath(opts.Output)
|
||||
if err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"ok": true,
|
||||
"file_path": safePath,
|
||||
"hint": "You MUST include the QR image in your response. Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.",
|
||||
}
|
||||
|
||||
var out io.Writer = os.Stdout
|
||||
if opts.Factory != nil {
|
||||
out = opts.Factory.IOStreams.Out
|
||||
}
|
||||
encoder := json.NewEncoder(out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(result); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write output: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateImageQRCode encodes the URL as a PNG QR code and writes it to outputPath.
|
||||
func generateImageQRCode(url string, size int, outputPath string) error {
|
||||
png, err := qrcode.Encode(url, qrcode.Medium, size)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to encode QR code: %v", err))
|
||||
}
|
||||
|
||||
err = vfs.WriteFile(outputPath, png, 0644)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "write_error", fmt.Sprintf("failed to write QR code to %s: %v", outputPath, err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateASCIIQRCode encodes the URL as an ASCII QR code and prints it to stdout.
|
||||
func generateASCIIQRCode(url string, w io.Writer) error {
|
||||
q, err := qrcode.New(url, qrcode.Medium)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to create QR code: %v", err))
|
||||
}
|
||||
|
||||
fmt.Fprint(w, q.ToSmallString(false))
|
||||
|
||||
return nil
|
||||
}
|
||||
368
cmd/auth/qrcode_test.go
Normal file
368
cmd/auth/qrcode_test.go
Normal file
@@ -0,0 +1,368 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png", "--size", "128"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.URL != "https://example.com" {
|
||||
t.Errorf("URL = %q, want %q", gotOpts.URL, "https://example.com")
|
||||
}
|
||||
if gotOpts.Size != 128 {
|
||||
t.Errorf("Size = %d, want %d", gotOpts.Size, 128)
|
||||
}
|
||||
if gotOpts.Output != "qr.png" {
|
||||
t.Errorf("Output = %q, want %q", gotOpts.Output, "qr.png")
|
||||
}
|
||||
if gotOpts.ASCII {
|
||||
t.Error("ASCII should be false by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"https://example.com", "--ascii"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !gotOpts.ASCII {
|
||||
t.Error("ASCII should be true when --ascii is passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_DefaultSize(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"https://example.com", "--ascii"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.Size != 256 {
|
||||
t.Errorf("default Size = %d, want 256", gotOpts.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_ExactOneArg(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error when no URL argument provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_RunE_PNGEndToEnd(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
tmpDir := t.TempDir()
|
||||
oldWd, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(oldWd) })
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("qr.png")
|
||||
if err != nil {
|
||||
t.Fatalf("output file not created: %v", err)
|
||||
}
|
||||
if string(data[:4]) != "\x89PNG" {
|
||||
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v, got: %s", err, stdout.String())
|
||||
}
|
||||
if result["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", result["ok"])
|
||||
}
|
||||
hint, _ := result["hint"].(string)
|
||||
if hint == "" {
|
||||
t.Error("hint is empty")
|
||||
}
|
||||
if !strings.Contains(hint, "MUST include") {
|
||||
t.Errorf("hint missing 'MUST include', got: %s", hint)
|
||||
}
|
||||
if !strings.Contains(hint, "NOT enough") {
|
||||
t.Errorf("hint missing 'NOT enough', got: %s", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_RunE_MissingOutput(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"https://example.com"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error when --output is missing in PNG mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"qrcode <url>",
|
||||
"QR code",
|
||||
"--output",
|
||||
"--ascii",
|
||||
"relative path",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("help missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_MissingURL(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{URL: ""})
|
||||
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("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "missing_url" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_MissingOutput(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
|
||||
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("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "missing_output" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_InvalidSize(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 16,
|
||||
Output: "qr.png",
|
||||
})
|
||||
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("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "invalid_size" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_SizeTooLarge(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 2048,
|
||||
Output: "qr.png",
|
||||
})
|
||||
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("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "invalid_size" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 256,
|
||||
Output: "/etc/passwd",
|
||||
})
|
||||
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("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_PNGWritesFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
tmpDir := t.TempDir()
|
||||
oldWd, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(oldWd) })
|
||||
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 256,
|
||||
Output: "qr.png",
|
||||
Factory: f,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat("qr.png")
|
||||
if err != nil {
|
||||
t.Fatalf("output file not created: %v", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Error("output file is empty")
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if jsonErr := json.Unmarshal(stdout.Bytes(), &result); jsonErr != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v, got: %s", jsonErr, stdout.String())
|
||||
}
|
||||
if result["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", result["ok"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_ASCIIOutputsToStdout(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
ASCII: true,
|
||||
Factory: f,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() == 0 {
|
||||
t.Error("ASCII QR code produced no output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImageQRCode_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "test-qr.png")
|
||||
|
||||
if err := generateImageQRCode("https://example.com", 256, outputPath); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output file: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("output file is empty")
|
||||
}
|
||||
if len(data) < 8 {
|
||||
t.Error("output too small to be a valid PNG")
|
||||
}
|
||||
if string(data[:4]) != "\x89PNG" {
|
||||
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImageQRCode_WriteError(t *testing.T) {
|
||||
err := generateImageQRCode("https://example.com", 256, "/nonexistent/deep/nested/dir/qr.png")
|
||||
if err == nil {
|
||||
t.Fatal("expected error writing to nonexistent directory")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
|
||||
}
|
||||
if exitErr.Detail.Type != "write_error" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "write_error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateASCIIQRCode_Success(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
err := generateASCIIQRCode("https://example.com", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Error("ASCII QR code produced no output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
err := generateASCIIQRCode("", &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty string")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "encode_error" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "encode_error")
|
||||
}
|
||||
}
|
||||
43
cmd/root.go
43
cmd/root.go
@@ -10,7 +10,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -389,8 +388,8 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "permission" {
|
||||
return
|
||||
}
|
||||
// Extract required scopes from API error detail
|
||||
scopes := extractRequiredScopes(exitErr.Detail.Detail)
|
||||
// Extract required scopes from API error detail (shared helper)
|
||||
scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
|
||||
if len(scopes) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -401,21 +400,10 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
}
|
||||
|
||||
// Select the recommended (least-privilege) scope
|
||||
scopeIfaces := make([]interface{}, len(scopes))
|
||||
for i, s := range scopes {
|
||||
scopeIfaces[i] = s
|
||||
}
|
||||
recommended := registry.SelectRecommendedScope(scopeIfaces, "tenant")
|
||||
if recommended == "" {
|
||||
recommended = scopes[0]
|
||||
}
|
||||
recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant")
|
||||
|
||||
// Build admin console URL with the recommended scope
|
||||
host := "open.feishu.cn"
|
||||
if cfg.Brand == "lark" {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
consoleURL := fmt.Sprintf("https://%s/page/scope-apply?clientID=%s&scopes=%s", host, url.QueryEscape(cfg.AppID), url.QueryEscape(recommended))
|
||||
consoleURL := registry.BuildConsoleScopeURL(cfg.Brand, cfg.AppID, recommended)
|
||||
|
||||
// Clear raw API detail — useful info is now in message/hint/console_url
|
||||
exitErr.Detail.Detail = nil
|
||||
@@ -452,26 +440,3 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
exitErr.Detail.ConsoleURL = consoleURL
|
||||
}
|
||||
}
|
||||
|
||||
// extractRequiredScopes extracts scope names from the API error's permission_violations field.
|
||||
func extractRequiredScopes(detail interface{}) []string {
|
||||
m, ok := detail.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
violations, ok := m["permission_violations"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var scopes []string
|
||||
for _, v := range violations {
|
||||
vm, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if subject, ok := vm["subject"].(string); ok {
|
||||
scopes = append(scopes, subject)
|
||||
}
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
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=
|
||||
|
||||
@@ -39,6 +39,10 @@ const (
|
||||
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
|
||||
LarkErrDriveCrossBrand = 1064511 // cross brand not support
|
||||
|
||||
// Wiki write-path lock contention (e.g. concurrent wiki +node-create under the
|
||||
// same parent). Server-side write lock; transient, safe to retry with backoff.
|
||||
LarkErrWikiLockContention = 131009
|
||||
|
||||
// Sheets float image: width/height/offset out of range or invalid.
|
||||
LarkErrSheetsFloatImageInvalidDims = 1310246
|
||||
|
||||
@@ -83,6 +87,8 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
// drive-specific constraints that benefit from actionable hints
|
||||
case LarkErrDriveResourceContention:
|
||||
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
|
||||
case LarkErrWikiLockContention:
|
||||
return ExitAPI, "conflict", "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes"
|
||||
case LarkErrDriveCrossTenantUnit:
|
||||
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
|
||||
case LarkErrDriveCrossBrand:
|
||||
|
||||
@@ -90,3 +90,24 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyLarkError_WikiLockContention verifies the wiki write-lock
|
||||
// contention error (131009) maps to an actionable retry hint instead of
|
||||
// a generic "api_error". Surfaces during concurrent wiki +node-create
|
||||
// against the same parent (see larksuite/cli#1012).
|
||||
func TestClassifyLarkError_WikiLockContention(t *testing.T) {
|
||||
t.Parallel()
|
||||
gotExitCode, gotType, gotHint := ClassifyLarkError(LarkErrWikiLockContention, "raw msg")
|
||||
if gotExitCode != ExitAPI {
|
||||
t.Fatalf("exitCode=%d, want %d", gotExitCode, ExitAPI)
|
||||
}
|
||||
if gotType != "conflict" {
|
||||
t.Fatalf("type=%q, want %q", gotType, "conflict")
|
||||
}
|
||||
if !strings.Contains(gotHint, "wiki write lock") {
|
||||
t.Fatalf("hint=%q, want substring %q", gotHint, "wiki write lock")
|
||||
}
|
||||
if !strings.Contains(gotHint, "backoff") {
|
||||
t.Fatalf("hint=%q, want substring %q", gotHint, "backoff")
|
||||
}
|
||||
}
|
||||
|
||||
82
internal/registry/scope_hint.go
Normal file
82
internal/registry/scope_hint.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// ExtractRequiredScopes pulls scope names out of the API error's
|
||||
// permission_violations field. The detail argument is the raw `error` block
|
||||
// that the platform returns alongside lark code 99991672 / 99991679 — typically
|
||||
// shaped as:
|
||||
//
|
||||
// { "permission_violations": [ {"subject": "<scope>"}, ... ] }
|
||||
//
|
||||
// Returns nil when the structure does not match or no non-empty subjects are
|
||||
// present, so callers can branch on a simple len() == 0 check.
|
||||
func ExtractRequiredScopes(detail interface{}) []string {
|
||||
m, ok := detail.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
violations, ok := m["permission_violations"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
scopes := make([]string, 0, len(violations))
|
||||
for _, v := range violations {
|
||||
vm, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if subject, ok := vm["subject"].(string); ok && subject != "" {
|
||||
scopes = append(scopes, subject)
|
||||
}
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
// SelectRecommendedScopeFromStrings is a string-typed convenience wrapper
|
||||
// around SelectRecommendedScope. When no scope is recognized by the priority
|
||||
// table, it falls back to the first input scope so callers always have
|
||||
// something to surface to users.
|
||||
func SelectRecommendedScopeFromStrings(scopes []string, identity string) string {
|
||||
if len(scopes) == 0 {
|
||||
return ""
|
||||
}
|
||||
ifaces := make([]interface{}, len(scopes))
|
||||
for i, s := range scopes {
|
||||
ifaces[i] = s
|
||||
}
|
||||
if recommended := SelectRecommendedScope(ifaces, identity); recommended != "" {
|
||||
return recommended
|
||||
}
|
||||
return scopes[0]
|
||||
}
|
||||
|
||||
// BuildConsoleScopeURL returns the developer-console "apply scope" URL for the
|
||||
// given app and scope, branded for feishu / lark. Returns "" when appID or
|
||||
// scope is empty so callers can omit the field cleanly.
|
||||
func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
|
||||
if appID == "" || scope == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == core.BrandLark {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||
host,
|
||||
url.QueryEscape(appID),
|
||||
url.QueryEscape(scope),
|
||||
)
|
||||
}
|
||||
104
internal/registry/scope_hint_test.go
Normal file
104
internal/registry/scope_hint_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestExtractRequiredScopes_HappyPath(t *testing.T) {
|
||||
detail := map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "docs:permission.member:create"},
|
||||
map[string]interface{}{"subject": "docs:doc"},
|
||||
map[string]interface{}{"subject": ""}, // empty subject filtered
|
||||
"not-a-map", // ignored
|
||||
},
|
||||
}
|
||||
got := ExtractRequiredScopes(detail)
|
||||
want := []string{"docs:permission.member:create", "docs:doc"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("ExtractRequiredScopes mismatch: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRequiredScopes_NilOrMalformed(t *testing.T) {
|
||||
cases := []interface{}{
|
||||
nil,
|
||||
"plain string",
|
||||
map[string]interface{}{},
|
||||
map[string]interface{}{"permission_violations": "not-a-list"},
|
||||
map[string]interface{}{"permission_violations": []interface{}{}},
|
||||
map[string]interface{}{"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": ""},
|
||||
}},
|
||||
}
|
||||
for i, in := range cases {
|
||||
if got := ExtractRequiredScopes(in); got != nil {
|
||||
t.Errorf("case %d: expected nil, got %v", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConsoleScopeURL_BrandSpecificHost(t *testing.T) {
|
||||
got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", "docs:permission.member:create")
|
||||
if !strings.Contains(got, "open.feishu.cn") {
|
||||
t.Errorf("feishu brand should use open.feishu.cn host, got %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "clientID=cli_xxx") {
|
||||
t.Errorf("missing app id in url: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "scopes=docs%3Apermission.member%3Acreate") {
|
||||
t.Errorf("scope not URL-escaped: %s", got)
|
||||
}
|
||||
|
||||
got = BuildConsoleScopeURL(core.BrandLark, "cli_yyy", "drive:drive")
|
||||
if !strings.Contains(got, "open.larksuite.com") {
|
||||
t.Errorf("lark brand should use open.larksuite.com host, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConsoleScopeURL_EmptyInput(t *testing.T) {
|
||||
if got := BuildConsoleScopeURL(core.BrandFeishu, "", "docs:doc"); got != "" {
|
||||
t.Errorf("empty appID should yield empty url, got %s", got)
|
||||
}
|
||||
if got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", ""); got != "" {
|
||||
t.Errorf("empty scope should yield empty url, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectRecommendedScopeFromStrings_FallsBackToFirst(t *testing.T) {
|
||||
ensureFreshRegistry(t)
|
||||
// Unknown scopes (not in priority table) → fallback to first
|
||||
got := SelectRecommendedScopeFromStrings([]string{"unknown:foo", "unknown:bar"}, "tenant")
|
||||
if got != "unknown:foo" {
|
||||
t.Errorf("expected fallback to first, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// When at least one scope is recognized by the priority table, the
|
||||
// recommended scope wins over the fallback (first input).
|
||||
func TestSelectRecommendedScopeFromStrings_PicksKnownScopeOverFallback(t *testing.T) {
|
||||
ensureFreshRegistry(t)
|
||||
// docs:permission.member:create is recommended (recommend=true) in
|
||||
// scope_priorities.json. Putting an unknown scope first would otherwise
|
||||
// win via the fallback path; this ensures the priority table is consulted
|
||||
// before falling back.
|
||||
got := SelectRecommendedScopeFromStrings([]string{"unknown:foo", "docs:permission.member:create"}, "tenant")
|
||||
if got != "docs:permission.member:create" {
|
||||
t.Errorf("expected priority-table winner, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectRecommendedScopeFromStrings_Empty(t *testing.T) {
|
||||
if got := SelectRecommendedScopeFromStrings(nil, "tenant"); got != "" {
|
||||
t.Errorf("nil slice should return empty, got %s", got)
|
||||
}
|
||||
if got := SelectRecommendedScopeFromStrings([]string{}, "tenant"); got != "" {
|
||||
t.Errorf("empty slice should return empty, got %s", got)
|
||||
}
|
||||
}
|
||||
@@ -5568,5 +5568,115 @@
|
||||
"scope_name": "speech_to_text:speech",
|
||||
"final_score": "70.8755",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app:publish",
|
||||
"final_score": "75.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app.access_scope:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app.access_scope:write",
|
||||
"final_score": "83.6587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app:write",
|
||||
"final_score": "76.7173",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "docs:secure_label:write_only",
|
||||
"final_score": "75.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "corehr:job_change_v2:read",
|
||||
"final_score": "75.9982",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "corehr:pre_hire.contract_file_id:read",
|
||||
"final_score": "80.0587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.user_setting:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "minutes:minutes.upload:write",
|
||||
"final_score": "83.6587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:feed.flag:write",
|
||||
"final_score": "79.5982",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:feed.flag:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "search:bot",
|
||||
"final_score": "67.0587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "application:bot.basic_info:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "drive:quota_detail:read_one",
|
||||
"final_score": "75.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "docs:permission.member:apply",
|
||||
"final_score": "83.6587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "corehr:employment.custom_field:write",
|
||||
"final_score": "75.6587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:message.group_at_msg.include_bot:readonly",
|
||||
"final_score": "88.9982",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "okr:okr.setting:read",
|
||||
"final_score": "80.0587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "directory:employee.base.leader_id:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "directory:employee.base.dotted_line_leaders:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "directory:employee.base.active_status:read",
|
||||
"final_score": "80.0587",
|
||||
"recommend": "false"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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.40",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
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
@@ -31,10 +31,17 @@ import (
|
||||
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",
|
||||
@@ -278,7 +285,10 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
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())
|
||||
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, fileInfo.Size(), baseAttachmentUploadTarget{
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: runtime.Str("base-token"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -459,31 +469,33 @@ func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValu
|
||||
return attachments, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -70,10 +70,12 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseFormsList,
|
||||
BaseFormUpdate,
|
||||
BaseFormGet,
|
||||
BaseFormDetail,
|
||||
BaseFormQuestionsCreate,
|
||||
BaseFormQuestionsDelete,
|
||||
BaseFormQuestionsUpdate,
|
||||
BaseFormQuestionsList,
|
||||
BaseFormSubmit,
|
||||
BaseDashboardList,
|
||||
BaseDashboardGet,
|
||||
BaseDashboardCreate,
|
||||
|
||||
@@ -3,29 +3,61 @@
|
||||
|
||||
package common
|
||||
|
||||
// FetchDriveMetaTitle looks up the document title via the drive metas batch_query API.
|
||||
func FetchDriveMetaTitle(runtime *RuntimeContext, token, docType string) (string, error) {
|
||||
// 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,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": token,
|
||||
"doc_type": docType,
|
||||
},
|
||||
},
|
||||
},
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return DriveMeta{}, err
|
||||
}
|
||||
|
||||
metas := GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return "", nil
|
||||
return DriveMeta{}, nil
|
||||
}
|
||||
meta, _ := metas[0].(map[string]interface{})
|
||||
return GetString(meta, "title"), nil
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -105,6 +106,44 @@ func TestFetchDriveMetaTitle(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -34,6 +37,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 +47,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 +77,32 @@ 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),
|
||||
)
|
||||
// Best-effort: when the underlying error is a structured permission
|
||||
// ExitError (lark code 99991672/99991679), surface lark_code,
|
||||
// required_scope and console_url so agents can guide users straight
|
||||
// to the dev console. Overrides the generic hint with a more
|
||||
// actionable one when console_url is available.
|
||||
annotateGrantPermissionError(runtime, result, err)
|
||||
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 +112,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
|
||||
}
|
||||
|
||||
@@ -137,3 +160,54 @@ func compactPermissionGrantError(err error) string {
|
||||
}
|
||||
return strings.Join(strings.Fields(err.Error()), " ")
|
||||
}
|
||||
|
||||
// annotateGrantPermissionError enriches a failed permission_grant result with
|
||||
// structured fields (lark_code / required_scope / console_url) when the
|
||||
// underlying error is a permission-class *output.ExitError. The CLI's main
|
||||
// permission-error path (cmd/root.go::enrichPermissionError) handles the same
|
||||
// case for top-level failures; this helper covers best-effort sub-calls whose
|
||||
// error is folded into a result map instead of propagated as ExitError.
|
||||
//
|
||||
// When console_url is available, the existing generic hint is overridden with
|
||||
// a more actionable one pointing at the developer console — that's the
|
||||
// concrete next step a user can take.
|
||||
func annotateGrantPermissionError(runtime *RuntimeContext, result map[string]interface{}, err error) {
|
||||
if runtime == nil || result == nil || err == nil {
|
||||
return
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
return
|
||||
}
|
||||
if exitErr.Detail.Type != "permission" {
|
||||
return
|
||||
}
|
||||
if exitErr.Detail.Code != 0 {
|
||||
result["lark_code"] = exitErr.Detail.Code
|
||||
}
|
||||
|
||||
scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
|
||||
if len(scopes) == 0 {
|
||||
return
|
||||
}
|
||||
recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant")
|
||||
if recommended == "" {
|
||||
return
|
||||
}
|
||||
result["required_scope"] = recommended
|
||||
|
||||
if runtime.Config == nil || runtime.Config.AppID == "" {
|
||||
return
|
||||
}
|
||||
consoleURL := registry.BuildConsoleScopeURL(runtime.Config.Brand, runtime.Config.AppID, recommended)
|
||||
if consoleURL == "" {
|
||||
return
|
||||
}
|
||||
result["console_url"] = consoleURL
|
||||
// Override the generic hint: pointing at the dev console is more actionable
|
||||
// than the generic "retry later" fallback set by buildPermissionGrantResult.
|
||||
result["hint"] = fmt.Sprintf(
|
||||
"App is missing the %q scope; enable it in the developer console (see console_url), then retry.",
|
||||
recommended,
|
||||
)
|
||||
}
|
||||
|
||||
311
shortcuts/common/permission_grant_test.go
Normal file
311
shortcuts/common/permission_grant_test.go
Normal file
@@ -0,0 +1,311 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── annotateGrantPermissionError unit tests ────────────────────────────────
|
||||
|
||||
func newAnnotateRuntime(brand core.LarkBrand, appID string) *RuntimeContext {
|
||||
return &RuntimeContext{
|
||||
Config: &core.CliConfig{
|
||||
AppID: appID,
|
||||
Brand: brand,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// permission_violations subjects must surface as required_scope, and the
|
||||
// console_url must be brand-specific. The hint should be overridden to point
|
||||
// at the developer console.
|
||||
func TestAnnotateGrantPermissionError_AppScopeNotEnabled(t *testing.T) {
|
||||
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
|
||||
result := map[string]interface{}{
|
||||
"hint": "generic fallback hint",
|
||||
}
|
||||
|
||||
err := output.ErrAPI(99991672, "Permission denied [99991672]", map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "docs:permission.member:create"},
|
||||
},
|
||||
})
|
||||
|
||||
annotateGrantPermissionError(rt, result, err)
|
||||
|
||||
if got := result["lark_code"]; got != 99991672 {
|
||||
t.Errorf("expected lark_code=99991672, got %v", got)
|
||||
}
|
||||
if got, _ := result["required_scope"].(string); got != "docs:permission.member:create" {
|
||||
t.Errorf("required_scope mismatch: got %v", got)
|
||||
}
|
||||
consoleURL, _ := result["console_url"].(string)
|
||||
if !strings.HasPrefix(consoleURL, "https://open.feishu.cn/page/scope-apply") {
|
||||
t.Errorf("console_url should target open.feishu.cn, got %s", consoleURL)
|
||||
}
|
||||
if !strings.Contains(consoleURL, "clientID=cli_demo") {
|
||||
t.Errorf("console_url missing clientID, got %s", consoleURL)
|
||||
}
|
||||
hint, _ := result["hint"].(string)
|
||||
if !strings.Contains(hint, "console_url") {
|
||||
t.Errorf("hint should reference console_url, got %s", hint)
|
||||
}
|
||||
if !strings.Contains(hint, "docs:permission.member:create") {
|
||||
t.Errorf("hint should mention required scope, got %s", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateGrantPermissionError_LarkBrand(t *testing.T) {
|
||||
rt := newAnnotateRuntime(core.BrandLark, "cli_demo")
|
||||
result := map[string]interface{}{}
|
||||
err := output.ErrAPI(99991679, "Permission denied [99991679]", map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "docs:permission.member:create"},
|
||||
},
|
||||
})
|
||||
|
||||
annotateGrantPermissionError(rt, result, err)
|
||||
|
||||
if u, _ := result["console_url"].(string); !strings.Contains(u, "open.larksuite.com") {
|
||||
t.Errorf("lark brand should yield larksuite host, got %s", u)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-permission errors (network, validation, plain errors) must not be
|
||||
// annotated — keep the existing generic hint untouched.
|
||||
func TestAnnotateGrantPermissionError_NonPermissionErrorNoOp(t *testing.T) {
|
||||
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
|
||||
|
||||
cases := []error{
|
||||
errors.New("plain error"),
|
||||
output.ErrNetwork("connection reset"),
|
||||
output.ErrValidation("bad request"),
|
||||
// Non-permission API errors (e.g. 230001) — type is "api_error" not "permission"
|
||||
output.ErrAPI(230001, "no permission", map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "docs:doc"},
|
||||
},
|
||||
}),
|
||||
}
|
||||
for i, e := range cases {
|
||||
result := map[string]interface{}{
|
||||
"hint": "untouched hint",
|
||||
}
|
||||
annotateGrantPermissionError(rt, result, e)
|
||||
if _, ok := result["lark_code"]; ok {
|
||||
t.Errorf("case %d: expected no lark_code, got %v", i, result["lark_code"])
|
||||
}
|
||||
if _, ok := result["console_url"]; ok {
|
||||
t.Errorf("case %d: expected no console_url, got %v", i, result["console_url"])
|
||||
}
|
||||
if got, _ := result["hint"].(string); got != "untouched hint" {
|
||||
t.Errorf("case %d: hint should be unchanged, got %s", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// permission_violations missing → only lark_code is annotated; no console_url
|
||||
// and the existing hint stays as-is (caller's generic fallback wins).
|
||||
func TestAnnotateGrantPermissionError_NoViolations(t *testing.T) {
|
||||
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
|
||||
result := map[string]interface{}{
|
||||
"hint": "untouched fallback",
|
||||
}
|
||||
err := output.ErrAPI(99991672, "Permission denied [99991672]", nil)
|
||||
|
||||
annotateGrantPermissionError(rt, result, err)
|
||||
|
||||
if got := result["lark_code"]; got != 99991672 {
|
||||
t.Errorf("expected lark_code captured, got %v", got)
|
||||
}
|
||||
if _, ok := result["console_url"]; ok {
|
||||
t.Errorf("console_url must not be set when violations are absent")
|
||||
}
|
||||
if got, _ := result["hint"].(string); got != "untouched fallback" {
|
||||
t.Errorf("hint should remain fallback when no console_url, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// AppID empty → no console_url even when violations exist.
|
||||
func TestAnnotateGrantPermissionError_EmptyAppID(t *testing.T) {
|
||||
rt := newAnnotateRuntime(core.BrandFeishu, "")
|
||||
result := map[string]interface{}{}
|
||||
err := output.ErrAPI(99991672, "Permission denied", map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "docs:doc"},
|
||||
},
|
||||
})
|
||||
|
||||
annotateGrantPermissionError(rt, result, err)
|
||||
if _, ok := result["console_url"]; ok {
|
||||
t.Errorf("console_url must not be set when appID is empty")
|
||||
}
|
||||
if got, _ := result["required_scope"].(string); got != "docs:doc" {
|
||||
t.Errorf("required_scope should still be set when appID is empty, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive: nil/empty arguments must be safe no-ops.
|
||||
func TestAnnotateGrantPermissionError_NilArgsSafe(t *testing.T) {
|
||||
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
|
||||
|
||||
annotateGrantPermissionError(nil, map[string]interface{}{}, nil)
|
||||
annotateGrantPermissionError(rt, nil, nil)
|
||||
annotateGrantPermissionError(rt, map[string]interface{}{}, nil)
|
||||
annotateGrantPermissionError(rt, map[string]interface{}{}, errors.New(""))
|
||||
}
|
||||
|
||||
// Integration-style: end-to-end through AutoGrantCurrentUserDrivePermission
|
||||
// with a mocked 99991672 response — verifies the annotated fields show up
|
||||
// in the JSON result that callers downstream consume.
|
||||
func TestAutoGrantStderrWarning_GrantFailed_AppScopeNotEnabled_Annotated(t *testing.T) {
|
||||
config := &core.CliConfig{
|
||||
AppID: "cli_app_demo",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test_user",
|
||||
}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, config)
|
||||
|
||||
// Stub the permission member create endpoint with a 99991672 response that
|
||||
// includes permission_violations — what the platform returns when the app
|
||||
// has not enabled the API scope.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/tkn_doc/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "App scope not enabled",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "docs:permission.member:create"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-3")
|
||||
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")
|
||||
}
|
||||
if result["status"] != PermissionGrantFailed {
|
||||
t.Fatalf("status = %v, want failed", result["status"])
|
||||
}
|
||||
if result["lark_code"] != 99991672 {
|
||||
t.Errorf("lark_code = %v, want 99991672", result["lark_code"])
|
||||
}
|
||||
if got, _ := result["required_scope"].(string); got != "docs:permission.member:create" {
|
||||
t.Errorf("required_scope = %v, want docs:permission.member:create", got)
|
||||
}
|
||||
consoleURL, _ := result["console_url"].(string)
|
||||
if !strings.Contains(consoleURL, "open.feishu.cn/page/scope-apply") {
|
||||
t.Errorf("console_url missing or wrong host: %s", consoleURL)
|
||||
}
|
||||
if !strings.Contains(consoleURL, "scopes=docs%3Apermission.member%3Acreate") {
|
||||
t.Errorf("console_url missing escaped scope: %s", consoleURL)
|
||||
}
|
||||
hint, _ := result["hint"].(string)
|
||||
if !strings.Contains(hint, "console_url") {
|
||||
t.Errorf("hint should be overridden to mention console_url, got %s", hint)
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,11 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
if firstErrCode != 0 {
|
||||
return nil, output.ErrAPI(firstErrCode, msg, "")
|
||||
}
|
||||
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg, "")
|
||||
// No structured API code — the failure was transport, parse, panic, or
|
||||
// cancellation. Suggest the actionable next step rather than shipping
|
||||
// an empty hint that would leave the calling agent with nothing to do.
|
||||
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg,
|
||||
"retry the command; if it persists, narrow --queries to a single term to isolate the failing input")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -18,6 +19,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"
|
||||
)
|
||||
@@ -1133,6 +1135,33 @@ func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// When all queries fail with no structured Lark API code (transport, parse,
|
||||
// panic, ctx-canceled), the returned ExitError must carry an actionable
|
||||
// hint so the calling agent has a next step to try instead of giving up.
|
||||
func TestFanoutAssemble_AllFailed_NoCode_HasActionableHint(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{Index: 0, Query: "alice", ErrMsg: "transport: connection refused"},
|
||||
{Index: 1, Query: "bob", ErrMsg: "transport: timeout"},
|
||||
}
|
||||
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when all queries failed")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatalf("expected Detail, got nil")
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
t.Errorf("expected non-empty Hint so agents have a next step; got empty")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "retry") {
|
||||
t.Errorf("hint should suggest retry as the first action; got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// Codes from the first failure must propagate through output.ErrAPI so the
|
||||
// CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
|
||||
// instead of 0, which would mean "success" in the Lark protocol.
|
||||
|
||||
@@ -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"
|
||||
@@ -168,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 != "" {
|
||||
@@ -205,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 "),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,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{}{
|
||||
@@ -129,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"
|
||||
)
|
||||
|
||||
@@ -20,18 +21,19 @@ import (
|
||||
var DriveExport = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+export",
|
||||
Description: "Export a doc/docx/sheet/bitable to a local file with limited polling",
|
||||
Description: "Export a doc/docx/sheet/bitable/slides to a local file with limited polling",
|
||||
Risk: "read",
|
||||
Scopes: []string{
|
||||
"docs:document.content:read",
|
||||
"docs:document:export",
|
||||
"docx:document:readonly",
|
||||
"drive:drive.metadata:readonly",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "source document token", Required: true},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base"}},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "file-name", Desc: "preferred output filename (optional)"},
|
||||
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
|
||||
@@ -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,23 +102,33 @@ 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -131,15 +131,15 @@ func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
}
|
||||
|
||||
switch spec.DocType {
|
||||
case "doc", "docx", "sheet", "bitable":
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
default:
|
||||
return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable", spec.DocType)
|
||||
return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType)
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base":
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base", spec.FileExtension)
|
||||
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension)
|
||||
}
|
||||
|
||||
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
|
||||
@@ -150,6 +150,14 @@ func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
return output.ErrValidation("--file-extension base only supports --doc-type bitable")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
|
||||
return output.ErrValidation("--file-extension pptx only supports --doc-type slides")
|
||||
}
|
||||
|
||||
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
|
||||
return output.ErrValidation("--doc-type slides only supports --file-extension pptx or pdf")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
|
||||
return output.ErrValidation("--sub-id is only used when exporting sheet/bitable as csv")
|
||||
@@ -345,6 +353,8 @@ func exportFileSuffix(fileExtension string) string {
|
||||
return ".csv"
|
||||
case "base":
|
||||
return ".base"
|
||||
case "pptx":
|
||||
return ".pptx"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -70,4 +70,10 @@ func TestSanitizeExportFileNameAndEnsureExtension(t *testing.T) {
|
||||
if got := exportFileSuffix("base"); got != ".base" {
|
||||
t.Fatalf("exportFileSuffix(base) = %q, want %q", got, ".base")
|
||||
}
|
||||
if got := ensureExportFileExtension("report", "pptx"); got != "report.pptx" {
|
||||
t.Fatalf("ensureExportFileExtension() = %q, want %q", got, "report.pptx")
|
||||
}
|
||||
if got := ensureExportFileExtension("report.pptx", "pptx"); got != "report.pptx" {
|
||||
t.Fatalf("ensureExportFileExtension() should preserve suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +50,34 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
name: "base bitable ok",
|
||||
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "base"},
|
||||
},
|
||||
{
|
||||
name: "slides pptx ok",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "pptx"},
|
||||
},
|
||||
{
|
||||
name: "slides pdf ok",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "base non bitable rejected",
|
||||
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "base"},
|
||||
wantErr: "only supports --doc-type bitable",
|
||||
},
|
||||
{
|
||||
name: "pptx non slides rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pptx"},
|
||||
wantErr: "only supports --doc-type slides",
|
||||
},
|
||||
{
|
||||
name: "slides csv rejected",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "csv"},
|
||||
wantErr: "slides only supports",
|
||||
},
|
||||
{
|
||||
name: "unknown doc type rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "unknown", FileExtension: "pdf"},
|
||||
wantErr: "invalid --doc-type",
|
||||
},
|
||||
{
|
||||
name: "unknown file extension rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "rtf"},
|
||||
@@ -81,16 +104,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 +144,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 +166,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 +195,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 +224,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 +278,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 +315,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 +335,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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -911,12 +911,16 @@ func marshalMarkdownPostContent(content [][]map[string]interface{}) string {
|
||||
"content": content,
|
||||
},
|
||||
}
|
||||
return marshalJSONNoEscape(payload)
|
||||
data, _ := json.Marshal(payload)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func buildSingleMDPost(markdown string) string {
|
||||
return marshalMarkdownPostContent([][]map[string]interface{}{
|
||||
buildPostElementNodes(optimizeMarkdownStyle(markdown)),
|
||||
{{
|
||||
"tag": "md",
|
||||
"text": optimizeMarkdownStyle(markdown),
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -940,7 +944,10 @@ func buildSegmentedPost(markdown string) string {
|
||||
if optimized == "" {
|
||||
continue
|
||||
}
|
||||
content = append(content, buildPostElementNodes(optimized))
|
||||
content = append(content, []map[string]interface{}{{
|
||||
"tag": "md",
|
||||
"text": optimized,
|
||||
}})
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return buildSingleMDPost(markdown)
|
||||
@@ -955,186 +962,8 @@ func buildMarkdownPostContent(markdown string) string {
|
||||
return buildSingleMDPost(markdown)
|
||||
}
|
||||
|
||||
// buildPostElementNodes splits optimized markdown text into Feishu post inline
|
||||
// elements. It tokenizes markdown links/images and bare http(s) URLs:
|
||||
// - markdown links are kept verbatim inside a {"tag":"md"} segment
|
||||
// - bare URLs become {"tag":"a"} elements rendered natively by Feishu,
|
||||
// avoiding the md renderer misinterpreting underscores as italic markers
|
||||
//
|
||||
// Fenced code blocks are protected before tokenization so their content remains
|
||||
// a single md segment, and bare URLs support balanced parentheses in the path.
|
||||
func buildPostElementNodes(text string) []map[string]interface{} {
|
||||
protected, codeBlocks := protectMarkdownCodeBlocks(text)
|
||||
if protected == "" {
|
||||
return []map[string]interface{}{{
|
||||
"tag": "md",
|
||||
"text": text,
|
||||
}}
|
||||
}
|
||||
elems := make([]map[string]interface{}, 0, 4)
|
||||
prev := 0
|
||||
for i := 0; i < len(protected); {
|
||||
end, kind, ok := scanPostToken(protected, i)
|
||||
if !ok {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if i > prev {
|
||||
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(protected[prev:i], codeBlocks))
|
||||
}
|
||||
|
||||
token := protected[i:end]
|
||||
if kind == postTokenMarkdown {
|
||||
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(token, codeBlocks))
|
||||
} else {
|
||||
url := trimBareURLToken(token)
|
||||
if url == "" {
|
||||
url = token
|
||||
}
|
||||
elems = append(elems, map[string]interface{}{
|
||||
"tag": "a",
|
||||
"text": url,
|
||||
"href": url,
|
||||
})
|
||||
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(token[len(url):], codeBlocks))
|
||||
}
|
||||
prev = end
|
||||
i = end
|
||||
}
|
||||
if prev < len(protected) {
|
||||
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(protected[prev:], codeBlocks))
|
||||
}
|
||||
if len(elems) == 0 {
|
||||
return []map[string]interface{}{{
|
||||
"tag": "md",
|
||||
"text": text,
|
||||
}}
|
||||
}
|
||||
return elems
|
||||
}
|
||||
|
||||
func trimBareURLToken(token string) string {
|
||||
trimmed := strings.TrimRight(token, ".,;:!?")
|
||||
for strings.HasSuffix(trimmed, ")") && strings.Count(trimmed, "(") < strings.Count(trimmed, ")") {
|
||||
trimmed = strings.TrimSuffix(trimmed, ")")
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
type postTokenKind int
|
||||
|
||||
const (
|
||||
postTokenMarkdown postTokenKind = iota
|
||||
postTokenURL
|
||||
)
|
||||
|
||||
func appendMDPostNode(elems []map[string]interface{}, text string) []map[string]interface{} {
|
||||
if text == "" {
|
||||
return elems
|
||||
}
|
||||
return append(elems, map[string]interface{}{
|
||||
"tag": "md",
|
||||
"text": text,
|
||||
})
|
||||
}
|
||||
|
||||
func scanPostToken(text string, start int) (end int, kind postTokenKind, ok bool) {
|
||||
if end, ok = scanMarkdownLinkToken(text, start); ok {
|
||||
return end, postTokenMarkdown, true
|
||||
}
|
||||
if end, ok = scanBareURLToken(text, start); ok {
|
||||
return end, postTokenURL, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func scanMarkdownLinkToken(text string, start int) (int, bool) {
|
||||
openBracket := start
|
||||
if text[start] == '!' {
|
||||
if start+1 >= len(text) || text[start+1] != '[' {
|
||||
return 0, false
|
||||
}
|
||||
openBracket = start + 1
|
||||
} else if text[start] != '[' {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
closeBracket := strings.IndexByte(text[openBracket+1:], ']')
|
||||
if closeBracket < 0 {
|
||||
return 0, false
|
||||
}
|
||||
closeBracket += openBracket + 1
|
||||
if closeBracket+1 >= len(text) || text[closeBracket+1] != '(' {
|
||||
return 0, false
|
||||
}
|
||||
return scanBalancedParenToken(text, closeBracket+1)
|
||||
}
|
||||
|
||||
func scanBareURLToken(text string, start int) (int, bool) {
|
||||
if !strings.HasPrefix(text[start:], "http://") && !strings.HasPrefix(text[start:], "https://") {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
depth := 0
|
||||
for i := start; i < len(text); i++ {
|
||||
switch text[i] {
|
||||
case ' ', '\t', '\n', '\r', '<', '>', '"', '[', ']':
|
||||
return i, i > start
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
if depth == 0 {
|
||||
return i, i > start
|
||||
}
|
||||
depth--
|
||||
}
|
||||
}
|
||||
return len(text), true
|
||||
}
|
||||
|
||||
func scanBalancedParenToken(text string, openParen int) (int, bool) {
|
||||
if openParen >= len(text) || text[openParen] != '(' {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
depth := 0
|
||||
for i := openParen; i < len(text); i++ {
|
||||
switch text[i] {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i + 1, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func buildPostElements(text string) string {
|
||||
return marshalJSONNoEscape(buildPostElementNodes(text))
|
||||
}
|
||||
|
||||
func marshalJSONNoEscape(v interface{}) string {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(v)
|
||||
return strings.TrimSuffix(buf.String(), "\n")
|
||||
}
|
||||
|
||||
// marshalStringNoEscape serializes a string to JSON without HTML-escaping
|
||||
// special characters like &, <, >. Go's json.Marshal escapes them to \u0026
|
||||
// etc. by default, which breaks URLs containing & in Feishu's md renderer.
|
||||
func marshalStringNoEscape(s string) string {
|
||||
return marshalJSONNoEscape(s)
|
||||
}
|
||||
|
||||
// wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network).
|
||||
// Used by DryRun. Output may include md/text paragraphs when blank-line separators are present.
|
||||
// Bare URLs are emitted as {"tag":"a"} elements to avoid Feishu's md renderer
|
||||
// misinterpreting underscores in URLs as italic markers.
|
||||
func wrapMarkdownAsPost(markdown string) string {
|
||||
return buildMarkdownPostContent(markdown)
|
||||
}
|
||||
|
||||
@@ -373,171 +373,19 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalStringNoEscape(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "ampersand not escaped", input: "a=1&b=2", want: `"a=1&b=2"`},
|
||||
{name: "angle brackets not escaped", input: "<tag>", want: `"<tag>"`},
|
||||
{name: "regular string", input: "hello world", want: `"hello world"`},
|
||||
{name: "url with ampersand", input: "https://example.com?a=1&b=2", want: `"https://example.com?a=1&b=2"`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := marshalStringNoEscape(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("marshalStringNoEscape(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostElements(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantSubs []string // substrings that must appear
|
||||
wantNsubs []string // substrings that must NOT appear
|
||||
}{
|
||||
{
|
||||
name: "plain text no URL",
|
||||
input: "hello **world**",
|
||||
wantSubs: []string{`"tag":"md"`, `hello **world**`},
|
||||
},
|
||||
{
|
||||
name: "bare URL only",
|
||||
input: "https://example.com/path",
|
||||
wantSubs: []string{`"tag":"a"`, `"text":"https://example.com/path"`, `"href":"https://example.com/path"`},
|
||||
},
|
||||
{
|
||||
name: "bare URL with underscores",
|
||||
input: "https://example.com/flow_id=abc_def",
|
||||
wantSubs: []string{`"tag":"a"`, `flow_id=abc_def`},
|
||||
},
|
||||
{
|
||||
name: "bare URL with ampersand not escaped",
|
||||
input: "https://example.com?a=1&b=2",
|
||||
wantSubs: []string{`"tag":"a"`, `a=1&b=2`},
|
||||
},
|
||||
{
|
||||
name: "text before and after URL",
|
||||
input: "click here: https://example.com/path ok?",
|
||||
wantSubs: []string{`"tag":"md"`, `click here: `, `"tag":"a"`, `https://example.com/path`, ` ok?`},
|
||||
},
|
||||
{
|
||||
name: "markdown link kept in md segment",
|
||||
input: "[click here](https://example.com/path_with_underscore)",
|
||||
wantSubs: []string{`"tag":"md"`, `[click here](https://example.com/path_with_underscore)`},
|
||||
},
|
||||
{
|
||||
name: "markdown link not promoted to a tag",
|
||||
input: "[text](https://example.com)",
|
||||
wantSubs: []string{`"tag":"md"`},
|
||||
wantNsubs: []string{`"tag":"a"`},
|
||||
},
|
||||
{
|
||||
name: "multiple bare URLs",
|
||||
input: "https://a.com/x_y and https://b.com/p_q",
|
||||
wantSubs: []string{
|
||||
`"tag":"a"`, `https://a.com/x_y`,
|
||||
`https://b.com/p_q`,
|
||||
`"tag":"md"`, ` and `,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed markdown and bare URL",
|
||||
input: "**bold** https://example.com/foo_bar [link](https://example.com) end",
|
||||
wantSubs: []string{`"tag":"md"`, `**bold**`, `"tag":"a"`, `foo_bar`, `[link](https://example.com)`},
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
wantSubs: []string{`"tag":"md"`, `"text":""`},
|
||||
},
|
||||
{
|
||||
name: "URL followed by comma",
|
||||
input: "visit https://example.com/path, then click",
|
||||
wantSubs: []string{`"tag":"a"`, `"href":"https://example.com/path"`},
|
||||
wantNsubs: []string{`https://example.com/path,`},
|
||||
},
|
||||
{
|
||||
name: "URL followed by period",
|
||||
input: "see https://example.com/foo.",
|
||||
wantSubs: []string{`"tag":"a"`, `https://example.com/foo`},
|
||||
wantNsubs: []string{`https://example.com/foo."`},
|
||||
},
|
||||
{
|
||||
name: "URL with no trailing punctuation unchanged",
|
||||
input: "https://example.com/foo_bar",
|
||||
wantSubs: []string{`"href":"https://example.com/foo_bar"`},
|
||||
},
|
||||
{
|
||||
name: "URL with balanced parentheses preserved",
|
||||
input: "https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
wantSubs: []string{`"href":"https://en.wikipedia.org/wiki/Foo_(bar)"`},
|
||||
wantNsubs: []string{`"href":"https://en.wikipedia.org/wiki/Foo_"`},
|
||||
},
|
||||
{
|
||||
name: "code block URL stays markdown",
|
||||
input: "```bash\ncurl https://example.com/foo_bar\n```",
|
||||
wantSubs: []string{`"tag":"md"`, "```bash\\ncurl https://example.com/foo_bar\\n```"},
|
||||
wantNsubs: []string{`"tag":"a"`},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildPostElements(tt.input)
|
||||
for _, sub := range tt.wantSubs {
|
||||
if !strings.Contains(got, sub) {
|
||||
t.Errorf("buildPostElements(%q)\n got: %s\n missing: %q", tt.input, got, sub)
|
||||
}
|
||||
}
|
||||
for _, sub := range tt.wantNsubs {
|
||||
if strings.Contains(got, sub) {
|
||||
t.Errorf("buildPostElements(%q)\n got: %s\n should not contain: %q", tt.input, got, sub)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPost(t *testing.T) {
|
||||
t.Run("plain markdown", func(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("hello **world**")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
|
||||
}
|
||||
node := decodePostParagraphForTest(t, got, 0)
|
||||
if node["tag"] != "md" {
|
||||
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
|
||||
}
|
||||
if node["text"] != "hello **world**" {
|
||||
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare URL becomes a tag", func(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("see https://example.com/flow_id=abc_def done")
|
||||
if !strings.Contains(got, `"tag":"a"`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() bare URL should produce a tag: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `flow_id=abc_def`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() URL content missing: %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("code block URL stays md", func(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("```bash\ncurl https://example.com/foo_bar\n```")
|
||||
if strings.Contains(got, `"tag":"a"`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() code block URL should stay markdown: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "```bash\\ncurl https://example.com/foo_bar\\n```") {
|
||||
t.Fatalf("wrapMarkdownAsPost() code block content missing: %s", got)
|
||||
}
|
||||
})
|
||||
got := wrapMarkdownAsPost("hello **world**")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
|
||||
}
|
||||
node := decodePostParagraphForTest(t, got, 0)
|
||||
if node["tag"] != "md" {
|
||||
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
|
||||
}
|
||||
if node["text"] != "hello **world**" {
|
||||
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldUseSegmentedPost(t *testing.T) {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -2331,15 +2331,15 @@ func validateSendTime(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
if !runtime.Bool("confirm-send") {
|
||||
return fmt.Errorf("--send-time requires --confirm-send to be set")
|
||||
return output.ErrValidation("--send-time requires --confirm-send to be set")
|
||||
}
|
||||
ts, err := strconv.ParseInt(sendTime, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
|
||||
return output.ErrValidation("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
|
||||
}
|
||||
minTime := time.Now().Unix() + 5*60
|
||||
if ts < minTime {
|
||||
return fmt.Errorf("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
|
||||
return output.ErrValidation("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2444,10 +2444,10 @@ func validateRecipientCount(to, cc, bcc string) error {
|
||||
func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFlag string, plainText bool, body string) error {
|
||||
if strings.TrimSpace(inlineFlag) != "" {
|
||||
if plainText {
|
||||
return fmt.Errorf("--inline is not supported with --plain-text (inline images require HTML body)")
|
||||
return output.ErrValidation("--inline is not supported with --plain-text (inline images require HTML body)")
|
||||
}
|
||||
if body != "" && !bodyIsHTML(body) {
|
||||
return fmt.Errorf("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
|
||||
return output.ErrValidation("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
|
||||
}
|
||||
}
|
||||
inlineSpecs, err := parseInlineSpecs(inlineFlag)
|
||||
@@ -2529,7 +2529,7 @@ func validateEventFlags(runtime *common.RuntimeContext) error {
|
||||
hasAll := summary != "" && start != "" && end != ""
|
||||
|
||||
if hasAny && !hasAll {
|
||||
return fmt.Errorf("--event-summary, --event-start, and --event-end must all be provided together")
|
||||
return output.ErrValidation("--event-summary, --event-start, and --event-end must all be provided together")
|
||||
}
|
||||
if summary == "" {
|
||||
return nil
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
@@ -241,7 +242,7 @@ func signatureCIDs(sig *signatureResult) []string {
|
||||
// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set.
|
||||
func validateSignatureWithPlainText(plainText bool, signatureID string) error {
|
||||
if plainText && signatureID != "" {
|
||||
return fmt.Errorf("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode")
|
||||
return output.ErrValidation("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
540
shortcuts/markdown/markdown_diff.go
Normal file
540
shortcuts/markdown/markdown_diff.go
Normal file
@@ -0,0 +1,540 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
markdownDiffModeRemoteVsRemote = "remote_vs_remote"
|
||||
markdownDiffModeRemoteVsLocal = "remote_vs_local"
|
||||
markdownDiffMaxContentBytes = 10 * 1024 * 1024
|
||||
markdownDiffTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var markdownDiffVersionRe = regexp.MustCompile(`^\d{1,19}$`)
|
||||
|
||||
type markdownDiffSpec struct {
|
||||
FileToken string
|
||||
FromVersion string
|
||||
ToVersion string
|
||||
FilePath string
|
||||
ContextLines int
|
||||
Format string
|
||||
}
|
||||
|
||||
type markdownDiffHunk struct {
|
||||
Header string `json:"header"`
|
||||
OldStart int `json:"old_start"`
|
||||
OldLines int `json:"old_lines"`
|
||||
NewStart int `json:"new_start"`
|
||||
NewLines int `json:"new_lines"`
|
||||
}
|
||||
|
||||
type markdownDiffLineKind int
|
||||
|
||||
const (
|
||||
markdownDiffLineEqual markdownDiffLineKind = iota
|
||||
markdownDiffLineDelete
|
||||
markdownDiffLineInsert
|
||||
)
|
||||
|
||||
type markdownDiffLineOp struct {
|
||||
Kind markdownDiffLineKind
|
||||
Content string
|
||||
}
|
||||
|
||||
type markdownDiffHunkRange struct {
|
||||
Start int
|
||||
End int
|
||||
}
|
||||
|
||||
func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if spec.FromVersion != "" {
|
||||
if err := validateMarkdownDiffVersionValue(spec.FromVersion, "--from-version"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if spec.ToVersion != "" {
|
||||
if err := validateMarkdownDiffVersionValue(spec.ToVersion, "--to-version"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if spec.FilePath != "" {
|
||||
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
|
||||
return output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
if err := validateMarkdownFileName(spec.FilePath, "--file"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if spec.ContextLines < 0 {
|
||||
return output.ErrValidation("--context-lines must be >= 0")
|
||||
}
|
||||
if spec.Format != "" && spec.Format != "json" && spec.Format != "pretty" {
|
||||
return output.ErrValidation("markdown +diff only supports --format json or pretty")
|
||||
}
|
||||
if spec.FilePath == "" {
|
||||
if spec.FromVersion == "" && spec.ToVersion == "" {
|
||||
return common.FlagErrorf("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
|
||||
}
|
||||
if spec.FromVersion == "" && spec.ToVersion != "" {
|
||||
return common.FlagErrorf("--to-version requires --from-version")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if spec.ToVersion != "" {
|
||||
return common.FlagErrorf("--to-version is not supported together with --file")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMarkdownDiffVersionValue(value, flagName string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return output.ErrValidation("%s cannot be empty", flagName)
|
||||
}
|
||||
if !markdownDiffVersionRe.MatchString(value) {
|
||||
return output.ErrValidation("%s must be a numeric version string", flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markdownDiffMode(spec markdownDiffSpec) string {
|
||||
if spec.FilePath != "" {
|
||||
return markdownDiffModeRemoteVsLocal
|
||||
}
|
||||
return markdownDiffModeRemoteVsRemote
|
||||
}
|
||||
|
||||
func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI().Desc("Download the requested Markdown content, compute a unified diff locally, and print the result without modifying the remote file")
|
||||
switch markdownDiffMode(spec) {
|
||||
case markdownDiffModeRemoteVsLocal:
|
||||
if spec.FromVersion != "" {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the specified remote Markdown version").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.FromVersion})
|
||||
} else {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the latest remote Markdown version").
|
||||
Set("file_token", spec.FileToken)
|
||||
}
|
||||
dry.Set("local_file", spec.FilePath)
|
||||
dry.Set("mode", markdownDiffModeRemoteVsLocal)
|
||||
default:
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the base remote Markdown version").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.FromVersion})
|
||||
if spec.ToVersion != "" {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[2] Download the target remote Markdown version").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.ToVersion})
|
||||
} else {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[2] Download the latest remote Markdown version").
|
||||
Set("file_token", spec.FileToken)
|
||||
}
|
||||
dry.Set("mode", markdownDiffModeRemoteVsRemote)
|
||||
}
|
||||
dry.Set("context_lines", spec.ContextLines)
|
||||
return dry
|
||||
}
|
||||
|
||||
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (string, string, error) {
|
||||
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
payload, err := readMarkdownDiffPayload(resp.Body, "remote Markdown content")
|
||||
if err != nil {
|
||||
return "", "", wrapMarkdownDownloadError(err)
|
||||
}
|
||||
return fileName, string(payload), nil
|
||||
}
|
||||
|
||||
func readMarkdownLocalFile(runtime *common.RuntimeContext, filePath string) (string, error) {
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
payload, err := readMarkdownDiffPayload(f, "local Markdown file")
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", err
|
||||
}
|
||||
return "", output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
return string(payload), nil
|
||||
}
|
||||
|
||||
func readMarkdownDiffPayload(r io.Reader, source string) ([]byte, error) {
|
||||
payload, err := io.ReadAll(io.LimitReader(r, markdownDiffMaxContentBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload) > markdownDiffMaxContentBytes {
|
||||
return nil, output.ErrValidation("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func splitMarkdownDiffLines(text string) []string {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
lines := strings.SplitAfter(text, "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func markdownDiffLineOps(fromContent, toContent string) []markdownDiffLineOp {
|
||||
dmp := diffmatchpatch.New()
|
||||
dmp.DiffTimeout = markdownDiffTimeout
|
||||
before, after, lineArray := dmp.DiffLinesToRunes(fromContent, toContent)
|
||||
diffs := dmp.DiffMainRunes(before, after, false)
|
||||
// Keep the diff line-based. Running cleanup after hydrating real text
|
||||
// would re-split replacements into word-level edits.
|
||||
diffs = dmp.DiffCharsToLines(diffs, lineArray)
|
||||
|
||||
ops := make([]markdownDiffLineOp, 0, len(diffs))
|
||||
for _, diff := range diffs {
|
||||
lines := splitMarkdownDiffLines(diff.Text)
|
||||
for _, line := range lines {
|
||||
switch diff.Type {
|
||||
case diffmatchpatch.DiffDelete:
|
||||
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineDelete, Content: line})
|
||||
case diffmatchpatch.DiffInsert:
|
||||
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineInsert, Content: line})
|
||||
default:
|
||||
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineEqual, Content: line})
|
||||
}
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
func markdownDiffSummary(ops []markdownDiffLineOp) (bool, int, int) {
|
||||
added := 0
|
||||
deleted := 0
|
||||
changed := false
|
||||
for _, op := range ops {
|
||||
switch op.Kind {
|
||||
case markdownDiffLineDelete:
|
||||
changed = true
|
||||
deleted++
|
||||
case markdownDiffLineInsert:
|
||||
changed = true
|
||||
added++
|
||||
}
|
||||
}
|
||||
return changed, added, deleted
|
||||
}
|
||||
|
||||
func markdownDiffHunkRanges(ops []markdownDiffLineOp, contextLines int) []markdownDiffHunkRange {
|
||||
if len(ops) == 0 {
|
||||
return nil
|
||||
}
|
||||
changedLines := make([]int, 0)
|
||||
for i, op := range ops {
|
||||
if op.Kind != markdownDiffLineEqual {
|
||||
changedLines = append(changedLines, i)
|
||||
}
|
||||
}
|
||||
if len(changedLines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ranges := make([]markdownDiffHunkRange, 0, len(changedLines))
|
||||
current := markdownDiffHunkRange{
|
||||
Start: max(0, changedLines[0]-contextLines),
|
||||
End: min(len(ops), changedLines[0]+contextLines+1),
|
||||
}
|
||||
for _, idx := range changedLines[1:] {
|
||||
next := markdownDiffHunkRange{
|
||||
Start: max(0, idx-contextLines),
|
||||
End: min(len(ops), idx+contextLines+1),
|
||||
}
|
||||
if next.Start <= current.End {
|
||||
if next.End > current.End {
|
||||
current.End = next.End
|
||||
}
|
||||
continue
|
||||
}
|
||||
ranges = append(ranges, current)
|
||||
current = next
|
||||
}
|
||||
ranges = append(ranges, current)
|
||||
return ranges
|
||||
}
|
||||
|
||||
func markdownDiffHunkAt(ops []markdownDiffLineOp, r markdownDiffHunkRange) markdownDiffHunk {
|
||||
oldBefore := 0
|
||||
newBefore := 0
|
||||
for _, op := range ops[:r.Start] {
|
||||
if op.Kind != markdownDiffLineInsert {
|
||||
oldBefore++
|
||||
}
|
||||
if op.Kind != markdownDiffLineDelete {
|
||||
newBefore++
|
||||
}
|
||||
}
|
||||
|
||||
oldLines := 0
|
||||
newLines := 0
|
||||
for _, op := range ops[r.Start:r.End] {
|
||||
if op.Kind != markdownDiffLineInsert {
|
||||
oldLines++
|
||||
}
|
||||
if op.Kind != markdownDiffLineDelete {
|
||||
newLines++
|
||||
}
|
||||
}
|
||||
|
||||
oldStart := oldBefore + 1
|
||||
newStart := newBefore + 1
|
||||
if oldLines == 0 {
|
||||
oldStart = oldBefore
|
||||
}
|
||||
if newLines == 0 {
|
||||
newStart = newBefore
|
||||
}
|
||||
|
||||
return markdownDiffHunk{
|
||||
Header: fmt.Sprintf("@@ -%d,%d +%d,%d @@", oldStart, oldLines, newStart, newLines),
|
||||
OldStart: oldStart,
|
||||
OldLines: oldLines,
|
||||
NewStart: newStart,
|
||||
NewLines: newLines,
|
||||
}
|
||||
}
|
||||
|
||||
func buildMarkdownUnifiedDiff(fromLabel, toLabel string, ops []markdownDiffLineOp, ranges []markdownDiffHunkRange) string {
|
||||
if len(ranges) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "--- %s\n", fromLabel)
|
||||
fmt.Fprintf(&b, "+++ %s\n", toLabel)
|
||||
for _, r := range ranges {
|
||||
hunk := markdownDiffHunkAt(ops, r)
|
||||
b.WriteString(hunk.Header)
|
||||
b.WriteByte('\n')
|
||||
for _, op := range ops[r.Start:r.End] {
|
||||
prefix := ' '
|
||||
switch op.Kind {
|
||||
case markdownDiffLineDelete:
|
||||
prefix = '-'
|
||||
case markdownDiffLineInsert:
|
||||
prefix = '+'
|
||||
}
|
||||
b.WriteByte(byte(prefix))
|
||||
b.WriteString(op.Content)
|
||||
if !strings.HasSuffix(op.Content, "\n") {
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(`\ No newline at end of file`)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func summarizeMarkdownDiff(fromLabel, toLabel, fromContent, toContent string, contextLines int) (string, bool, int, int, []markdownDiffHunk) {
|
||||
ops := markdownDiffLineOps(fromContent, toContent)
|
||||
changed, added, deleted := markdownDiffSummary(ops)
|
||||
ranges := markdownDiffHunkRanges(ops, contextLines)
|
||||
hunks := make([]markdownDiffHunk, 0, len(ranges))
|
||||
for _, r := range ranges {
|
||||
hunks = append(hunks, markdownDiffHunkAt(ops, r))
|
||||
}
|
||||
return buildMarkdownUnifiedDiff(fromLabel, toLabel, ops, ranges), changed, added, deleted, hunks
|
||||
}
|
||||
|
||||
func colorizeUnifiedDiff(diffText string) string {
|
||||
if diffText == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.SplitAfter(diffText, "\n")
|
||||
var b strings.Builder
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimRight(line, "\n")
|
||||
suffix := ""
|
||||
if strings.HasSuffix(line, "\n") {
|
||||
suffix = "\n"
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "@@"):
|
||||
b.WriteString(output.Cyan)
|
||||
b.WriteString(trimmed)
|
||||
b.WriteString(output.Reset)
|
||||
case strings.HasPrefix(trimmed, "+++"), strings.HasPrefix(trimmed, "---"):
|
||||
b.WriteString(output.Bold)
|
||||
b.WriteString(trimmed)
|
||||
b.WriteString(output.Reset)
|
||||
case strings.HasPrefix(trimmed, "+") && !strings.HasPrefix(trimmed, "+++"):
|
||||
b.WriteString(output.Green)
|
||||
b.WriteString(trimmed)
|
||||
b.WriteString(output.Reset)
|
||||
case strings.HasPrefix(trimmed, "-") && !strings.HasPrefix(trimmed, "---"):
|
||||
b.WriteString(output.Red)
|
||||
b.WriteString(trimmed)
|
||||
b.WriteString(output.Reset)
|
||||
default:
|
||||
b.WriteString(trimmed)
|
||||
}
|
||||
b.WriteString(suffix)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownDiff(w io.Writer, data map[string]interface{}) {
|
||||
if !common.GetBool(data, "changed") {
|
||||
io.WriteString(w, "No differences.\n")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, colorizeUnifiedDiff(common.GetString(data, "diff")))
|
||||
}
|
||||
|
||||
var MarkdownDiff = common.Shortcut{
|
||||
Service: "markdown",
|
||||
Command: "+diff",
|
||||
Description: "Compare remote Markdown versions or compare remote Markdown against a local file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "target Markdown file token", Required: true},
|
||||
{Name: "from-version", Desc: "base remote version; when --to-version is omitted, compare this version to the latest remote version"},
|
||||
{Name: "to-version", Desc: "target remote version; requires --from-version"},
|
||||
{Name: "file", Desc: "local .md file path to compare against the remote content"},
|
||||
{Name: "context-lines", Desc: "number of unchanged context lines to include around each diff hunk", Type: "int", Default: "3"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMarkdownDiffSpec(runtime, markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
Format: runtime.Format,
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return markdownDiffDryRun(markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
}
|
||||
|
||||
var (
|
||||
fromLabel string
|
||||
toLabel string
|
||||
fromContent string
|
||||
toContent string
|
||||
err error
|
||||
)
|
||||
|
||||
switch markdownDiffMode(spec) {
|
||||
case markdownDiffModeRemoteVsLocal:
|
||||
fromLabel = "a/" + spec.FileToken
|
||||
if spec.FromVersion != "" {
|
||||
fromLabel += "@version:" + spec.FromVersion
|
||||
} else {
|
||||
fromLabel += "@latest"
|
||||
}
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toLabel = "b/" + spec.FilePath
|
||||
toContent, err = readMarkdownLocalFile(runtime, spec.FilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
fromLabel = "a/" + spec.FileToken + "@version:" + spec.FromVersion
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.ToVersion != "" {
|
||||
toLabel = "b/" + spec.FileToken + "@version:" + spec.ToVersion
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion)
|
||||
} else {
|
||||
toLabel = "b/" + spec.FileToken + "@latest"
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
diffText, changed, addedLines, deletedLines, hunks := summarizeMarkdownDiff(fromLabel, toLabel, fromContent, toContent, spec.ContextLines)
|
||||
|
||||
out := map[string]interface{}{
|
||||
"changed": changed,
|
||||
"mode": markdownDiffMode(spec),
|
||||
"file_token": spec.FileToken,
|
||||
"from_version": spec.FromVersion,
|
||||
"to_version": spec.ToVersion,
|
||||
"from_label": fromLabel,
|
||||
"to_label": toLabel,
|
||||
"added_lines": addedLines,
|
||||
"deleted_lines": deletedLines,
|
||||
"context_lines": spec.ContextLines,
|
||||
"hunks": hunks,
|
||||
"diff": diffText,
|
||||
}
|
||||
if spec.FilePath != "" {
|
||||
out["local_file"] = spec.FilePath
|
||||
}
|
||||
|
||||
runtime.OutFormatRaw(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownDiff(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
379
shortcuts/markdown/markdown_diff_test.go
Normal file
379
shortcuts/markdown/markdown_diff_test.go
Normal file
@@ -0,0 +1,379 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestMarkdownDiffRejectsUnsupportedFormat(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--format", "table",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only supports --format json or pretty") {
|
||||
t.Fatalf("expected format validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRejectsToVersionWithoutFromVersion(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--to-version", "7633658129540910628",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--to-version requires --from-version") {
|
||||
t.Fatalf("expected version validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\n- alpha\n- beta\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\n- alpha\n- beta updated\n- gamma\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--to-version", "7633658129540910628",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Changed bool `json:"changed"`
|
||||
Mode string `json:"mode"`
|
||||
FromVersion string `json:"from_version"`
|
||||
ToVersion string `json:"to_version"`
|
||||
AddedLines int `json:"added_lines"`
|
||||
DeletedLines int `json:"deleted_lines"`
|
||||
Diff string `json:"diff"`
|
||||
Hunks []markdownDiffHunk `json:"hunks"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json unmarshal error: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true, got false: %s", stdout.String())
|
||||
}
|
||||
if !env.Data.Changed {
|
||||
t.Fatalf("expected changed=true: %s", stdout.String())
|
||||
}
|
||||
if env.Data.Mode != markdownDiffModeRemoteVsRemote {
|
||||
t.Fatalf("mode = %q, want %q", env.Data.Mode, markdownDiffModeRemoteVsRemote)
|
||||
}
|
||||
if env.Data.FromVersion != "7633658129540910621" || env.Data.ToVersion != "7633658129540910628" {
|
||||
t.Fatalf("versions = %q -> %q", env.Data.FromVersion, env.Data.ToVersion)
|
||||
}
|
||||
if env.Data.AddedLines != 2 || env.Data.DeletedLines != 1 {
|
||||
t.Fatalf("added/deleted = %d/%d, want 2/1", env.Data.AddedLines, env.Data.DeletedLines)
|
||||
}
|
||||
if len(env.Data.Hunks) != 1 {
|
||||
t.Fatalf("len(hunks) = %d, want 1", len(env.Data.Hunks))
|
||||
}
|
||||
if !strings.Contains(env.Data.Diff, "@@") || !strings.Contains(env.Data.Diff, "+- gamma") {
|
||||
t.Fatalf("diff missing expected content: %s", env.Data.Diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRemoteVsLocalPretty(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\nhello old\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("local.md", []byte("# Title\n\nhello new\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--file", "./local.md",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "@@") {
|
||||
t.Fatalf("pretty output missing hunk header: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), output.Red+"-hello old"+output.Reset) {
|
||||
t.Fatalf("pretty output missing removed line color: %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), output.Green+"+hello new"+output.Reset) {
|
||||
t.Fatalf("pretty output missing added line color: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRejectsOversizedRemoteContent(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
Status: 200,
|
||||
RawBody: bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("local.md", []byte("# Title\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--file", "./local.md",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "remote Markdown content exceeds 10.0 MB markdown +diff content limit") {
|
||||
t.Fatalf("expected remote content size error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("local.md", bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--file", "./local.md",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "local Markdown file exceeds 10.0 MB markdown +diff content limit") {
|
||||
t.Fatalf("expected local content size error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDownloadErrorPreservesStructuredErrors(t *testing.T) {
|
||||
apiErr := output.ErrAPI(99991663, "permission denied", map[string]interface{}{"permission": "drive:file:download"})
|
||||
if got := wrapMarkdownDownloadError(apiErr); got != apiErr {
|
||||
t.Fatalf("wrapMarkdownDownloadError() = %v, want original API error", got)
|
||||
}
|
||||
|
||||
got := wrapMarkdownDownloadError(errors.New("dial tcp timeout"))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("wrapMarkdownDownloadError() = %T, want *output.ExitError", got)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if !strings.Contains(got.Error(), "download failed: dial tcp timeout") {
|
||||
t.Fatalf("wrapped error = %q", got.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffIncludesNoNewlineMarker(t *testing.T) {
|
||||
diffText, changed, added, deleted, hunks := summarizeMarkdownDiff(
|
||||
"a/test.md",
|
||||
"b/test.md",
|
||||
"# Title\n\nhello old",
|
||||
"# Title\n\nhello new",
|
||||
3,
|
||||
)
|
||||
if !changed {
|
||||
t.Fatalf("expected changed=true")
|
||||
}
|
||||
if added != 1 || deleted != 1 {
|
||||
t.Fatalf("added/deleted = %d/%d, want 1/1", added, deleted)
|
||||
}
|
||||
if len(hunks) != 1 {
|
||||
t.Fatalf("len(hunks) = %d, want 1", len(hunks))
|
||||
}
|
||||
if strings.Count(diffText, "\\ No newline at end of file") != 2 {
|
||||
t.Fatalf("diff should contain two no-newline markers: %q", diffText)
|
||||
}
|
||||
if !strings.Contains(diffText, "-hello old\n\\ No newline at end of file\n+hello new\n\\ No newline at end of file\n") {
|
||||
t.Fatalf("diff missing expected no-newline marker sequence: %q", diffText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("line1\nline2\nline3\nline4\nline5\nline6\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
|
||||
Status: 200,
|
||||
RawBody: []byte("line1\nline2 changed\nline3\nline4\nline5 changed\nline6\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--to-version", "7633658129540910628",
|
||||
"--context-lines", "0",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Changed bool `json:"changed"`
|
||||
AddedLines int `json:"added_lines"`
|
||||
DeletedLines int `json:"deleted_lines"`
|
||||
Hunks []markdownDiffHunk `json:"hunks"`
|
||||
Diff string `json:"diff"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json unmarshal error: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !env.OK || !env.Data.Changed {
|
||||
t.Fatalf("expected changed=true: %s", stdout.String())
|
||||
}
|
||||
if env.Data.AddedLines != 2 || env.Data.DeletedLines != 2 {
|
||||
t.Fatalf("added/deleted = %d/%d, want 2/2", env.Data.AddedLines, env.Data.DeletedLines)
|
||||
}
|
||||
if len(env.Data.Hunks) != 2 {
|
||||
t.Fatalf("len(hunks) = %d, want 2", len(env.Data.Hunks))
|
||||
}
|
||||
if !strings.Contains(env.Data.Diff, "-line2") || !strings.Contains(env.Data.Diff, "+line5 changed") {
|
||||
t.Fatalf("diff missing expected content: %s", env.Data.Diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffNoChangesPretty(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "No differences." {
|
||||
t.Fatalf("pretty no-change output = %q, want %q", got, "No differences.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffDryRunRemoteVsLocal(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
localPath := filepath.Join(".", "local.md")
|
||||
if err := os.WriteFile(localPath, []byte("# local\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--file", localPath,
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/:file_token/download") && !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/box_md_diff/download") {
|
||||
t.Fatalf("dry-run missing download call: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"local_file": "local.md"`) && !strings.Contains(stdout.String(), `"local_file": "./local.md"`) {
|
||||
t.Fatalf("dry-run missing local file metadata: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,13 @@ func markdownTestConfig() *core.CliConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func markdownPermissionTestConfig(userOpenID string) *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "markdown-perm-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
UserOpenId: userOpenID,
|
||||
}
|
||||
}
|
||||
|
||||
func mountAndRunMarkdown(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "markdown"}
|
||||
@@ -182,7 +189,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Shortcuts()
|
||||
want := []string{"+create", "+fetch", "+patch", "+overwrite"}
|
||||
want := []string{"+create", "+diff", "+fetch", "+patch", "+overwrite"}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want))
|
||||
@@ -269,6 +276,27 @@ func TestMarkdownCreateValidationBranches(t *testing.T) {
|
||||
},
|
||||
want: "--folder-token cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "wiki token cannot be empty",
|
||||
args: []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--wiki-token=",
|
||||
},
|
||||
want: "--wiki-token cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "folder and wiki tokens are mutually exclusive",
|
||||
args: []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--folder-token", "fld_target",
|
||||
"--wiki-token", "wikcn_target",
|
||||
},
|
||||
want: "--folder-token and --wiki-token are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "folder token must be valid",
|
||||
args: []string{
|
||||
@@ -279,6 +307,16 @@ func TestMarkdownCreateValidationBranches(t *testing.T) {
|
||||
},
|
||||
want: "--folder-token",
|
||||
},
|
||||
{
|
||||
name: "wiki token must be valid",
|
||||
args: []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--wiki-token", "../bad",
|
||||
},
|
||||
want: "--wiki-token",
|
||||
},
|
||||
{
|
||||
name: "content mode still validates markdown file name",
|
||||
args: []string{
|
||||
@@ -372,11 +410,40 @@ func TestMarkdownCreateDryRunWithInlineContent(t *testing.T) {
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/files/upload_all") {
|
||||
t.Fatalf("dry-run missing upload_all: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/metas/batch_query") || !strings.Contains(out, `"with_url": true`) {
|
||||
t.Fatalf("dry-run missing metadata URL lookup: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "markdown content") {
|
||||
t.Fatalf("dry-run missing content marker: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--wiki-token", "wikcn_markdown_dryrun_target",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"parent_type": "wiki"`) {
|
||||
t.Fatalf("dry-run missing wiki parent_type: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"parent_node": "wikcn_markdown_dryrun_target"`) {
|
||||
t.Fatalf("dry-run missing wiki parent_node: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/metas/batch_query") || !strings.Contains(out, `"with_url": true`) {
|
||||
t.Fatalf("dry-run missing metadata URL lookup: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
@@ -416,6 +483,9 @@ func TestMarkdownCreateDryRunWithFileUsesStatOnly(t *testing.T) {
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/files/upload_prepare") {
|
||||
t.Fatalf("dry-run missing multipart prepare step: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/metas/batch_query") || !strings.Contains(out, `"with_url": true`) {
|
||||
t.Fatalf("dry-run missing metadata URL lookup: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "open should not be called in dry-run") {
|
||||
t.Fatalf("dry-run unexpectedly tried to open the source file: %s", out)
|
||||
}
|
||||
@@ -435,6 +505,18 @@ func TestMarkdownCreateSuccessUploadAll(t *testing.T) {
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_create", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_create"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
@@ -467,11 +549,60 @@ func TestMarkdownCreateSuccessUploadAll(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), `"file_name": "README.md"`) {
|
||||
t.Fatalf("stdout missing file_name: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"url": "https://www.feishu.cn/file/box_md_create"`) {
|
||||
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_create"`) {
|
||||
t.Fatalf("stdout missing url: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateSuccessUploadAllToWikiReturnsMetaURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_create_wiki",
|
||||
"version": "1002",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_create_wiki", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_create_wiki"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello\n",
|
||||
"--wiki-token", "wikcn_markdown_create_target",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["parent_type"]; got != markdownUploadParentTypeWiki {
|
||||
t.Fatalf("parent_type = %q, want %q", got, markdownUploadParentTypeWiki)
|
||||
}
|
||||
if got := body.Fields["parent_node"]; got != "wikcn_markdown_create_target" {
|
||||
t.Fatalf("parent_node = %q, want %q", got, "wikcn_markdown_create_target")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_create_wiki"`) {
|
||||
t.Fatalf("stdout missing metadata url for wiki-hosted markdown file: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -484,6 +615,18 @@ func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.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": "box_md_create_pretty", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_create_pretty"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
@@ -500,7 +643,7 @@ func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
|
||||
if !strings.Contains(out, "file_token: box_md_create_pretty") {
|
||||
t.Fatalf("pretty output missing file_token: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "url: https://www.feishu.cn/file/box_md_create_pretty") {
|
||||
if !strings.Contains(out, "url: https://tenant.example.com/file/box_md_create_pretty") {
|
||||
t.Fatalf("pretty output missing url: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "permission_grant.status: skipped") {
|
||||
@@ -511,6 +654,114 @@ func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateBotAutoGrantSkippedNoUser(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownPermissionTestConfig(""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_skipped",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_skipped", "doc_type": "file", "url": "https://example.feishu.cn/file/box_md_skipped"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello\n",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
grant, _ := envelope.Data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
|
||||
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateBotAutoGrantFailed(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownPermissionTestConfig("ou_current_user"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_grant_fail",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_grant_fail", "doc_type": "file", "url": "https://example.feishu.cn/file/box_md_grant_fail"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/box_md_grant_fail/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello\n",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
grant, _ := envelope.Data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
|
||||
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateMissingFileReturnsReadError(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
@@ -558,6 +809,18 @@ func TestMarkdownCreateMultipartUploadSuccess(t *testing.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": "box_md_multipart", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_multipart"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
@@ -586,6 +849,96 @@ func TestMarkdownCreateMultipartUploadSuccess(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), `"file_token": "box_md_multipart"`) {
|
||||
t.Fatalf("stdout missing multipart file_token: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_multipart"`) {
|
||||
t.Fatalf("stdout missing multipart metadata url: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateMultipartUploadToWikiUsesWikiParent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_markdown_wiki_ok",
|
||||
"block_size": float64(markdownSinglePartSizeLimit),
|
||||
"block_num": float64(2),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(prepareStub)
|
||||
uploadPartStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_part",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadPartStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_multipart_wiki",
|
||||
"version": "1005",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_multipart_wiki", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_multipart_wiki"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
fh, err := os.Create("large.md")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(markdownSinglePartSizeLimit + 1); err != nil {
|
||||
fh.Close()
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close() error: %v", err)
|
||||
}
|
||||
|
||||
err = mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--file", "large.md",
|
||||
"--wiki-token", "wikcn_markdown_multipart_target",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(prepareStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode upload_prepare body: %v\nraw=%s", err, string(prepareStub.CapturedBody))
|
||||
}
|
||||
if got := body["parent_type"]; got != markdownUploadParentTypeWiki {
|
||||
t.Fatalf("parent_type = %#v, want %q", got, markdownUploadParentTypeWiki)
|
||||
}
|
||||
if got := body["parent_node"]; got != "wikcn_markdown_multipart_target" {
|
||||
t.Fatalf("parent_node = %#v, want %q", got, "wikcn_markdown_multipart_target")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_multipart_wiki"`) {
|
||||
t.Fatalf("stdout missing metadata url for wiki-hosted multipart markdown file: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateFailsWhenMultipartPlanIsTooSmall(t *testing.T) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MarkdownCreate,
|
||||
MarkdownDiff,
|
||||
MarkdownFetch,
|
||||
MarkdownPatch,
|
||||
MarkdownOverwrite,
|
||||
|
||||
@@ -5,13 +5,18 @@ package shortcuts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/okr"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/apps"
|
||||
"github.com/larksuite/cli/shortcuts/base"
|
||||
"github.com/larksuite/cli/shortcuts/calendar"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -31,10 +36,28 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/wiki"
|
||||
)
|
||||
|
||||
// Empty brand (no config loaded) is treated as no-restriction so bootstrap
|
||||
// paths and tests without config still see the full service list.
|
||||
var brandRestrictedServices = map[string][]core.LarkBrand{
|
||||
"apps": {core.BrandFeishu},
|
||||
}
|
||||
|
||||
func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool {
|
||||
allowed, ok := brandRestrictedServices[service]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if brand == "" {
|
||||
return true
|
||||
}
|
||||
return slices.Contains(allowed, brand)
|
||||
}
|
||||
|
||||
// allShortcuts aggregates shortcuts from all domain packages.
|
||||
var allShortcuts []common.Shortcut
|
||||
|
||||
func init() {
|
||||
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
|
||||
@@ -67,6 +90,14 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) {
|
||||
}
|
||||
|
||||
func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) {
|
||||
// Factory.Config may be nil in tests that pass a zero-value factory.
|
||||
var brand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
brand = cfg.Brand
|
||||
}
|
||||
}
|
||||
|
||||
// Group by service
|
||||
byService := make(map[string][]common.Shortcut)
|
||||
for _, s := range allShortcuts {
|
||||
@@ -115,5 +146,46 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
if service == "mail" {
|
||||
mail.InstallOnMail(svc)
|
||||
}
|
||||
|
||||
if !IsShortcutServiceAvailable(service, brand) {
|
||||
installBrandRestrictionGuard(svc, service, brand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors internal/cmdpolicy/apply.go::installDenyStub: DisableFlagParsing +
|
||||
// ArbitraryArgs keep cobra from short-circuiting with "missing required flag"
|
||||
// before our RunE runs; leaf-level PersistentPreRunE defeats cobra's "first
|
||||
// PreRunE wins" walk-up that would otherwise shadow the stub.
|
||||
func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core.LarkBrand) {
|
||||
stub := func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return output.ErrValidation(
|
||||
"the %q feature is not yet supported on the %s brand",
|
||||
service, brand,
|
||||
)
|
||||
}
|
||||
noopPreRun := func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
}
|
||||
var walk func(c *cobra.Command)
|
||||
walk = func(c *cobra.Command) {
|
||||
c.Hidden = true
|
||||
c.DisableFlagParsing = true
|
||||
c.Args = cobra.ArbitraryArgs
|
||||
c.PreRunE = nil
|
||||
c.PreRun = nil
|
||||
c.PersistentPreRunE = noopPreRun
|
||||
c.PersistentPreRun = nil
|
||||
c.RunE = stub
|
||||
c.Run = nil
|
||||
for _, child := range c.Commands() {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(svc)
|
||||
|
||||
// --help bypasses RunE, so surface the restriction in Long too.
|
||||
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
|
||||
}
|
||||
|
||||
122
shortcuts/register_brand_guard_test.go
Normal file
122
shortcuts/register_brand_guard_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newFactoryWithBrand(brand core.LarkBrand) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) {
|
||||
return &core.CliConfig{Brand: brand}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func findChild(root *cobra.Command, name string) *cobra.Command {
|
||||
for _, c := range root.Commands() {
|
||||
if c.Name() == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBrandGuard_AppsStaysRegisteredOnLark(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark))
|
||||
|
||||
apps := findChild(program, "apps")
|
||||
if apps == nil {
|
||||
t.Fatal("apps service command should be registered on Lark brand (so users see a clear brand error, not 'unknown command')")
|
||||
}
|
||||
if !apps.Hidden {
|
||||
t.Error("apps service command should be Hidden on Lark brand")
|
||||
}
|
||||
if len(apps.Commands()) == 0 {
|
||||
t.Error("apps subcommands should still be mounted (so children also hit the brand-restriction stub)")
|
||||
}
|
||||
for _, child := range apps.Commands() {
|
||||
if !child.Hidden {
|
||||
t.Errorf("apps child %q should be Hidden on Lark brand", child.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrandGuard_AppsExecuteReturnsBrandError(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark))
|
||||
|
||||
apps := findChild(program, "apps")
|
||||
if apps == nil {
|
||||
t.Fatal("apps should be registered")
|
||||
}
|
||||
create := findChild(apps, "+create")
|
||||
if create == nil {
|
||||
t.Fatal("apps +create should be registered")
|
||||
}
|
||||
|
||||
err := create.RunE(create, []string{"--name", "x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected brand-restriction error, got nil")
|
||||
}
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("expected ExitValidation (%d), got %d", output.ExitValidation, exitErr.Code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Error(), "apps") || !strings.Contains(exitErr.Error(), "lark") {
|
||||
t.Errorf("expected error to mention apps + lark, got: %s", exitErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrandGuard_AppsExecutableOnFeishu(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newFactoryWithBrand(core.BrandFeishu))
|
||||
|
||||
apps := findChild(program, "apps")
|
||||
if apps == nil {
|
||||
t.Fatal("apps should be registered on Feishu brand")
|
||||
}
|
||||
if apps.Hidden {
|
||||
t.Error("apps should NOT be Hidden on Feishu brand")
|
||||
}
|
||||
create := findChild(apps, "+create")
|
||||
if create == nil {
|
||||
t.Fatal("apps +create should be registered on Feishu brand")
|
||||
}
|
||||
if create.DisableFlagParsing {
|
||||
t.Error("apps +create should not have DisableFlagParsing on Feishu (the guard must not have run)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrandGuard_DispatchHitsStubViaCobra(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark))
|
||||
|
||||
program.SetArgs([]string{"apps", "+create", "--name", "x", "--app-type", "HTML"})
|
||||
program.SetContext(context.Background())
|
||||
err := program.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error from dispatching apps +create on Lark brand")
|
||||
}
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *output.ExitError from cobra dispatch, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Error(), "lark") {
|
||||
t.Errorf("dispatched error should mention lark brand, got: %s", exitErr.Error())
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) {
|
||||
|
||||
for _, path := range [][]string{
|
||||
{"markdown", "+create"},
|
||||
{"markdown", "+diff"},
|
||||
{"markdown", "+fetch"},
|
||||
{"markdown", "+overwrite"},
|
||||
} {
|
||||
|
||||
@@ -5,13 +5,15 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -44,10 +46,6 @@ var SheetWriteImage = common.Shortcut{
|
||||
if err := validateSingleCellRange(runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := validateSheetWriteImageFile(runtime.Str("image"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -79,12 +77,19 @@ var SheetWriteImage = common.Shortcut{
|
||||
pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
imagePath := runtime.Str("image")
|
||||
safePath, stat, err := validateSheetWriteImageFile(imagePath)
|
||||
fio := runtime.FileIO()
|
||||
stat, err := validateSheetWriteImageFile(fio, imagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
imageBytes, err := vfs.ReadFile(safePath)
|
||||
imageFile, err := fio.Open(imagePath)
|
||||
if err != nil {
|
||||
return wrapSheetWriteImageOpenError(err)
|
||||
}
|
||||
defer imageFile.Close()
|
||||
|
||||
imageBytes, err := io.ReadAll(imageFile)
|
||||
if err != nil {
|
||||
return output.ErrValidation("cannot read image file: %s", err)
|
||||
}
|
||||
@@ -109,21 +114,37 @@ var SheetWriteImage = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func validateSheetWriteImageFile(imagePath string) (string, fs.FileInfo, error) {
|
||||
safePath, err := validate.SafeInputPath(imagePath)
|
||||
if err != nil {
|
||||
return "", nil, output.ErrValidation("unsafe image path: %s", err)
|
||||
func validateSheetWriteImageFile(fio fileio.FileIO, imagePath string) (fileio.FileInfo, error) {
|
||||
if fio == nil {
|
||||
return nil, output.ErrValidation("no file I/O provider registered")
|
||||
}
|
||||
stat, err := vfs.Stat(safePath)
|
||||
stat, err := fio.Stat(imagePath)
|
||||
if err != nil {
|
||||
return "", nil, output.ErrValidation("image file not found: %s", imagePath)
|
||||
return nil, wrapSheetWriteImageStatError(err, imagePath)
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return "", nil, output.ErrValidation("image must be a regular file: %s", imagePath)
|
||||
if stat.IsDir() || !stat.Mode().IsRegular() {
|
||||
return nil, output.ErrValidation("image must be a regular file: %s", imagePath)
|
||||
}
|
||||
const maxImageSize int64 = 20 * 1024 * 1024
|
||||
if stat.Size() > maxImageSize {
|
||||
return "", nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
|
||||
return nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
|
||||
}
|
||||
return safePath, stat, nil
|
||||
return stat, nil
|
||||
}
|
||||
|
||||
func wrapSheetWriteImageStatError(err error, imagePath string) error {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return output.ErrValidation("unsafe image path: %s", err)
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return output.ErrValidation("image file not found: %s", imagePath)
|
||||
}
|
||||
return output.ErrValidation("cannot stat image file: %s", err)
|
||||
}
|
||||
|
||||
func wrapSheetWriteImageOpenError(err error) error {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return output.ErrValidation("unsafe image path: %s", err)
|
||||
}
|
||||
return output.ErrValidation("cannot read image file: %s", err)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -463,7 +464,7 @@ func validateExpectedFlag(s string) error {
|
||||
}
|
||||
var arr []interface{}
|
||||
if err := json.Unmarshal([]byte(s), &arr); err != nil {
|
||||
return fmt.Errorf("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
|
||||
return output.ErrValidation("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -304,3 +304,88 @@ func decodeSheetCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]in
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestSheetCreateBotAutoGrantSkippedNoUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheet": map[string]interface{}{
|
||||
"spreadsheet_token": "shtcn_skipped",
|
||||
"url": "https://example.feishu.cn/sheets/shtcn_skipped",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSheetCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "No User Sheet",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSheetCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
|
||||
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreateBotAutoGrantFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "ou_current_user"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheet": map[string]interface{}{
|
||||
"spreadsheet_token": "shtcn_grant_fail",
|
||||
"url": "https://example.feishu.cn/sheets/shtcn_grant_fail",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/shtcn_grant_fail/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := runSheetCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "Grant Fail Sheet",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSheetCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
|
||||
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -29,6 +30,15 @@ func TestSheetCreateSheetValidateMissingToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInfoRequiresSpreadsheetMetaAndReadScopes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := []string{"sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"}
|
||||
if !reflect.DeepEqual(SheetInfo.Scopes, want) {
|
||||
t.Fatalf("SheetInfo scopes = %v, want %v", SheetInfo.Scopes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetManageValidateRejectsURLAndTokenTogether(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -7,12 +7,15 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -38,6 +41,56 @@ func mountAndRunSheets(t *testing.T, s common.Shortcut, args []string, f *cmduti
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
type sheetWriteImageStaticFileIOProvider struct {
|
||||
fio fileio.FileIO
|
||||
}
|
||||
|
||||
func (p *sheetWriteImageStaticFileIOProvider) Name() string { return "sheet-write-image-static" }
|
||||
|
||||
func (p *sheetWriteImageStaticFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO {
|
||||
return p.fio
|
||||
}
|
||||
|
||||
type sheetWriteImageMemoryFileIO struct {
|
||||
files map[string][]byte
|
||||
}
|
||||
|
||||
func (f *sheetWriteImageMemoryFileIO) Open(name string) (fileio.File, error) {
|
||||
data, ok := f.files[name]
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return sheetWriteImageMemoryFile{Reader: bytes.NewReader(data)}, nil
|
||||
}
|
||||
|
||||
func (f *sheetWriteImageMemoryFileIO) Stat(name string) (fileio.FileInfo, error) {
|
||||
data, ok := f.files[name]
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return sheetWriteImageFileInfo{size: int64(len(data))}, nil
|
||||
}
|
||||
|
||||
func (f *sheetWriteImageMemoryFileIO) ResolvePath(path string) (string, error) { return path, nil }
|
||||
|
||||
func (f *sheetWriteImageMemoryFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type sheetWriteImageMemoryFile struct {
|
||||
*bytes.Reader
|
||||
}
|
||||
|
||||
func (sheetWriteImageMemoryFile) Close() error { return nil }
|
||||
|
||||
type sheetWriteImageFileInfo struct {
|
||||
size int64
|
||||
}
|
||||
|
||||
func (i sheetWriteImageFileInfo) Size() int64 { return i.size }
|
||||
func (i sheetWriteImageFileInfo) IsDir() bool { return false }
|
||||
func (i sheetWriteImageFileInfo) Mode() fs.FileMode { return 0 }
|
||||
|
||||
const existingWriteImageTestFile = "./lark_sheets_cell_images.go"
|
||||
|
||||
// ── Validate ─────────────────────────────────────────────────────────────────
|
||||
@@ -221,80 +274,20 @@ func TestSheetWriteImageDryRunWithSheetID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageDryRunRejectsMissingFile(t *testing.T) {
|
||||
func TestSheetWriteImageDryRunDoesNotValidateImageFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetWriteImage, []string{
|
||||
"+write-image",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:A1",
|
||||
"--image", "./missing.png",
|
||||
"--image", "/__bridge_url__/qKrk1wSAtS",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "image file not found") {
|
||||
t.Fatalf("expected file-not-found error before dry-run planning, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageDryRunRejectsDirectory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cmdutil.TestChdir(t, tmpDir)
|
||||
if err := os.Mkdir("imgdir", 0o755); err != nil {
|
||||
t.Fatalf("Mkdir() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetWriteImage, []string{
|
||||
"+write-image",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:A1",
|
||||
"--image", "./imgdir",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "regular file") {
|
||||
t.Fatalf("expected regular-file error before dry-run planning, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageDryRunRejectsAbsolutePath(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetWriteImage, []string{
|
||||
"+write-image",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:A1",
|
||||
"--image", "/etc/passwd",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe image path") {
|
||||
t.Fatalf("expected unsafe-path error before dry-run planning, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageDryRunRejectsOversizedFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cmdutil.TestChdir(t, tmpDir)
|
||||
|
||||
fh, err := os.Create("huge.png")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
t.Fatalf("dry-run should not stat or open image files, got: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(20*1024*1024 + 1); err != nil {
|
||||
fh.Close()
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err = mountAndRunSheets(t, SheetWriteImage, []string{
|
||||
"+write-image",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:A1",
|
||||
"--image", "./huge.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds 20MB limit") {
|
||||
t.Fatalf("expected size error before dry-run planning, got: %v", err)
|
||||
if !strings.Contains(stdout.String(), "/__bridge_url__/qKrk1wSAtS") {
|
||||
t.Fatalf("dry-run output should preserve image path: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +361,55 @@ func TestSheetWriteImageExecuteSendsJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageExecuteUsesFileIOForBridgeSentinelPath(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
imagePath := "/__bridge_url__/qKrk1wSAtS"
|
||||
imageData := []byte{0x89, 0x50, 0x4E, 0x47}
|
||||
f.FileIOProvider = &sheetWriteImageStaticFileIOProvider{
|
||||
fio: &sheetWriteImageMemoryFileIO{
|
||||
files: map[string][]byte{imagePath: imageData},
|
||||
},
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheetToken": "shtTOKEN",
|
||||
"revision": float64(5),
|
||||
"updateRange": "sheet1!A1:A1",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetWriteImage, []string{
|
||||
"+write-image",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:A1",
|
||||
"--image", imagePath,
|
||||
"--name", "bridge.png",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("request body is not valid JSON: %v", err)
|
||||
}
|
||||
if body["name"] != "bridge.png" {
|
||||
t.Fatalf("body name = %v, want bridge.png", body["name"])
|
||||
}
|
||||
if body["image"] == nil {
|
||||
t.Fatal("body image field is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageExecuteRejectsNonexistentFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ import (
|
||||
var SheetInfo = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+info",
|
||||
Description: "View spreadsheet and sheet information",
|
||||
Description: "View spreadsheet metadata and sheet information",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
Scopes: []string{"sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
|
||||
@@ -147,6 +147,55 @@ func TestSlidesCreateBotSkippedWithoutCurrentUser(t *testing.T) {
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
|
||||
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateBotAutoGrantFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "ou_current_user"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_grant_fail",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_grant_fail", "https://example.feishu.cn/slides/pres_grant_fail")
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/pres_grant_fail/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "Grant Fail PPT",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
|
||||
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateDryRunDefaultTitle verifies that dry-run also normalizes an empty title to "Untitled".
|
||||
|
||||
@@ -38,7 +38,7 @@ const defaultTaskAttachmentResourceType = "task"
|
||||
var UploadAttachmentTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+upload-attachment",
|
||||
Description: "upload a local file as an attachment to a task; use --resource-type=task_delivery when uploading to task agents",
|
||||
Description: "upload a local file as an attachment to a task",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:attachment:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
|
||||
@@ -17,5 +17,8 @@ func Shortcuts() []common.Shortcut {
|
||||
WikiNodeCopy,
|
||||
WikiNodeGet,
|
||||
WikiNodeDelete,
|
||||
WikiMemberAdd,
|
||||
WikiMemberRemove,
|
||||
WikiMemberList,
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user