Compare commits

..

32 Commits

Author SHA1 Message Date
liangshuo-1
ce5b4f24e1 chore(release): v1.0.39 (#1052)
Change-Id: I06bca4f3aedec1adee9ecd3d060c333cc6dd301e
2026-05-22 21:10:35 +08:00
MaxHuang22
4b2223194b fix: add 22 new scope entries to scope priorities (#1050)
Change-Id: I2e7bb2e2971bfb071c3976d349b2d2bc4cc485ae
2026-05-22 19:48:08 +08:00
zgz2048
4582dfd281 docs(base): update location full_address guidance (#754) 2026-05-22 18:05:35 +08:00
ethan-zhx
5c01a7f7f0 feat(slides): export slides (#988)
Change-Id: Ice3e8784e78986d427c4c94664e1e5edff2a4fcd
2026-05-22 17:19:49 +08:00
raistlin042
d5d2fee848 chore(apps): refine lark-apps skill description and surface (#1040)
- description: switch from trigger-word enumeration to a general
  principle (any HTML artifact intended to be independently accessible
  falls under this skill; defer the deploy-vs-demo decision to the
  skill body)
- surface apps +access-scope-get in prerequisites list and Shortcuts
  table so agents can find the read side of access-scope
- add "writing HTML hard constraints" section: index.html is the
  required entry filename, --path cannot equal cwd (both are CLI-side
  hard rejects that previously only lived in the html-publish ref)
2026-05-22 16:39:36 +08:00
hGrany
ffcf7781b4 feat(sidecar): support multi-client identity isolation in server-demo (#934)
* feat(sidecar): support multi-client identity isolation in server-demo

When multiple CLI sandbox environments share a single sidecar instance,
user tokens (UAT) were not isolated -- the last user to log in would
overwrite previous users' tokens, causing identity cross-contamination.

This change introduces per-client HMAC key isolation:
- Each client gets a unique client-*.key file for data-plane HMAC signing,
  allowing the sidecar to identify request origin.
- A new auth_bridge.go handles management endpoints (login/poll/status)
  with explicit client-to-feishuOpenId binding.
- User token resolution is strictly bound to the matched client -- no
  fallback to other users' tokens when a client has no mapping.
- The shared proxy.key is reused across restarts instead of regenerated,
  fixing a race condition when multiple sidecar instances start together.

Wire protocol (sidecar package) is unchanged; existing single-client
deployments are fully backward compatible.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* fix(sidecar): address review feedback on filesystem and safety

- Replace os.ReadFile/WriteFile/ReadDir with vfs.* equivalents for test
  mockability, consistent with project coding guidelines.
- Limit auth bridge request body to 64KB to prevent memory exhaustion.
- Log errors in saveUserMap instead of silently discarding them.
- Reject client keys that collide with the shared proxy key.
- Reject duplicate client keys instead of silently overwriting.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* refactor(sidecar): remove workspace-specific naming and backward compat

- parseClientID: only accept "client_id" field, remove legacy fallback
- loadClientKeys: scan all *.key (excluding proxy.key), no prefix required
- Remove legacy file migration logic in newAuthBridge
- Update flag description to reflect generic key scanning

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* refactor(sidecar): extract multi-tenant demo and add unit tests

Address review feedback from sang-neo03:

1. Extract multi-client code into sidecar/server-multi-tenant-demo/,
   keeping server-demo as the minimal single-tenant reference.

2. Add unit tests for the isolation guarantee:
   - loadClientKeys: shared-key collision and duplicate keyHex are skipped
   - verifyWithClientKeys: correct client matched, unknown key rejected
   - loadUserMap/saveUserMap: round-trip persistence across restart

3. Cross-link READMEs between server-demo and server-multi-tenant-demo.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* docs(sidecar): rewrite multi-tenant demo README with problem statement and client guide

- Explain the multi-app credential isolation problem (app_secret must
  not be exposed to client environments)
- Document typical deployment topology with multiple sidecar instances
- Add complete client setup guide: env vars, multi-app switching, login
  flow, and end-to-end workflow example
- Document design decisions and management endpoint details

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* fix(sidecar): address CodeRabbit review feedback on tests and docs

- Make TestProxyHandler_AcceptsAllowedAuthHeaders fully offline by using
  httptest.NewTLSServer instead of depending on open.feishu.cn
- Isolate TestRun_RejectsSelfProxy config state with t.Setenv and temp dirs
- Check os.MkdirAll error in test fixture setup
- Add language identifiers to fenced code blocks (MD040)
- Validate user-supplied CLI paths with validate.SafeInputPath

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

---------

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)
2026-05-22 15:25:00 +08:00
liujiashu-shiro
fbe4cc689a feat(im): support Markdown image rendering in post content (#893)
add documentation for sending Markdown images, and align image handling guidance with actual runtime behavior
2026-05-22 10:44:10 +08:00
liangshuo-1
ac85c3e34d chore(release): v1.0.38 (#1026)
- Bump version to 1.0.38
- Update CHANGELOG.md with the apps brand gating change since v1.0.37
- Backfill the [v1.0.38] link reference at the bottom of CHANGELOG.md

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

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

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

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

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

Change-Id: Id9a87706a1e43756d8142637be9ec1e0748d4ddf

* fix: use markdown file comment anchor placeholder

Change-Id: Ifffc4cdd963c13e53f4cad154aebe11ae309df9e

* fix: gate drive file comments by supported extensions

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

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

---------

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

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

Closes #963
2026-05-21 18:17:24 +08:00
caojie0621
56749e70cb fix(sheets): use FileIO for write-image input (#996) 2026-05-21 15:53:44 +08:00
liangshuo-1
8c700aea00 chore(release): v1.0.36 (#1011)
Change-Id: Ifb0b6bf05d486943d9a689bf63dde2251dcd3500
2026-05-21 12:24:14 +08:00
MaxHuang22
42746d6c9d fix: revert incremental skills sync (#965) (#1008)
Change-Id: Ic95e8a74a0d6fc7f89782dccde867fd794cfcf46
2026-05-21 12:08:27 +08:00
zed
94b103dbf6 fix(auth): return validation error when --scope is empty in auth check (#999)
strings.Fields("") returns an empty slice, causing --scope "" to bypass
validation and return ok: true. Replace the false-positive success path
with an ErrValidation error so callers correctly detect the invalid input.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 11:52:05 +08:00
wangweiming-01
e19e09019c feat: return real tenant URLs for drive +upload and markdown +create (#992)
Change-Id: I6b513eef57a3479c8971b3bb6cbf005cad3f8040
2026-05-21 11:07:37 +08:00
search_zhuhao
3bab9a0692 docs(lark-drive): improve search evidence guidance (#864)
Change-Id: I000c2d56962e6da2a7ef77d986c2eb73ec286546
2026-05-20 20:45:41 +08:00
liangshuo-1
6840bb7415 chore(release): v1.0.35 (#995)
Change-Id: I6ddc8cfc029c684deb5de4f210357e19ade083e1
2026-05-20 19:46:10 +08:00
caojie0621
ce485eb3f5 fix(sheets): declare metadata scope for info shortcut (#994) 2026-05-20 19:43:21 +08:00
YangJunzhou-01
c98a49f2a3 docs(im): clarify media key formats for message media flags (#991)
* docs(im): clarify media path restrictions

* docs(im): clarify file key formats for message file flags

Change-Id: I329ca0db9e7a01b774846d522d1b2a64da74233c

---------

Co-authored-by: mtsui-cmyk <mervyntsui@gmail.com>
2026-05-20 17:39:14 +08:00
wangweiming-01
c02a38f077 feat: support wiki node target in markdown +create (#883)
Change-Id: Idb89464344599571cda3d27d136727553dcf0e7e
2026-05-20 17:03:32 +08:00
zhangheng023
3a3fc31d0b feat: add incremental skills sync (#965)
* feat: add incremental skills sync

* fix: address skills sync review feedback
2026-05-20 16:27:07 +08:00
wangweiming-01
8c73f49e91 docs: add media-preview reference (#990)
Change-Id: I5ba1991874e262fb98f3421e61503b58bb71d861
2026-05-20 15:59:39 +08:00
liujinkun2025
9272b9da99 docs(skills): migrate docs +search to drive +search and fix creator_ids owner semantic (#951)
docs +search is in maintenance and will be removed; cloud-space resource
discovery is consolidated onto drive +search. Two related doc/help fixes:

1. Redirect guidance: docs +search -> drive +search
   - skill-template/domains/{doc,sheets}.md
   - lark-base/SKILL.md: --filter '{"doc_types":["BITABLE"]}' -> --doc-types bitable
   - lark-sheets/SKILL.md: body + frontmatter description, add drive-search ref link
   Same server API, equivalent capability; only flattens the entry from
   nested --filter JSON to flags. reference links repointed to lark-drive.

2. Fix creator_ids/--mine semantic: creator -> owner
   The server matches creator_ids (incl. --mine / --creator-ids) by owner
   (document owner), not original creator, despite the OpenAPI field name.
   - shortcuts/drive/drive_search.go: --help Desc and Tip
   - lark-drive/references/lark-drive-search.md: identity section, params, rules, examples
   - lark-drive/SKILL.md: top-level guidance
   - lark-doc/references/lark-doc-search.md: creator_ids usage note (now self-consistent)
   Wire field name creator_ids kept (aligned with the server).

Docs/help strings only, no logic change; gofmt / go vet / package build pass.

Change-Id: If3ebf5a247b7e38b58050c677dc888a310f1c6b6
2026-05-20 15:08:50 +08:00
wangweiming-01
27a5eeddcc docs: prefer local comments for drive reviews (#981)
* docs: prefer local comments for drive reviews

Change-Id: Ie2eaa54320cd2612b66b2d617750d23b950e38db

* docs: align drive comment fallback guidance

Change-Id: Ia7512babe3656b57374c86068198c8192871ff81
2026-05-20 14:32:18 +08:00
zgz2048
0c4eadd41e docs: add wiki base fast path (#982) 2026-05-20 14:31:45 +08:00
yballul-bytedance
69c34481f5 feat: Product CLI 4no-meego (#759)
Change-Id: If08f236c8ae351f92683f2b861cc999eb6f1d22d
2026-05-20 14:02:03 +08:00
wangweiming-01
fa45e1c7e4 feat: add markdown +diff shortcut (#876)
* feat: add markdown +diff shortcut

Change-Id: I7da27889517707ac6f1d5e8c429e4bdfb49fdcf8

* fix: harden markdown diff downloads

Change-Id: I0020e14ebee780617d790836af1368db851b8cf1

* refactor: address markdown diff review feedback

Change-Id: I0ddb852218ec4784c0f9491896796c3007f04122
2026-05-20 12:20:51 +08:00
河伯
d793790807 feat(doc): warn before overwrite when document contains whiteboard or file blocks (#825)
* feat(doc): warn before overwrite when document contains whiteboard or file blocks

Before executing an overwrite in v1 mode, pre-fetch the current document
and scan the Markdown for <whiteboard> and <file> resource blocks. If any
are found, print a warning to stderr listing the counts and suggesting the
user take a backup with `docs +fetch` first.

Overwrite replaces the entire document and cannot reconstruct these blocks
from Markdown; previously the data was lost with no indication to the caller.
The check is best-effort: a failed pre-fetch silently skips the guard rather
than blocking the overwrite.

* test(doc): add validateSelectionByTitleV1 tests and drop redundant empty-md guard in warnOverwriteResourceBlocks

* fix(doc): use regex for resource block detection, add latency/coverage comments, document skip_task_detail purpose
2026-05-20 11:28:57 +08:00
154 changed files with 14641 additions and 934 deletions

2
.gitignore vendored
View File

@@ -42,3 +42,5 @@ app.log
/server-demo
.tmp/
cover*.out
lark-env.sh

View File

@@ -2,6 +2,72 @@
All notable changes to this project will be documented in this file.
## [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
@@ -774,6 +840,11 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[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

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 26 AI Agent [Skills](./skills/).
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
## Why lark-cli?
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/)
- **Wide Coverage** — 18 business domains, 200+ curated commands, 26 AI Agent [Skills](./skills/)
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
@@ -41,6 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
## Installation & Quick Start

View File

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

View File

@@ -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()

View File

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

View File

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

View File

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

View File

@@ -125,5 +125,5 @@ func getLoginMsg(lang string) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown"}
return []string{"base", "contact", "docs", "markdown", "apps"}
}

View File

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

3
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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"
}
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
},
}

View 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

View File

@@ -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 {

View File

@@ -70,10 +70,12 @@ func Shortcuts() []common.Shortcut {
BaseFormsList,
BaseFormUpdate,
BaseFormGet,
BaseFormDetail,
BaseFormQuestionsCreate,
BaseFormQuestionsDelete,
BaseFormQuestionsUpdate,
BaseFormQuestionsList,
BaseFormSubmit,
BaseDashboardList,
BaseDashboardGet,
BaseDashboardCreate,

View File

@@ -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
}

View File

@@ -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())

View File

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

View File

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

View File

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

View File

@@ -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 "),
)
}

View File

@@ -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)
}
})
}
}

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ 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",
@@ -32,8 +32,8 @@ var DriveExport = common.Shortcut{
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)"},

View File

@@ -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 ""
}

View File

@@ -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)
}
}

View File

@@ -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"},

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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")

View File

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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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)"},
},

View File

@@ -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")

View File

@@ -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 ""
}

View File

@@ -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 != "" {

View File

@@ -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

View 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
},
}

View 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())
}
}

View File

@@ -33,6 +33,13 @@ func markdownTestConfig() *core.CliConfig {
}
}
func markdownPermissionTestConfig(userOpenID string) *core.CliConfig {
return &core.CliConfig{
AppID: "markdown-perm-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
UserOpenId: userOpenID,
}
}
func mountAndRunMarkdown(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "markdown"}
@@ -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) {

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MarkdownCreate,
MarkdownDiff,
MarkdownFetch,
MarkdownPatch,
MarkdownOverwrite,

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) {
for _, path := range [][]string{
{"markdown", "+create"},
{"markdown", "+diff"},
{"markdown", "+fetch"},
{"markdown", "+overwrite"},
} {

View File

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

View File

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

View File

@@ -7,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()

View File

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

View File

@@ -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"},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -195,3 +195,9 @@ its identity flipped (bot↔user) or its auth-header redirected (e.g. into
| `allowlist.go` | Target host / identity allowlists |
| `audit.go` | Log path/error sanitization |
| `handler_test.go` | Unit tests for all of the above |
## See also
- [server-multi-tenant-demo](../server-multi-tenant-demo/) — extends this demo
with per-client HMAC key isolation, OAuth device-flow login, and persistent
client → user mapping for multi-tenant deployments

View File

@@ -0,0 +1,281 @@
# Multi-Tenant Sidecar Server Demo
> ⚠️ **This is a demo.** For production deployment, implement your own sidecar
> server conforming to the wire protocol in `github.com/larksuite/cli/sidecar`.
## Problem
Organizations often manage **multiple Lark/Feishu apps** (e.g. one per
department, one per product line), each with its own `app_id` and `app_secret`.
These credentials must never be exposed to end-user environments (CI runners,
developer sandboxes, containerized workspaces). At the same time, when multiple
users share the same sidecar infrastructure, their Feishu identities must be
strictly isolated — user A must never accidentally operate as user B.
The single-tenant [server-demo](../server-demo/) solves the credential-hiding
problem for **one app with one user**. This multi-tenant demo extends it to
support:
1. **Multiple apps** — run one sidecar instance per app; each instance holds
its own `app_id` / `app_secret` and listens on a separate port. Clients
choose which app to use by pointing `LARKSUITE_CLI_AUTH_PROXY` to the
corresponding port.
2. **Per-client identity isolation** — each client environment gets a unique
HMAC key. The sidecar identifies request origin by matching the HMAC
signature and injects the correct user's token. No fallback to other
users' tokens.
3. **Self-service user login** — management endpoints let each client initiate
an OAuth device-flow login to bind their own Feishu identity, without
exposing `app_secret` to the client.
## Typical deployment
```text
Trusted Host
┌──────────────────────────────────────────────┐
│ sidecar instance A (port 16384) │
│ app_id=cli_aaa app_secret=*** │
│ keys/proxy.key keys/alice.key keys/bob… │
│ │
│ sidecar instance B (port 16385) │
│ app_id=cli_bbb app_secret=*** │
│ keys/proxy.key keys/charlie.key ... │
└─────────────┬────────────────────────────────┘
│ same machine (loopback / docker bridge)
┌─────────────┴────────────────────────────────┐
│ Client sandbox (container / CI runner) │
│ │
│ LARKSUITE_CLI_AUTH_PROXY=http://host:16384 │
│ LARKSUITE_CLI_PROXY_KEY=<contents of │
│ alice.key> │
│ LARKSUITE_CLI_APP_ID=cli_aaa │
│ LARKSUITE_CLI_BRAND=feishu │
│ │
│ $ lark api GET /open-apis/... --as user │
│ → sidecar matches alice.key │
│ → injects alice's Feishu user token │
└──────────────────────────────────────────────┘
```
**Key points:**
- `app_id` and `app_secret` live only on the trusted host — clients only
know `app_id` (needed for the CLI's credential pipeline) and their own
HMAC key.
- Each sidecar instance binds one app. Multiple apps = multiple instances
on different ports.
- Clients select which app to use by choosing which sidecar port to connect
to (via `LARKSUITE_CLI_AUTH_PROXY`).
## Architecture
```text
┌──────────────────────────────────────────────────────┐
│ Sidecar Server │
│ │
│ ┌─────────────┐ ┌──────────────────────────────┐ │
│ │ Shared Key │ │ Per-Client Keys │ │
│ │ (proxy.key) │ │ alice.key, bob.key, ... │ │
│ └──────┬──────┘ └──────────────┬───────────────┘ │
│ │ management plane │ data plane │
│ ▼ ▼ │
│ ┌─────────────┐ ┌──────────────────────────────┐ │
│ │ Auth Bridge │ │ Proxy Handler │ │
│ │ login/poll/ │ │ HMAC verify → identify │ │
│ │ status │ │ client → inject user token │ │
│ └─────────────┘ └──────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
```
**Dual-key design:**
- **Management plane** (login flow): all clients use the shared `proxy.key`.
This allows any client to initiate login and query status without needing
individual key files pre-provisioned.
- **Data plane** (API proxy): each client uses its own `{name}.key` for HMAC
signing. The sidecar identifies the client by matching which key verifies
the request signature, then injects that client's bound user token.
## Build
```bash
go build -tags authsidecar_multi_tenant_demo \
-o sidecar-multi-tenant-demo \
./sidecar/server-multi-tenant-demo/
```
## Server setup
### 1. Configure the Lark app (trusted side only)
```bash
lark-cli config init --new # set app_id / app_secret
```
### 2. Prepare the keys directory
```text
keys/
├── proxy.key # shared key (auto-generated on first run)
├── alice.key # client "alice" — generate with: openssl rand -hex 32 > alice.key
├── bob.key # client "bob"
└── charlie.key # client "charlie"
```
- Each file contains a 64-character hex string (32 bytes).
- Filename stem (without `.key`) becomes the client identity.
- `proxy.key` is excluded from client key scanning.
- Keys are auto-rescanned on cache miss — add a new `.key` file and the next
unrecognized request will trigger a rescan; no restart needed.
- Duplicate key values and shared-key collisions are rejected with a warning.
### 3. Start the server
```bash
./sidecar-multi-tenant-demo \
--listen 127.0.0.1:16384 \
--key-file /path/to/keys/proxy.key \
--keys-dir /path/to/keys/ \
--log-file /path/to/audit.log
```
| Flag | Default | Purpose |
| --- | --- | --- |
| `--listen` | `127.0.0.1:16384` | Address to bind the HTTP listener |
| `--key-file` | `~/.lark-sidecar/proxy.key` | Shared HMAC key path (created if absent) |
| `--keys-dir` | *(parent of `--key-file`)* | Directory containing per-client `*.key` files |
| `--log-file` | *(stderr)* | Audit log output path |
| `--profile` | *(active profile)* | lark-cli profile name for credential lookup |
## Client setup
**No changes to `lark-cli` itself are required.** The standard sidecar env
vars are all that's needed — the multi-tenant isolation is entirely
server-side.
### Required environment variables
```bash
# Point to the sidecar instance for the desired app
export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16384"
# Client-specific HMAC key (data-plane identity)
export LARKSUITE_CLI_PROXY_KEY="$(cat /path/to/keys/alice.key)"
# Must match the app configured on the sidecar instance
export LARKSUITE_CLI_APP_ID="cli_xxx"
# feishu or lark
export LARKSUITE_CLI_BRAND="feishu"
```
### Multi-app switching (multiple sidecar instances)
When the server operator runs multiple sidecar instances (one per app), clients
switch between apps by changing `LARKSUITE_CLI_AUTH_PROXY` to point to the
appropriate port:
```bash
# App A (e.g. "Marketing" app)
export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16384"
export LARKSUITE_CLI_APP_ID="cli_marketing_app"
# App B (e.g. "Engineering" app)
export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16385"
export LARKSUITE_CLI_APP_ID="cli_engineering_app"
```
A client-side helper script can present these as a menu (e.g. "Select
company"), reading from a local config file that maps app names to ports.
The sidecar itself does not implement app selection — it is one instance per
app by design.
### User login flow
Once the env vars are set, the client authenticates via the management
endpoints. A helper script (or manual `curl`) calls:
1. **Login**: `POST /_sidecar/auth/login` with `{"client_id": "alice"}`
returns a device code and verification URL.
2. **User opens the URL in a browser** and authorizes the app.
3. **Poll**: `POST /_sidecar/auth/poll` with `{"device_code": "...", "client_id": "alice"}`
blocks until authorization completes.
4. **Status**: `POST /_sidecar/auth/status` with `{"client_id": "alice"}`
returns the bound user name and token status.
All management requests are signed with the **shared `proxy.key`** (not the
client-specific key). The `client_id` in the body tells the sidecar which
client→user mapping to update.
After login, `lark-cli` commands (`lark api ...`, `lark doc ...`, etc.) work
immediately — the sidecar injects the correct user token based on the
client's HMAC key, with no additional configuration needed.
### Example: end-to-end workflow
```bash
# 1. Server operator generates a key for a new client
openssl rand -hex 32 > /path/to/keys/alice.key
# 2. Client environment is configured (e.g. in .bashrc or container init)
export LARKSUITE_CLI_AUTH_PROXY="http://host.docker.internal:16384"
export LARKSUITE_CLI_PROXY_KEY="$(cat /path/to/keys/alice.key)"
export LARKSUITE_CLI_APP_ID="cli_xxx"
export LARKSUITE_CLI_BRAND="feishu"
# 3. Client logs in (one-time)
# (using a helper script that calls the management endpoints)
lark-auth login
# 4. Client uses lark-cli as normal — identity is automatically resolved
lark api GET /open-apis/authen/v1/user_info --as user
# → returns alice's Feishu identity, not another user's
```
## Management endpoints
| Endpoint | Method | Body | Purpose |
| --- | --- | --- | --- |
| `/_sidecar/auth/login` | POST | `{"client_id": "...", "domains": [...]}` | Start OAuth device-flow |
| `/_sidecar/auth/poll` | POST | `{"device_code": "...", "client_id": "..."}` | Poll for completion |
| `/_sidecar/auth/status` | POST | `{"client_id": "..."}` | Query status and mapping |
All management requests require HMAC signing with the shared `proxy.key`.
The HMAC covers method, path, timestamp, and body SHA-256 — see
`verifyManagementHMAC` in `auth_bridge.go` for the canonical string format.
## Design decisions
1. **HMAC key as client identity** — the key is the existing trust anchor.
Using it for identification introduces no new trust assumptions and
prevents a malicious client from spoofing another client's identity
(unlike a header-based approach).
2. **No fallback on unmapped clients** — this is authentication. Silently
falling back to another user's token is a security violation. Unmapped
clients receive an explicit error prompting them to log in.
3. **One sidecar instance per app** — keeps `app_secret` scoping simple and
avoids cross-app token confusion. Multi-app support is achieved by running
multiple instances on different ports.
4. **Proxy.key reuse across restarts** — when multiple sidecar instances start
concurrently, they all write to the same key file. The last writer wins,
leaving other instances with stale in-memory keys. Reusing the existing
key eliminates this race.
## Source layout
| File | Purpose |
| --- | --- |
| `main.go` | Entry point: flag parsing, key loading, server lifecycle |
| `handler.go` | `proxyHandler.ServeHTTP` — multi-key HMAC verification and request forwarding |
| `auth_bridge.go` | Management endpoints: login, poll, status, user mapping persistence |
| `forward.go` | Forwarding HTTP client + proxy-header filter |
| `allowlist.go` | Target host / identity allowlists |
| `audit.go` | Log path/error sanitization |
| `handler_test.go` | Unit tests |
## See also
- [server-demo](../server-demo/) — single-tenant minimal implementation
- [`sidecar` package](https://pkg.go.dev/github.com/larksuite/cli/sidecar) — wire protocol

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_demo
package main
import (
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/sidecar"
)
// buildAllowedHosts extracts the set of allowed target hostnames from
// multiple brand endpoints so the sidecar can serve both feishu and lark clients.
func buildAllowedHosts(endpoints ...core.Endpoints) map[string]bool {
hosts := make(map[string]bool)
for _, ep := range endpoints {
for _, u := range []string{ep.Open, ep.Accounts, ep.MCP} {
if idx := strings.Index(u, "://"); idx >= 0 {
hosts[u[idx+3:]] = true
}
}
}
return hosts
}
// buildAllowedIdentities returns the set of identities the sidecar is allowed to serve,
// based on the trusted-side strict mode / SupportedIdentities configuration.
func buildAllowedIdentities(cfg *core.CliConfig) map[string]bool {
ids := make(map[string]bool)
switch {
case cfg.SupportedIdentities == 0: // unknown/unset → allow both
ids[sidecar.IdentityUser] = true
ids[sidecar.IdentityBot] = true
case cfg.SupportedIdentities&1 != 0: // SupportsUser bit
ids[sidecar.IdentityUser] = true
}
if cfg.SupportedIdentities == 0 || cfg.SupportedIdentities&2 != 0 { // SupportsBot bit
ids[sidecar.IdentityBot] = true
}
return ids
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_demo
package main
import "strings"
// sanitizePath strips query parameters and replaces ID-like path segments
// with ":id" to prevent document tokens, chat IDs, etc. from leaking into logs.
// Example: /open-apis/docx/v1/documents/doxcnXXXX/blocks → /open-apis/docx/v1/documents/:id/blocks
func sanitizePath(pathAndQuery string) string {
// Strip query
path := pathAndQuery
if i := strings.IndexByte(path, '?'); i >= 0 {
path = path[:i]
}
// Replace ID-like segments (8+ chars, not a pure API keyword)
parts := strings.Split(path, "/")
for i, p := range parts {
if looksLikeID(p) {
parts[i] = ":id"
}
}
return strings.Join(parts, "/")
}
// looksLikeID returns true if a path segment appears to be a resource identifier
// rather than an API route keyword. Heuristic: 8+ chars and contains a digit.
func looksLikeID(seg string) bool {
if len(seg) < 8 {
return false
}
for _, c := range seg {
if c >= '0' && c <= '9' {
return true
}
}
return false
}
// sanitizeError returns a safe error string for logging, capped at 200 bytes
// to avoid dumping upstream response bodies into audit logs.
func sanitizeError(err error) string {
s := err.Error()
if len(s) > 200 {
return s[:200] + "..."
}
return s
}

View File

@@ -0,0 +1,530 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_demo
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/vfs"
)
// authBridge handles /_sidecar/auth/* management endpoints.
// Supports multi-user token isolation: each client environment gets its own
// Feishu identity via a clientName → feishuOpenId mapping.
//
// Identity chain: PROXY_KEY → clientName → feishuOpenId → keychain token
type authBridge struct {
key []byte
appID string
appSecret string
brand core.LarkBrand
cred *credential.CredentialProvider
logger *log.Logger
httpCl *http.Client
mu sync.Mutex
pendingPolls map[string]context.CancelFunc
// clientName → feishuOpenId (protected by mu)
userMap map[string]string
mapFile string
}
func newAuthBridge(key []byte, appID, appSecret string, brand core.LarkBrand, cred *credential.CredentialProvider, logger *log.Logger) *authBridge {
configDir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR")
mapFile := ""
if configDir != "" {
mapFile = filepath.Join(configDir, "client_user_map.json")
}
ab := &authBridge{
key: key,
appID: appID,
appSecret: appSecret,
brand: brand,
cred: cred,
logger: logger,
httpCl: &http.Client{Timeout: 30 * time.Second},
pendingPolls: make(map[string]context.CancelFunc),
userMap: make(map[string]string),
mapFile: mapFile,
}
ab.loadUserMap()
return ab
}
func (ab *authBridge) loadUserMap() {
if ab.mapFile == "" {
return
}
data, err := vfs.ReadFile(ab.mapFile)
if err != nil {
return
}
var m map[string]string
if json.Unmarshal(data, &m) == nil && m != nil {
ab.userMap = m
}
}
func (ab *authBridge) saveUserMap() {
if ab.mapFile == "" {
return
}
data, err := json.MarshalIndent(ab.userMap, "", " ")
if err != nil {
ab.logger.Printf("AUTH_BRIDGE_ERROR action=save_user_map error=%q", err.Error())
return
}
if err := vfs.WriteFile(ab.mapFile, data, 0600); err != nil {
ab.logger.Printf("AUTH_BRIDGE_ERROR action=save_user_map error=%q", err.Error())
}
}
// verifyManagementHMAC checks a simplified HMAC for management endpoints.
// Canonical string: "sidecar-mgmt\n<method>\n<path>\n<timestamp>\n<body_sha256>"
func (ab *authBridge) verifyManagementHMAC(r *http.Request, body []byte) error {
ts := r.Header.Get("X-Sidecar-Timestamp")
sig := r.Header.Get("X-Sidecar-Signature")
bodySha := r.Header.Get("X-Sidecar-Body-SHA256")
if ts == "" || sig == "" || bodySha == "" {
return fmt.Errorf("missing required headers")
}
tsVal, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp")
}
drift := math.Abs(float64(time.Now().Unix() - tsVal))
if drift > 60 {
return fmt.Errorf("timestamp drift %.0fs exceeds limit", drift)
}
actualSha := sha256Hex(body)
if bodySha != actualSha {
return fmt.Errorf("body SHA256 mismatch")
}
canonical := "sidecar-mgmt\n" + r.Method + "\n" + r.URL.Path + "\n" + ts + "\n" + bodySha
mac := hmac.New(sha256.New, ab.key)
mac.Write([]byte(canonical))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(sig)) {
return fmt.Errorf("HMAC signature mismatch")
}
return nil
}
func sha256Hex(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
// ServeHTTP routes management API requests.
func (ab *authBridge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
if err != nil {
jsonError(w, http.StatusBadRequest, "failed to read body")
return
}
r.Body.Close()
if err := ab.verifyManagementHMAC(r, body); err != nil {
jsonError(w, http.StatusUnauthorized, "HMAC verification failed: "+err.Error())
ab.logger.Printf("AUTH_BRIDGE_REJECT path=%s reason=%q", r.URL.Path, err.Error())
return
}
switch r.URL.Path {
case "/_sidecar/auth/login":
ab.handleLogin(w, r, body)
case "/_sidecar/auth/poll":
ab.handlePoll(w, r, body)
case "/_sidecar/auth/status":
ab.handleStatus(w, r, body)
default:
jsonError(w, http.StatusNotFound, "unknown management endpoint")
}
}
// parseClientID extracts the client identifier from a JSON body.
func parseClientID(body []byte) string {
var raw struct {
ClientID string `json:"client_id"`
}
if len(body) > 0 {
_ = json.Unmarshal(body, &raw)
}
return raw.ClientID
}
// handleLogin initiates a device-flow OAuth login.
func (ab *authBridge) handleLogin(w http.ResponseWriter, _ *http.Request, body []byte) {
var req struct {
Scope string `json:"scope"`
Domains []string `json:"domains"`
}
if len(body) > 0 {
_ = json.Unmarshal(body, &req)
}
clientID := parseClientID(body)
scope := req.Scope
if scope == "" {
scope = loadCachedScopes()
}
if scope == "" {
scope = "offline_access"
}
ab.logger.Printf("AUTH_BRIDGE_LOGIN_SCOPE scope_count=%d domains=%v client=%s",
len(strings.Fields(scope)), req.Domains, clientID)
authResp, err := larkauth.RequestDeviceAuthorization(
ab.httpCl, ab.appID, ab.appSecret, ab.brand, scope, io.Discard,
)
if err != nil {
jsonError(w, http.StatusBadGateway, "device authorization failed: "+err.Error())
ab.logger.Printf("AUTH_BRIDGE_ERROR action=login error=%q", err.Error())
return
}
ab.logger.Printf("AUTH_BRIDGE_LOGIN device_code_prefix=%s expires_in=%d",
truncate(authResp.DeviceCode, 12), authResp.ExpiresIn)
resp := map[string]interface{}{
"ok": true,
"verification_url": authResp.VerificationUriComplete,
"user_code": authResp.UserCode,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"interval": authResp.Interval,
}
jsonOK(w, resp)
}
// handlePoll polls the device-flow token endpoint.
// Binds the resulting feishu identity to the client on success.
func (ab *authBridge) handlePoll(w http.ResponseWriter, r *http.Request, body []byte) {
var req struct {
DeviceCode string `json:"device_code"`
}
if err := json.Unmarshal(body, &req); err != nil || req.DeviceCode == "" {
jsonError(w, http.StatusBadRequest, "device_code is required")
return
}
clientID := parseClientID(body)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
defer cancel()
ab.mu.Lock()
if oldCancel, ok := ab.pendingPolls[req.DeviceCode]; ok {
oldCancel()
}
ab.pendingPolls[req.DeviceCode] = cancel
ab.mu.Unlock()
defer func() {
ab.mu.Lock()
delete(ab.pendingPolls, req.DeviceCode)
ab.mu.Unlock()
}()
result := larkauth.PollDeviceToken(
ctx, ab.httpCl, ab.appID, ab.appSecret, ab.brand,
req.DeviceCode, 5, 600, io.Discard,
)
if !result.OK {
resp := map[string]interface{}{
"ok": false,
"error": result.Error,
"msg": result.Message,
}
jsonOK(w, resp)
ab.logger.Printf("AUTH_BRIDGE_POLL_FAIL device_code_prefix=%s error=%q",
truncate(req.DeviceCode, 12), result.Message)
return
}
if result.Token == nil {
jsonError(w, http.StatusInternalServerError, "token response was nil")
return
}
now := time.Now().UnixMilli()
storedToken := &larkauth.StoredUAToken{
AppId: ab.appID,
AccessToken: result.Token.AccessToken,
RefreshToken: result.Token.RefreshToken,
ExpiresAt: now + int64(result.Token.ExpiresIn)*1000,
RefreshExpiresAt: now + int64(result.Token.RefreshExpiresIn)*1000,
Scope: result.Token.Scope,
GrantedAt: now,
}
ep := core.ResolveEndpoints(ab.brand)
openID, userName, err := fetchUserInfoDirect(ab.httpCl, ep.Open, result.Token.AccessToken)
if err != nil {
ab.logger.Printf("AUTH_BRIDGE_WARN action=user_info error=%q", err.Error())
jsonError(w, http.StatusBadGateway, "login succeeded but failed to get user info: "+err.Error())
return
}
storedToken.UserOpenId = openID
if err := larkauth.SetStoredToken(storedToken); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to store token: "+err.Error())
return
}
if err := addUserToConfig(ab.appID, openID, userName); err != nil {
ab.logger.Printf("AUTH_BRIDGE_WARN action=sync_config error=%q", err.Error())
}
if clientID != "" {
ab.mu.Lock()
ab.userMap[clientID] = openID
ab.saveUserMap()
ab.mu.Unlock()
ab.logger.Printf("AUTH_BRIDGE_MAP client=%s -> feishu=%s (%s)",
clientID, openID, userName)
}
ab.logger.Printf("AUTH_BRIDGE_LOGIN_OK user=%s open_id=%s scope_count=%d client=%s",
userName, openID, len(strings.Fields(result.Token.Scope)), clientID)
resp := map[string]interface{}{
"ok": true,
"user_name": userName,
"open_id": openID,
}
jsonOK(w, resp)
}
// handleStatus returns current auth status.
// Accepts client_id in body for client-specific mapping.
func (ab *authBridge) handleStatus(w http.ResponseWriter, _ *http.Request, body []byte) {
clientID := parseClientID(body)
multi, err := core.LoadMultiAppConfig()
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to load config: "+err.Error())
return
}
var users []map[string]interface{}
for _, app := range multi.Apps {
if app.AppId != ab.appID {
continue
}
for _, u := range app.Users {
stored := larkauth.GetStoredToken(ab.appID, u.UserOpenId)
status := "unknown"
if stored != nil {
status = larkauth.TokenStatus(stored)
}
users = append(users, map[string]interface{}{
"user_name": u.UserName,
"user_open_id": u.UserOpenId,
"token_status": status,
})
}
}
resp := map[string]interface{}{
"ok": true,
"users": users,
}
if clientID != "" {
ab.mu.Lock()
mappedOpenID := ab.userMap[clientID]
ab.mu.Unlock()
resp["client_id"] = clientID
resp["mapped_open_id"] = mappedOpenID
if mappedOpenID != "" {
stored := larkauth.GetStoredToken(ab.appID, mappedOpenID)
if stored != nil {
resp["mapped_status"] = larkauth.TokenStatus(stored)
for _, u := range users {
if u["user_open_id"] == mappedOpenID {
resp["mapped_user_name"] = u["user_name"]
break
}
}
} else {
resp["mapped_status"] = "no_token"
}
} else {
resp["mapped_status"] = "not_mapped"
}
}
jsonOK(w, resp)
}
// resolveUserTokenByClient resolves a UAT for a specific client environment.
// Returns an error if the client has no user mapping — the user must
// run the login flow first. No fallback to other users' tokens.
func (ab *authBridge) resolveUserTokenByClient(clientName string) (string, error) {
ab.mu.Lock()
openID := ab.userMap[clientName]
ab.mu.Unlock()
if openID == "" {
ab.logger.Printf("AUTH_BRIDGE_REJECT_NO_MAPPING client=%s", clientName)
return "", fmt.Errorf("client %q has no user mapping; run the login flow to authorize", clientName)
}
ab.logger.Printf("AUTH_BRIDGE_RESOLVE client=%s feishu=%s", clientName, openID)
opts := larkauth.UATCallOptions{
UserOpenId: openID,
AppId: ab.appID,
AppSecret: ab.appSecret,
Domain: ab.brand,
}
token, err := larkauth.GetValidAccessToken(ab.httpCl, opts)
if err != nil {
return "", fmt.Errorf("failed to resolve token for user %s: %v", openID, err)
}
return token, nil
}
func addUserToConfig(appID, openID, userName string) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
return err
}
for i := range multi.Apps {
if multi.Apps[i].AppId != appID {
continue
}
found := false
for j := range multi.Apps[i].Users {
if multi.Apps[i].Users[j].UserOpenId == openID {
multi.Apps[i].Users[j].UserName = userName
found = true
break
}
}
if !found {
multi.Apps[i].Users = append(multi.Apps[i].Users, core.AppUser{
UserOpenId: openID,
UserName: userName,
})
}
return core.SaveMultiAppConfig(multi)
}
return fmt.Errorf("app %s not found in config", appID)
}
func fetchUserInfoDirect(client *http.Client, openBase, accessToken string) (openID, name string, err error) {
u := openBase + "/open-apis/authen/v1/user_info"
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return "", "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", err
}
var result struct {
Code int `json:"code"`
Data struct {
OpenID string `json:"open_id"`
Name string `json:"name"`
} `json:"data"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", "", fmt.Errorf("parse user_info response: %w", err)
}
if result.Code != 0 {
return "", "", fmt.Errorf("user_info API error: [%d] %s", result.Code, result.Msg)
}
return result.Data.OpenID, result.Data.Name, nil
}
func jsonOK(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"error": msg,
})
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
func loadCachedScopes() string {
configDir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR")
if configDir == "" {
return ""
}
dir := filepath.Join(configDir, "cache", "auth_login_scopes")
entries, err := vfs.ReadDir(dir)
if err != nil {
return ""
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
data, err := vfs.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var doc struct {
RequestedScope string `json:"requested_scope"`
}
if json.Unmarshal(data, &doc) == nil && doc.RequestedScope != "" {
return doc.RequestedScope
}
}
return ""
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_demo
package main
import (
"fmt"
"net/http"
"time"
"github.com/larksuite/cli/sidecar"
)
// newForwardClient creates an HTTP client for forwarding requests to the
// Lark API. It strips Authorization on cross-host redirects and disables
// proxy to prevent real tokens from leaking through environment proxies.
func newForwardClient() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil // never proxy the trusted hop
return &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
req.Header.Del("Authorization")
req.Header.Del(sidecar.HeaderMCPUAT)
req.Header.Del(sidecar.HeaderMCPTAT)
}
return nil
},
}
}
// isProxyHeader returns true for headers specific to the sidecar protocol.
func isProxyHeader(key string) bool {
switch http.CanonicalHeaderKey(key) {
case http.CanonicalHeaderKey(sidecar.HeaderProxyTarget),
http.CanonicalHeaderKey(sidecar.HeaderProxyIdentity),
http.CanonicalHeaderKey(sidecar.HeaderProxySignature),
http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp),
http.CanonicalHeaderKey(sidecar.HeaderBodySHA256),
http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader):
return true
}
return false
}

View File

@@ -0,0 +1,372 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_demo
package main
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/url"
"path/filepath"
"strings"
"sync"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/sidecar"
)
// proxyHandler handles HTTP requests from sandbox CLI instances.
type proxyHandler struct {
key []byte
cred *credential.CredentialProvider
appID string
brand core.LarkBrand
logger *log.Logger
forwardCl *http.Client
allowedHosts map[string]bool // target host allowlist derived from brand
allowedIDs map[string]bool // identity allowlist derived from strict mode
authBridge *authBridge
// Per-client key isolation: keyHex → clientName.
// Data-plane requests are signed with a client-specific key;
// the matched key determines which client (and thus which user
// token) to use. Protected by ckMu.
ckMu sync.RWMutex
clientKeys map[string]clientKeyEntry
keysDir string // directory to scan for *.key files (excluding proxy.key)
}
type clientKeyEntry struct {
key []byte
clientName string
}
// loadClientKeys scans keysDir for *.key files (excluding the shared
// proxy.key) and populates the clientKeys map. The filename stem (without
// .key) becomes the client identity. No naming convention is enforced.
// Safe to call multiple times (e.g. on cache miss).
func (h *proxyHandler) loadClientKeys() {
if h.keysDir == "" {
return
}
entries, err := vfs.ReadDir(h.keysDir)
if err != nil {
h.logger.Printf("KEYS_SCAN_ERROR dir=%s error=%q", h.keysDir, err.Error())
return
}
sharedKeyHex := string(h.key)
newKeys := make(map[string]clientKeyEntry)
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".key") {
continue
}
clientName := strings.TrimSuffix(name, ".key")
if clientName == "" || clientName == "proxy" {
continue
}
data, err := vfs.ReadFile(filepath.Join(h.keysDir, name))
if err != nil {
continue
}
keyHex := strings.TrimSpace(string(data))
if len(keyHex) != 64 {
h.logger.Printf("KEYS_SCAN_SKIP file=%s reason=\"key length %d, expected 64\"", name, len(keyHex))
continue
}
if keyHex == sharedKeyHex {
h.logger.Printf("KEYS_SCAN_SKIP file=%s reason=\"collides with shared proxy key\"", name)
continue
}
if existing, ok := newKeys[keyHex]; ok {
h.logger.Printf("KEYS_SCAN_SKIP file=%s reason=\"duplicate key, already loaded for client %s\"", name, existing.clientName)
continue
}
newKeys[keyHex] = clientKeyEntry{key: []byte(keyHex), clientName: clientName}
}
h.ckMu.Lock()
h.clientKeys = newKeys
h.ckMu.Unlock()
if len(newKeys) > 0 {
names := make([]string, 0, len(newKeys))
for _, e := range newKeys {
names = append(names, e.clientName)
}
h.logger.Printf("KEYS_LOADED count=%d clients=%v", len(newKeys), names)
}
}
// verifyWithClientKeys tries each client key to verify the HMAC.
// Returns the client name on success, or empty string + error if none match.
func (h *proxyHandler) verifyWithClientKeys(cr sidecar.CanonicalRequest, signature string) (string, error) {
h.ckMu.RLock()
keys := h.clientKeys
h.ckMu.RUnlock()
for _, entry := range keys {
if err := sidecar.Verify(entry.key, cr, signature); err == nil {
return entry.clientName, nil
}
}
// Cache miss: rescan keys directory and retry once
h.loadClientKeys()
h.ckMu.RLock()
keys = h.clientKeys
h.ckMu.RUnlock()
for _, entry := range keys {
if err := sidecar.Verify(entry.key, cr, signature); err == nil {
return entry.clientName, nil
}
}
return "", fmt.Errorf("no client key matched")
}
// allowedAuthHeaders lists the only header names the sidecar will inject real
// tokens into.
var allowedAuthHeaders = map[string]bool{
"Authorization": true,
sidecar.HeaderMCPUAT: true,
sidecar.HeaderMCPTAT: true,
}
func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Route management endpoints to authBridge (different HMAC scheme)
if len(r.URL.Path) > 10 && r.URL.Path[:10] == "/_sidecar/" {
if h.authBridge != nil {
h.authBridge.ServeHTTP(w, r)
} else {
http.Error(w, "auth bridge not configured", http.StatusNotImplemented)
}
return
}
start := time.Now()
// 0. Check protocol version
version := r.Header.Get(sidecar.HeaderProxyVersion)
if version != sidecar.ProtocolV1 {
http.Error(w, "unsupported "+sidecar.HeaderProxyVersion+": "+version, http.StatusBadRequest)
return
}
// 1. Verify timestamp
ts := r.Header.Get(sidecar.HeaderProxyTimestamp)
if ts == "" {
http.Error(w, "missing "+sidecar.HeaderProxyTimestamp, http.StatusBadRequest)
return
}
// 2. Read body and verify SHA256
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read request body", http.StatusBadRequest)
return
}
r.Body.Close()
claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256)
if claimedSHA == "" {
http.Error(w, "missing "+sidecar.HeaderBodySHA256, http.StatusBadRequest)
return
}
actualSHA := sidecar.BodySHA256(body)
if claimedSHA != actualSHA {
http.Error(w, "body SHA256 mismatch", http.StatusBadRequest)
return
}
// 3. Verify HMAC signature
target := r.Header.Get(sidecar.HeaderProxyTarget)
if target == "" {
http.Error(w, "missing "+sidecar.HeaderProxyTarget, http.StatusBadRequest)
return
}
pathAndQuery := r.URL.RequestURI()
targetHost, err := parseTarget(target)
if err != nil {
http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+err.Error(), http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
return
}
identity := r.Header.Get(sidecar.HeaderProxyIdentity)
if identity == "" {
http.Error(w, "missing "+sidecar.HeaderProxyIdentity, http.StatusBadRequest)
return
}
authHeader := r.Header.Get(sidecar.HeaderProxyAuthHeader)
if authHeader == "" {
http.Error(w, "missing "+sidecar.HeaderProxyAuthHeader, http.StatusBadRequest)
return
}
signature := r.Header.Get(sidecar.HeaderProxySignature)
cr := sidecar.CanonicalRequest{
Version: version,
Method: r.Method,
Host: targetHost,
PathAndQuery: pathAndQuery,
BodySHA256: claimedSHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
}
// Try the primary (shared) key first, then per-client keys.
// matchedClient is empty when using the shared key.
var matchedClient string
if err := sidecar.Verify(h.key, cr, signature); err != nil {
client, clientErr := h.verifyWithClientKeys(cr, signature)
if clientErr != nil {
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), "no key matched")
return
}
matchedClient = client
}
// 4. Validate target host against allowlist
if !h.allowedHosts[targetHost] {
http.Error(w, "target host not allowed: "+targetHost, http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=\"target host %s not in allowlist\"", r.Method, sanitizePath(pathAndQuery), targetHost)
return
}
// 5. Validate identity
if !h.allowedIDs[identity] {
http.Error(w, "identity not allowed: "+identity, http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=\"identity %s not allowed by strict mode\"", r.Method, sanitizePath(pathAndQuery), identity)
return
}
// 5.5 Validate auth-header
if !allowedAuthHeaders[authHeader] {
http.Error(w, "auth-header not allowed: "+authHeader, http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=\"auth-header %s not in allowlist\"", r.Method, sanitizePath(pathAndQuery), authHeader)
return
}
// 6. Resolve real token
// UAT (user identity): per-client isolation via matched PROXY_KEY.
// TAT (bot identity): shared credential provider (app-level).
var resolvedToken string
if identity == sidecar.IdentityUser && h.authBridge != nil {
token, err := h.authBridge.resolveUserTokenByClient(matchedClient)
if err != nil {
http.Error(w, "failed to resolve user token: "+err.Error(), http.StatusInternalServerError)
h.logger.Printf("TOKEN_ERROR method=%s path=%s identity=%s client=%s error=%q",
r.Method, sanitizePath(pathAndQuery), identity, matchedClient, sanitizeError(err))
return
}
resolvedToken = token
} else {
tokenResult, err := h.cred.ResolveToken(r.Context(), credential.TokenSpec{
Type: credential.TokenTypeTAT,
AppID: h.appID,
})
if err != nil {
http.Error(w, "failed to resolve token: "+err.Error(), http.StatusInternalServerError)
h.logger.Printf("TOKEN_ERROR method=%s path=%s identity=%s error=%q", r.Method, sanitizePath(pathAndQuery), identity, sanitizeError(err))
return
}
resolvedToken = tokenResult.Token
}
// 7. Build forwarding request
forwardURL := "https://" + targetHost + pathAndQuery
forwardReq, err := http.NewRequestWithContext(r.Context(), r.Method, forwardURL, bytes.NewReader(body))
if err != nil {
http.Error(w, "failed to create forward request", http.StatusInternalServerError)
return
}
for k, vs := range r.Header {
if isProxyHeader(k) {
continue
}
for _, v := range vs {
forwardReq.Header.Add(k, v)
}
}
forwardReq.Header.Del("Authorization")
forwardReq.Header.Del(sidecar.HeaderMCPUAT)
forwardReq.Header.Del(sidecar.HeaderMCPTAT)
// 8. Inject real token
if authHeader == "Authorization" {
forwardReq.Header.Set("Authorization", "Bearer "+resolvedToken)
} else {
forwardReq.Header.Set(authHeader, resolvedToken)
}
// 9. Forward request
resp, err := h.forwardCl.Do(forwardReq)
if err != nil {
http.Error(w, "forward request failed: "+err.Error(), http.StatusBadGateway)
h.logger.Printf("FORWARD_ERROR method=%s path=%s error=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
return
}
defer resp.Body.Close()
// 10. Copy response back
for k, vs := range resp.Header {
for _, v := range vs {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
// 11. Audit log
clientTag := ""
if matchedClient != "" {
clientTag = " client=" + matchedClient
}
h.logger.Printf("FORWARD method=%s path=%s identity=%s status=%d duration=%s%s",
r.Method, sanitizePath(pathAndQuery), identity, resp.StatusCode, time.Since(start).Round(time.Millisecond), clientTag)
}
// parseTarget validates X-Lark-Proxy-Target and returns the host portion.
func parseTarget(target string) (host string, err error) {
u, perr := url.Parse(target)
if perr != nil {
return "", fmt.Errorf("parse: %w", perr)
}
if u.Scheme != "https" {
return "", fmt.Errorf("scheme must be https, got %q", u.Scheme)
}
if u.Host == "" {
return "", fmt.Errorf("missing host")
}
if u.User != nil {
return "", fmt.Errorf("userinfo not allowed")
}
if u.Path != "" && u.Path != "/" {
return "", fmt.Errorf("path not allowed (got %q)", u.Path)
}
if u.RawQuery != "" {
return "", fmt.Errorf("query not allowed")
}
if u.Fragment != "" {
return "", fmt.Errorf("fragment not allowed")
}
return u.Host, nil
}

View File

@@ -0,0 +1,878 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_demo
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
// fakeExtProvider is a stub extcred.Provider for tests that returns a fixed token.
type fakeExtProvider struct {
token string
}
func (f *fakeExtProvider) Name() string { return "fake" }
func (f *fakeExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) {
return nil, nil
}
func (f *fakeExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
return &extcred.Token{Value: f.token, Source: "fake"}, nil
}
func discardLogger() *log.Logger {
return log.New(io.Discard, "", 0)
}
func newTestHandler(key []byte) *proxyHandler {
return &proxyHandler{
key: key,
logger: discardLogger(),
forwardCl: &http.Client{},
allowedHosts: map[string]bool{
"open.feishu.cn": true,
"accounts.feishu.cn": true,
"mcp.feishu.cn": true,
},
allowedIDs: map[string]bool{
sidecar.IdentityUser: true,
sidecar.IdentityBot: true,
},
}
}
// signedReq creates a properly signed request for testing handler logic past
// HMAC verification. Identity defaults to bot and auth-header to
// "Authorization"; callers can override by mutating the returned request
// before calling ServeHTTP (and re-signing if they need the signature to
// remain valid after the mutation).
func signedReq(t *testing.T, key []byte, method, target, path string, body []byte) *http.Request {
t.Helper()
targetHost := target
if idx := strings.Index(target, "://"); idx >= 0 {
targetHost = target[idx+3:]
}
bodySHA := sidecar.BodySHA256(body)
ts := sidecar.Timestamp()
identity := sidecar.IdentityBot
authHeader := "Authorization"
sig := sidecar.Sign(key, sidecar.CanonicalRequest{
Version: sidecar.ProtocolV1,
Method: method,
Host: targetHost,
PathAndQuery: path,
BodySHA256: bodySHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
})
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
}
req := httptest.NewRequest(method, path, bodyReader)
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTarget, target)
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
req.Header.Set(sidecar.HeaderProxySignature, sig)
return req
}
// resign recomputes the HMAC signature over the request's current proxy
// headers. Use this in tests that mutate a signed field (Identity,
// AuthHeader, Target host, etc.) after calling signedReq.
func resign(t *testing.T, key []byte, req *http.Request, body []byte) {
t.Helper()
target := req.Header.Get(sidecar.HeaderProxyTarget)
targetHost := target
if idx := strings.Index(target, "://"); idx >= 0 {
targetHost = target[idx+3:]
}
sig := sidecar.Sign(key, sidecar.CanonicalRequest{
Version: req.Header.Get(sidecar.HeaderProxyVersion),
Method: req.Method,
Host: targetHost,
PathAndQuery: req.URL.RequestURI(),
BodySHA256: sidecar.BodySHA256(body),
Timestamp: req.Header.Get(sidecar.HeaderProxyTimestamp),
Identity: req.Header.Get(sidecar.HeaderProxyIdentity),
AuthHeader: req.Header.Get(sidecar.HeaderProxyAuthHeader),
})
req.Header.Set(sidecar.HeaderProxySignature, sig)
}
// TestProxyHandler_UnsupportedVersion verifies the handler rejects requests
// whose HeaderProxyVersion is absent or set to an unknown value. Kept in
// front so an old client paired with a newer server (or vice versa) surfaces
// a clear 400 instead of a misleading HMAC mismatch downstream.
func TestProxyHandler_UnsupportedVersion(t *testing.T) {
h := newTestHandler([]byte("key"))
for _, v := range []string{"", "v0", "v2"} {
req := httptest.NewRequest("GET", "/path", nil)
if v != "" {
req.Header.Set(sidecar.HeaderProxyVersion, v)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("version=%q: expected 400, got %d", v, w.Code)
}
}
}
func TestProxyHandler_MissingTimestamp(t *testing.T) {
h := newTestHandler([]byte("key"))
req := httptest.NewRequest("GET", "/path", nil)
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestProxyHandler_MissingBodySHA(t *testing.T) {
h := newTestHandler([]byte("key"))
req := httptest.NewRequest("GET", "/path", nil)
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTimestamp, sidecar.Timestamp())
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestProxyHandler_BadHMAC(t *testing.T) {
h := newTestHandler([]byte("real-key"))
bodySHA := sidecar.BodySHA256(nil)
ts := sidecar.Timestamp()
req := httptest.NewRequest("GET", "/path", nil)
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTarget, "https://open.feishu.cn")
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityBot)
req.Header.Set(sidecar.HeaderProxyAuthHeader, "Authorization")
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
req.Header.Set(sidecar.HeaderProxySignature, "bad-signature")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestProxyHandler_BodySHA256Mismatch(t *testing.T) {
h := newTestHandler([]byte("key"))
req := httptest.NewRequest("POST", "/path", bytes.NewReader([]byte("real body")))
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTarget, "https://open.feishu.cn")
req.Header.Set(sidecar.HeaderProxyTimestamp, sidecar.Timestamp())
req.Header.Set(sidecar.HeaderBodySHA256, sidecar.BodySHA256([]byte("different body")))
req.Header.Set(sidecar.HeaderProxySignature, "whatever")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestProxyHandler_TargetNotAllowed(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
req := signedReq(t, key, "GET", "https://evil.com", "/steal", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for disallowed host, got %d", w.Code)
}
}
func TestProxyHandler_IdentityNotAllowed(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
// Restrict to bot only
h.allowedIDs = map[string]bool{sidecar.IdentityBot: true}
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityUser)
resign(t, key, req, nil) // identity is signed; must re-sign after mutation
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for disallowed identity, got %d", w.Code)
}
}
// TestParseTarget covers the per-shape rejections directly, without the
// surrounding HTTP plumbing.
func TestParseTarget(t *testing.T) {
cases := []struct {
name string
target string
wantErr bool
wantSub string // expected fragment of the error message
}{
{name: "valid https", target: "https://open.feishu.cn", wantErr: false},
{name: "valid https trailing slash", target: "https://open.feishu.cn/", wantErr: false},
{name: "http downgrade", target: "http://open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
{name: "missing scheme", target: "open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
{name: "ftp scheme", target: "ftp://open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
{name: "empty", target: "", wantErr: true, wantSub: "scheme must be https"},
{name: "empty host", target: "https://", wantErr: true, wantSub: "missing host"},
{name: "with path", target: "https://open.feishu.cn/open-apis", wantErr: true, wantSub: "path not allowed"},
{name: "with query", target: "https://open.feishu.cn?a=1", wantErr: true, wantSub: "query not allowed"},
{name: "with fragment", target: "https://open.feishu.cn#frag", wantErr: true, wantSub: "fragment not allowed"},
{name: "with userinfo", target: "https://attacker:pw@open.feishu.cn", wantErr: true, wantSub: "userinfo not allowed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
host, err := parseTarget(tc.target)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got host=%q", host)
}
if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) {
t.Errorf("error %q should contain %q", err.Error(), tc.wantSub)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host != "open.feishu.cn" {
t.Errorf("host = %q, want %q", host, "open.feishu.cn")
}
})
}
}
// TestProxyHandler_RejectsNonHTTPSTarget verifies end-to-end that a
// compromised sandbox holding a valid PROXY_KEY cannot coerce the sidecar
// into forwarding real tokens over cleartext HTTP or to an unexpected path.
// The check must fire before HMAC verification so that the request is
// rejected even when the signature is technically valid.
func TestProxyHandler_RejectsNonHTTPSTarget(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
cases := []struct {
name string
target string
}{
{"http downgrade", "http://open.feishu.cn"},
{"bare hostname", "open.feishu.cn"},
{"ftp scheme", "ftp://open.feishu.cn"},
{"target with path", "https://open.feishu.cn/open-apis/evil"},
{"target with query", "https://open.feishu.cn?steal=1"},
{"target with userinfo", "https://attacker:pw@open.feishu.cn"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Sign with a valid key against the malicious target — proves the
// scheme/shape check is not bypassed by signature legitimacy.
req := signedReq(t, key, "GET", tc.target, "/open-apis/im/v1/chats", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 for target %q, got %d (body: %s)", tc.target, w.Code, w.Body.String())
}
})
}
}
// TestProxyHandler_RejectsIdentityReplay locks in C1 end-to-end: a captured
// bot-signed request whose identity header is flipped to user (or vice versa)
// must be rejected at HMAC verification, not silently served with the wrong
// token type. Without identity in the canonical string this returns 200.
func TestProxyHandler_RejectsIdentityReplay(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
// Attacker flips identity without touching signature.
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityUser)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("identity replay must fail signature verify (got %d, want 401): %s",
w.Code, w.Body.String())
}
}
// TestProxyHandler_RejectsAuthHeaderReplay is the companion: flipping
// X-Lark-Proxy-Auth-Header post-signature must invalidate the signature so
// an attacker cannot redirect the injected token into an unintended header.
func TestProxyHandler_RejectsAuthHeaderReplay(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyAuthHeader, "Cookie")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("auth-header replay must fail signature verify (got %d, want 401): %s",
w.Code, w.Body.String())
}
}
// TestProxyHandler_RejectsAuthHeaderNotInAllowlist pins the auth-header
// allowlist: even a correctly-signed request must be rejected if it asks
// the sidecar to inject the real token into an unintended header (e.g.
// Cookie / User-Agent / X-Forwarded-For). This closes the sidechannel
// where the real token ends up in headers that Lark ignores for auth but
// intermediate logs may capture.
func TestProxyHandler_RejectsAuthHeaderNotInAllowlist(t *testing.T) {
key := []byte("test-key")
h := newTestHandler(key)
for _, bad := range []string{"Cookie", "User-Agent", "X-Forwarded-For", "X-Real-IP", "Set-Cookie"} {
t.Run(bad, func(t *testing.T) {
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyAuthHeader, bad)
resign(t, key, req, nil) // auth-header is signed; must re-sign after override
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("authHeader=%q: expected 403, got %d (body: %s)",
bad, w.Code, w.Body.String())
}
})
}
}
// TestProxyHandler_AcceptsAllowedAuthHeaders confirms the three protocol
// header names remain accepted after the allowlist is enforced. A local
// TLS test server stands in for the upstream so the test is fully offline.
func TestProxyHandler_AcceptsAllowedAuthHeaders(t *testing.T) {
key := []byte("test-key")
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()
upstreamHost := strings.TrimPrefix(upstream.URL, "https://")
for _, good := range []string{"Authorization", sidecar.HeaderMCPUAT, sidecar.HeaderMCPTAT} {
t.Run(good, func(t *testing.T) {
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{token: "real-token"}},
nil, nil, nil,
)
h := &proxyHandler{
key: key,
cred: cred,
appID: "cli_test",
logger: discardLogger(),
forwardCl: upstream.Client(),
allowedHosts: map[string]bool{upstreamHost: true},
allowedIDs: map[string]bool{sidecar.IdentityUser: true, sidecar.IdentityBot: true},
}
req := signedReq(t, key, "GET", "https://"+upstreamHost, "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyAuthHeader, good)
resign(t, key, req, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("authHeader=%q: expected 200, got %d body=%s", good, w.Code, w.Body.String())
}
})
}
}
func TestRun_RejectsSelfProxy(t *testing.T) {
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
keyPath := filepath.Join(t.TempDir(), "proxy.key")
err := run(context.Background(), "127.0.0.1:0", keyPath, "", "", "")
if err == nil {
t.Fatal("expected error when AUTH_PROXY is set")
}
if !strings.Contains(err.Error(), envvars.CliAuthProxy) {
t.Errorf("error should mention %s, got: %v", envvars.CliAuthProxy, err)
}
}
func TestForwardClient_RedirectStripsAuth(t *testing.T) {
redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if auth := r.Header.Get("Authorization"); auth != "" {
t.Errorf("Authorization leaked to redirect target: %s", auth)
}
w.WriteHeader(http.StatusOK)
}))
defer redirectTarget.Close()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, redirectTarget.URL+"/redirected", http.StatusFound)
}))
defer origin.Close()
client := newForwardClient()
req, _ := http.NewRequest("GET", origin.URL+"/start", nil)
req.Header.Set("Authorization", "Bearer real-token")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
}
func TestForwardClient_RedirectStripsMCPHeaders(t *testing.T) {
redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if v := r.Header.Get(sidecar.HeaderMCPUAT); v != "" {
t.Errorf("X-Lark-MCP-UAT leaked to redirect target: %s", v)
}
if v := r.Header.Get(sidecar.HeaderMCPTAT); v != "" {
t.Errorf("X-Lark-MCP-TAT leaked to redirect target: %s", v)
}
w.WriteHeader(http.StatusOK)
}))
defer redirectTarget.Close()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, redirectTarget.URL+"/redirected", http.StatusFound)
}))
defer origin.Close()
client := newForwardClient()
req, _ := http.NewRequest("POST", origin.URL+"/mcp", nil)
req.Header.Set(sidecar.HeaderMCPUAT, "real-uat-token")
req.Header.Set(sidecar.HeaderMCPTAT, "real-tat-token")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
}
// TestProxyHandler_StripsClientSuppliedAuthHeaders verifies that the sidecar
// is the sole source of auth headers on the forwarded request. A malicious
// sandbox client must not be able to smuggle an Authorization/MCP header that
// rides along with the sidecar-injected real token.
func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
const realToken = "real-tenant-access-token"
// Capture what the upstream receives after sidecar forwarding.
// TLS is required because parseTarget rejects non-https targets.
var captured http.Header
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()
// Strip "https://" prefix to get host:port (matches what the handler sees).
upstreamHost := strings.TrimPrefix(upstream.URL, "https://")
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{token: realToken}},
nil, nil, nil,
)
key := []byte("test-key")
h := &proxyHandler{
key: key,
cred: cred,
appID: "cli_test",
logger: discardLogger(),
forwardCl: upstream.Client(), // trusts the httptest CA
allowedHosts: map[string]bool{upstreamHost: true},
allowedIDs: map[string]bool{sidecar.IdentityUser: true, sidecar.IdentityBot: true},
}
cases := []struct {
name string
proxyAuthHeader string // which header sidecar should inject into
wantInjectedHeader string // the header the real token ends up in
wantInjectedValue string
wantStrippedHeaders []string
}{
{
name: "inject Authorization, strip MCP attacker headers",
proxyAuthHeader: "Authorization",
wantInjectedHeader: "Authorization",
wantInjectedValue: "Bearer " + realToken,
wantStrippedHeaders: []string{sidecar.HeaderMCPUAT, sidecar.HeaderMCPTAT},
},
{
name: "inject MCP UAT, strip Authorization attacker header",
proxyAuthHeader: sidecar.HeaderMCPUAT,
wantInjectedHeader: sidecar.HeaderMCPUAT,
wantInjectedValue: realToken,
wantStrippedHeaders: []string{"Authorization", sidecar.HeaderMCPTAT},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
captured = nil
req := signedReq(t, key, "GET", "https://"+upstreamHost, "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyAuthHeader, tc.proxyAuthHeader)
resign(t, key, req, nil) // auth-header is signed; re-sign after override
// Attacker smuggles all three possible auth headers with bogus values.
req.Header.Set("Authorization", "Bearer attacker-token")
req.Header.Set(sidecar.HeaderMCPUAT, "attacker-uat")
req.Header.Set(sidecar.HeaderMCPTAT, "attacker-tat")
// Non-auth headers should still pass through.
req.Header.Set("X-Custom-Header", "keep-me")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from upstream, got %d; body=%s", w.Code, w.Body.String())
}
if captured == nil {
t.Fatal("upstream handler was not invoked")
}
// Injected header contains the real token (not the attacker value).
if got := captured.Get(tc.wantInjectedHeader); got != tc.wantInjectedValue {
t.Errorf("%s = %q, want %q", tc.wantInjectedHeader, got, tc.wantInjectedValue)
}
// All other auth headers must be stripped.
for _, h := range tc.wantStrippedHeaders {
if got := captured.Get(h); got != "" {
t.Errorf("%s should be stripped, got %q", h, got)
}
}
// Non-auth headers still forwarded.
if got := captured.Get("X-Custom-Header"); got != "keep-me" {
t.Errorf("X-Custom-Header = %q, want %q", got, "keep-me")
}
})
}
}
func TestBuildAllowedHosts(t *testing.T) {
feishu := core.Endpoints{
Open: "https://open.feishu.cn", Accounts: "https://accounts.feishu.cn", MCP: "https://mcp.feishu.cn",
}
lark := core.Endpoints{
Open: "https://open.larksuite.com", Accounts: "https://accounts.larksuite.com", MCP: "https://mcp.larksuite.com",
}
hosts := buildAllowedHosts(feishu, lark)
// feishu hosts
if !hosts["open.feishu.cn"] {
t.Error("expected open.feishu.cn in allowlist")
}
if !hosts["mcp.feishu.cn"] {
t.Error("expected mcp.feishu.cn in allowlist")
}
// lark hosts
if !hosts["open.larksuite.com"] {
t.Error("expected open.larksuite.com in allowlist")
}
if !hosts["mcp.larksuite.com"] {
t.Error("expected mcp.larksuite.com in allowlist")
}
// evil host
if hosts["evil.com"] {
t.Error("evil.com should not be in allowlist")
}
}
func TestSanitizePath(t *testing.T) {
tests := []struct {
input string
want string
}{
{"/open-apis/im/v1/messages?receive_id_type=chat_id", "/open-apis/im/v1/messages"},
{"/open-apis/calendar/v4/events", "/open-apis/calendar/v4/events"},
{"/open-apis/docx/v1/documents/doxcnABCD1234/blocks", "/open-apis/docx/v1/documents/:id/blocks"},
{"/open-apis/im/v1/chats/oc_abcdef12345678/members", "/open-apis/im/v1/chats/:id/members"},
{"/path?secret=abc", "/path"},
}
for _, tt := range tests {
if got := sanitizePath(tt.input); got != tt.want {
t.Errorf("sanitizePath(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestLooksLikeID(t *testing.T) {
tests := []struct {
seg string
want bool
}{
{"doxcnABCD1234", true}, // doc token
{"oc_abcdef12345678", true}, // chat ID
{"v1", false}, // API version
{"messages", false}, // route keyword
{"open-apis", false}, // route prefix
{"ab1", false}, // too short
}
for _, tt := range tests {
if got := looksLikeID(tt.seg); got != tt.want {
t.Errorf("looksLikeID(%q) = %v, want %v", tt.seg, got, tt.want)
}
}
}
func TestSanitizeError(t *testing.T) {
short := fmt.Errorf("short error")
if got := sanitizeError(short); got != "short error" {
t.Errorf("got %q", got)
}
longMsg := make([]byte, 300)
for i := range longMsg {
longMsg[i] = 'x'
}
long := fmt.Errorf("%s", string(longMsg))
got := sanitizeError(long)
if len(got) > 210 {
t.Errorf("expected truncation, got %d chars", len(got))
}
if !bytes.HasSuffix([]byte(got), []byte("...")) {
t.Errorf("expected '...' suffix, got %q", got[len(got)-10:])
}
}
// ---------- Multi-tenant tests ----------
func writeKeyFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func TestLoadClientKeys_SkipsSharedKeyCollision(t *testing.T) {
dir := t.TempDir()
sharedKey := strings.Repeat("aa", 32) // 64 hex chars
aliceKey := strings.Repeat("bb", 32)
writeKeyFile(t, dir, "proxy.key", sharedKey)
writeKeyFile(t, dir, "alice.key", aliceKey)
writeKeyFile(t, dir, "evil.key", sharedKey) // same as shared key
var logBuf bytes.Buffer
h := &proxyHandler{
key: []byte(sharedKey),
keysDir: dir,
clientKeys: make(map[string]clientKeyEntry),
logger: log.New(&logBuf, "", 0),
}
h.loadClientKeys()
h.ckMu.RLock()
defer h.ckMu.RUnlock()
if len(h.clientKeys) != 1 {
t.Fatalf("expected 1 client key (alice), got %d", len(h.clientKeys))
}
for _, entry := range h.clientKeys {
if entry.clientName != "alice" {
t.Errorf("expected client alice, got %s", entry.clientName)
}
}
if !strings.Contains(logBuf.String(), "KEYS_SCAN_SKIP") || !strings.Contains(logBuf.String(), "collides with shared proxy key") {
t.Errorf("expected KEYS_SCAN_SKIP log for shared key collision, got: %s", logBuf.String())
}
}
func TestLoadClientKeys_SkipsDuplicateKeyHex(t *testing.T) {
dir := t.TempDir()
sharedKey := strings.Repeat("aa", 32)
dupeKey := strings.Repeat("cc", 32)
writeKeyFile(t, dir, "proxy.key", sharedKey)
writeKeyFile(t, dir, "alice.key", dupeKey)
writeKeyFile(t, dir, "bob.key", dupeKey) // duplicate of alice
var logBuf bytes.Buffer
h := &proxyHandler{
key: []byte(sharedKey),
keysDir: dir,
clientKeys: make(map[string]clientKeyEntry),
logger: log.New(&logBuf, "", 0),
}
h.loadClientKeys()
h.ckMu.RLock()
defer h.ckMu.RUnlock()
if len(h.clientKeys) != 1 {
t.Fatalf("expected 1 client key (first loaded), got %d", len(h.clientKeys))
}
if !strings.Contains(logBuf.String(), "KEYS_SCAN_SKIP") || !strings.Contains(logBuf.String(), "duplicate key") {
t.Errorf("expected KEYS_SCAN_SKIP log for duplicate key, got: %s", logBuf.String())
}
}
func TestLoadClientKeys_SkipsProxyAndNonKeyFiles(t *testing.T) {
dir := t.TempDir()
sharedKey := strings.Repeat("aa", 32)
writeKeyFile(t, dir, "proxy.key", sharedKey)
writeKeyFile(t, dir, "alice.key", strings.Repeat("bb", 32))
writeKeyFile(t, dir, "notes.txt", "not a key")
if err := os.MkdirAll(filepath.Join(dir, "subdir.key"), 0755); err != nil {
t.Fatal(err)
}
var logBuf bytes.Buffer
h := &proxyHandler{
key: []byte(sharedKey),
keysDir: dir,
clientKeys: make(map[string]clientKeyEntry),
logger: log.New(&logBuf, "", 0),
}
h.loadClientKeys()
h.ckMu.RLock()
defer h.ckMu.RUnlock()
if len(h.clientKeys) != 1 {
t.Fatalf("expected 1 client key (alice), got %d", len(h.clientKeys))
}
}
func TestVerifyWithClientKeys_MatchesCorrectClient(t *testing.T) {
dir := t.TempDir()
sharedKey := strings.Repeat("aa", 32)
aliceKey := strings.Repeat("bb", 32)
bobKey := strings.Repeat("cc", 32)
writeKeyFile(t, dir, "proxy.key", sharedKey)
writeKeyFile(t, dir, "alice.key", aliceKey)
writeKeyFile(t, dir, "bob.key", bobKey)
h := &proxyHandler{
key: []byte(sharedKey),
keysDir: dir,
clientKeys: make(map[string]clientKeyEntry),
logger: discardLogger(),
}
h.loadClientKeys()
cr := sidecar.CanonicalRequest{
Version: sidecar.ProtocolV1,
Method: "GET",
Host: "open.feishu.cn",
PathAndQuery: "/test",
BodySHA256: sidecar.BodySHA256(nil),
Timestamp: sidecar.Timestamp(),
Identity: sidecar.IdentityBot,
AuthHeader: "Authorization",
}
// Sign with alice's key
aliceSig := sidecar.Sign([]byte(aliceKey), cr)
client, err := h.verifyWithClientKeys(cr, aliceSig)
if err != nil {
t.Fatalf("expected alice key to verify, got error: %v", err)
}
if client != "alice" {
t.Errorf("expected client=alice, got %q", client)
}
// Sign with bob's key
bobSig := sidecar.Sign([]byte(bobKey), cr)
client, err = h.verifyWithClientKeys(cr, bobSig)
if err != nil {
t.Fatalf("expected bob key to verify, got error: %v", err)
}
if client != "bob" {
t.Errorf("expected client=bob, got %q", client)
}
// Sign with unknown key
unknownKey := strings.Repeat("dd", 32)
unknownSig := sidecar.Sign([]byte(unknownKey), cr)
client, err = h.verifyWithClientKeys(cr, unknownSig)
if err == nil {
t.Errorf("expected error for unknown key, got client=%q", client)
}
if client != "" {
t.Errorf("expected empty client for unknown key, got %q", client)
}
}
func TestUserMap_RoundTripPersistence(t *testing.T) {
dir := t.TempDir()
mapFile := filepath.Join(dir, "client_user_map.json")
ab := &authBridge{
userMap: make(map[string]string),
mapFile: mapFile,
logger: discardLogger(),
}
// Initially empty
ab.loadUserMap()
if len(ab.userMap) != 0 {
t.Fatalf("expected empty map, got %v", ab.userMap)
}
// Populate and save
ab.userMap["alice"] = "ou_alice_open_id_123"
ab.userMap["bob"] = "ou_bob_open_id_456"
ab.saveUserMap()
// Verify file contents
data, err := os.ReadFile(mapFile)
if err != nil {
t.Fatalf("failed to read map file: %v", err)
}
var saved map[string]string
if err := json.Unmarshal(data, &saved); err != nil {
t.Fatalf("failed to parse saved map: %v", err)
}
if saved["alice"] != "ou_alice_open_id_123" || saved["bob"] != "ou_bob_open_id_456" {
t.Errorf("saved map mismatch: %v", saved)
}
// Create new instance and load — simulates restart
ab2 := &authBridge{
userMap: make(map[string]string),
mapFile: mapFile,
logger: discardLogger(),
}
ab2.loadUserMap()
if ab2.userMap["alice"] != "ou_alice_open_id_123" {
t.Errorf("after reload, alice=%q, want ou_alice_open_id_123", ab2.userMap["alice"])
}
if ab2.userMap["bob"] != "ou_bob_open_id_456" {
t.Errorf("after reload, bob=%q, want ou_bob_open_id_456", ab2.userMap["bob"])
}
}

View File

@@ -0,0 +1,195 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_demo
// Command sidecar-server-demo is a reference implementation of a sidecar
// auth proxy server. It is NOT production-ready — integrators should
// implement their own server conforming to the wire protocol defined in
// github.com/larksuite/cli/sidecar.
//
// The demo reuses the lark-cli credential pipeline (keychain + config) to
// resolve real tokens, so it only works on a machine that has been
// configured with `lark-cli auth login`.
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/sidecar"
)
func main() {
listen := flag.String("listen", sidecar.DefaultListenAddr, "listen address (host:port)")
keyFile := flag.String("key-file", defaultKeyFile(), "path to write the HMAC key")
keysDir := flag.String("keys-dir", "", "directory containing per-client *.key files for identity isolation (defaults to key-file's parent dir)")
logFile := flag.String("log-file", "", "audit log file (stderr if empty)")
profile := flag.String("profile", "", "lark-cli profile name (empty = active profile)")
flag.Parse()
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if err := run(ctx, *listen, *keyFile, *keysDir, *logFile, *profile); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func defaultKeyFile() string {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".lark-sidecar", "proxy.key")
}
return "/tmp/lark-sidecar/proxy.key"
}
func run(ctx context.Context, listen, keyFile, keysDir, logFile, profile string) error {
if v := os.Getenv(envvars.CliAuthProxy); v != "" {
return fmt.Errorf("%s is set in this environment (%s); unset it before starting the sidecar server", envvars.CliAuthProxy, v)
}
if listen == "" {
return fmt.Errorf("invalid --listen address: empty")
}
if _, err := validate.SafeInputPath(keyFile); err != nil {
return fmt.Errorf("invalid --key-file path: %w", err)
}
if logFile != "" {
if _, err := validate.SafeInputPath(logFile); err != nil {
return fmt.Errorf("invalid --log-file path: %w", err)
}
}
if keysDir != "" {
if _, err := validate.SafeInputPath(keysDir); err != nil {
return fmt.Errorf("invalid --keys-dir path: %w", err)
}
}
// Reuse existing key if present; generate a new one only on first run.
keyDir := filepath.Dir(keyFile)
if err := vfs.MkdirAll(keyDir, 0700); err != nil {
return fmt.Errorf("failed to create key directory: %v", err)
}
var keyHex string
if existing, err := vfs.ReadFile(keyFile); err == nil && len(strings.TrimSpace(string(existing))) == 64 {
keyHex = strings.TrimSpace(string(existing))
} else {
keyBytes := make([]byte, 32)
if _, err := rand.Read(keyBytes); err != nil {
return fmt.Errorf("failed to generate HMAC key: %v", err)
}
keyHex = hex.EncodeToString(keyBytes)
if err := vfs.WriteFile(keyFile, []byte(keyHex), 0600); err != nil {
return fmt.Errorf("failed to write key file: %v", err)
}
}
// Default keysDir to the parent directory of keyFile
if keysDir == "" {
keysDir = keyDir
}
// Audit logger
var auditLogger *log.Logger
if logFile != "" {
f, err := vfs.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("failed to open log file: %v", err)
}
defer f.Close()
auditLogger = log.New(f, "", log.LstdFlags)
} else {
auditLogger = log.New(os.Stderr, "[audit] ", log.LstdFlags)
}
factory := cmdutil.NewDefault(nil, cmdutil.InvocationContext{Profile: profile})
cfg, err := factory.Config()
if err != nil {
return fmt.Errorf("failed to load config: %v", err)
}
listener, err := net.Listen("tcp", listen)
if err != nil {
return fmt.Errorf("failed to listen on %s: %v", listen, err)
}
defer listener.Close()
allowedHosts := buildAllowedHosts(
core.ResolveEndpoints(core.BrandFeishu),
core.ResolveEndpoints(core.BrandLark),
)
allowedIDs := buildAllowedIdentities(cfg)
ab := newAuthBridge([]byte(keyHex), cfg.AppID, cfg.AppSecret, cfg.Brand, factory.Credential, auditLogger)
handler := &proxyHandler{
key: []byte(keyHex),
cred: factory.Credential,
appID: cfg.AppID,
brand: cfg.Brand,
logger: auditLogger,
forwardCl: newForwardClient(),
allowedHosts: allowedHosts,
allowedIDs: allowedIDs,
authBridge: ab,
keysDir: keysDir,
}
handler.loadClientKeys()
server := &http.Server{
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
}
go func() {
<-ctx.Done()
auditLogger.Println("shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
auditLogger.Printf("shutdown error: %v", err)
}
}()
keyPrefix := keyHex
if len(keyPrefix) > 8 {
keyPrefix = keyPrefix[:8]
}
proxyURL := "http://" + listen
fmt.Fprintf(os.Stderr, "Auth sidecar listening on %s\n", proxyURL)
fmt.Fprintf(os.Stderr, "HMAC key prefix: %s\n", keyPrefix)
fmt.Fprintf(os.Stderr, "Full key written to %s (mode 0600)\n", keyFile)
fmt.Fprintf(os.Stderr, "Client keys dir: %s\n", keysDir)
fmt.Fprintf(os.Stderr, "\nSet in sandbox:\n")
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliAuthProxy, proxyURL)
fmt.Fprintf(os.Stderr, " export %s=\"<read from %s>\"\n", envvars.CliProxyKey, keyFile)
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliAppID, cfg.AppID)
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliBrand, string(cfg.Brand))
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("sidecar server exited unexpectedly: %v", err)
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More