Compare commits

..

24 Commits

Author SHA1 Message Date
fangshuyu-768
aea9f37f58 feat(wiki): add exponential backoff retry for +node-create lock contention (#1012) (#1076)
When creating wiki nodes under the same parent concurrently, the API
returns error code 131009 (lock contention) ~5-15% of the time. This
adds automatic retry with exponential backoff (250ms, 500ms; max 2
retries) so callers no longer need to implement retry logic themselves.

- Retry loop in runWikiNodeCreate: only retries on code 131009, respects
  context cancellation, prints progress to stderr
- wrapWikiNodeCreateRetryError preserves Err/Raw/Detail.Code in ExitError
- 6 unit tests covering retry success, exhaustion, non-contention error,
  single-retry success, context cancellation, no-retry on success
- 8 dry-run E2E tests for wiki +node-create request shape and validation
2026-05-25 20:03:17 +08:00
liujinkun2025
ac06eaa0f4 fix(wiki): rename +node-get --token to --node-token, keep alias (#1074)
Per issue #1049 (third point), wiki +node-get used --token while sibling
commands (+node-delete / +node-copy / +move) use --node-token. The
inconsistency forced humans and AI agents to remember which adjacent
command takes which flag.

Make --node-token the canonical flag and keep --token as a hidden,
deprecated alias so existing scripts continue to work. pflag's
MarkDeprecated prints "Flag --token has been deprecated, use --node-token
instead" to stderr on use, guiding callers to migrate. Conflict between
the two with different values is rejected upfront.

Skills docs (lark-wiki, lark-base) updated to prefer --node-token.

Change-Id: I3415a98f079613c0b1a0b989cf54a09cbb8986fb
2026-05-25 17:28:45 +08:00
fangshuyu-768
282c27784d docs(skills): add 云盘/云存储 alias alongside 云空间 for agent clarity (#1073) 2026-05-25 17:15:50 +08:00
郭立lee
f2a4c95665 fix(output): classify wiki lock-contention error (131009) with retry hint (#1014)
Wiki write-path operations (most commonly `wiki +node-create` against the
same parent) surface code 131009 "lock contention" under concurrent calls.
Currently this falls through to the generic "api_error" classification,
giving users no hint that it is transient and safe to retry.

Mirror the existing `LarkErrDriveResourceContention` (1061045) treatment:
add a named constant, classify as "conflict", and emit a hint that points
the caller toward exponential backoff or serializing sibling-node writes.

Refs: #1012
2026-05-25 15:50:45 +08:00
JackZhao10086
cb5055eb46 feat(auth): add auth qrcode subcommand and update auth docs/hints (#968)
* feat(auth): add auth qrcode subcommand and update auth docs/hints

* refactor(auth/qrcode): improve qrcode command with validation and custom output
2026-05-25 15:34:00 +08:00
WJzz1
9d4233bfe3 fix(contact): add actionable hint when fanout search all-fail with no API code (#1054)
In buildFanoutResponse, when every fanout query fails AND the first failure
has no Lark API code (i.e. transport, parse, panic, or context-cancel),
the returned ExitError was carrying an empty Hint. This is the only
output.ErrWithHint call in shortcuts/ that ships an empty hint.

AGENTS.md states: "every error message you write will be parsed by an AI
to decide its next action. Make errors structured, actionable, and
specific." An empty hint gives the agent nothing to do.

Populate the hint with the actionable next step for this branch — retry,
and if it persists, narrow --queries to a single term to isolate the
failing input. The companion test exercises the no-code path and asserts
the hint is non-empty and mentions "retry".

Co-authored-by: Wang-Yeah623 <Wang-Yeah623@users.noreply.github.com>
2026-05-25 12:08:18 +08:00
zed
708cbc2b31 fix: use ErrValidation instead of fmt.Errorf in Validate paths (#1001)
Replace 8 bare fmt.Errorf calls with output.ErrValidation across 3 files
so validation errors consistently return structured JSON (type: validation,
exit 2) matching the rest of the codebase.

Affected functions: validateExpectedFlag (sheets), validateSendTime,
validateComposeInlineAndAttachments, validateEventFlags (mail),
validateSignatureWithPlainText (mail)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:52:51 +08:00
ZEden0
6d1f9980fa fix: annotate auto-grant permission failures with required_scope and console_url (#1045)
When AutoGrantCurrentUserDrivePermission encounters lark code 99991672/99991679,
extract permission_violations from the underlying ExitError and surface
lark_code, required_scope, and console_url on the result map. Override the
generic fallback hint with one pointing at the developer console — the
concrete next step a user can take.

Refactor extractRequiredScopes / SelectRecommendedScope wrapping / console URL
construction out of cmd/root.go into internal/registry/scope_hint.go so both
the top-level enrichPermissionError path and the best-effort sub-call path in
shortcuts/common share one implementation.

Change-Id: Ida63ed160d1167b7961b6faac5c2cf9b7f971c65
2026-05-25 11:01:01 +08:00
zero-my
6e3e120ec8 Docs/lark task shortcut doc refresh (#1057)
* docs: align lark-task attachment descriptions

* docs: restore lark-task attachment capability summary
2026-05-24 00:32:28 +08:00
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
151 changed files with 12146 additions and 718 deletions

View File

@@ -2,6 +2,40 @@
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
@@ -806,6 +840,10 @@ 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

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

@@ -43,6 +43,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdAuthScopes(f, nil))
cmd.AddCommand(NewCmdAuthList(f, nil))
cmd.AddCommand(NewCmdAuthCheck(f, nil))
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
return cmd
}

View File

@@ -61,7 +61,7 @@ func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
for _, want := range []string{
"only delivers final turn messages",
"--no-wait --json",
"send the verification URL to the user as your final message",
"send the verification URL (or QR code) to the user as your final message",
"run --device-code in a later step",
} {
if !strings.Contains(got, want) {

View File

@@ -47,9 +47,10 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
Long: `Device Flow authorization login.
For AI agents: this command blocks until the user completes authorization in the
browser. If your harness only delivers final turn messages, use --no-wait --json,
send the verification URL to the user as your final message, end the turn, then
run --device-code in a later step after the user confirms authorization.`,
browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json,
send the verification URL (or QR code) to the user as your final message, end the turn, then
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
to generate QR codes (supports ASCII and PNG formats).`,
RunE: func(cmd *cobra.Command, args []string) error {
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
return output.ErrWithHint(output.ExitValidation, "command_denied",
@@ -68,7 +69,13 @@ run --device-code in a later step after the user confirms authorization.`,
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
available := sortedKnownDomains()
var helpBrand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
helpBrand = cfg.Brand
}
}
available := sortedKnownDomains(helpBrand)
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
@@ -139,14 +146,14 @@ func authLoginRun(opts *LoginOptions) error {
// Expand --domain all to all available domains (from_meta projects + shortcut services)
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
selectedDomains = sortedKnownDomains()
selectedDomains = sortedKnownDomains(config.Brand)
break
}
}
// Validate domain names and suggest corrections for unknown ones
if len(selectedDomains) > 0 {
knownDomains := allKnownDomains()
knownDomains := allKnownDomains(config.Brand)
for _, d := range selectedDomains {
if !knownDomains[d] {
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
@@ -170,7 +177,7 @@ func authLoginRun(opts *LoginOptions) error {
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand)
if err != nil {
return err
}
@@ -208,10 +215,10 @@ func authLoginRun(opts *LoginOptions) error {
if len(selectedDomains) > 0 || opts.Recommend {
var candidateScopes []string
if len(selectedDomains) > 0 {
candidateScopes = collectScopesForDomains(selectedDomains, "user")
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
} else {
// --recommend without --domain: all domains
candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user")
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
}
// Filter to auto-approve scopes if --recommend or interactive "common"
@@ -269,7 +276,7 @@ func authLoginRun(opts *LoginOptions) error {
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the URL the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
"hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL so the user can either scan or copy the link.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. Prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
@@ -452,6 +459,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
return nil
}
// syncLoginUserToProfile persists the logged-in user info into the named profile.
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
@@ -477,6 +485,7 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
return nil
}
// findProfileByName returns the AppConfig matching profileName, or nil.
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
for i := range multi.Apps {
if multi.Apps[i].ProfileName() == profileName {
@@ -490,7 +499,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
// shortcut scopes for the given domain names.
// Domains with auth_domain children are automatically expanded to include
// their children's scopes.
func collectScopesForDomains(domains []string, identity string) []string {
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
scopeSet := make(map[string]bool)
// 1. API scopes from from_meta projects
@@ -509,6 +518,9 @@ func collectScopesForDomains(domains []string, identity string) []string {
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
for _, s := range sc.DeclaredScopesForIdentity(identity) {
scopeSet[s] = true
@@ -528,7 +540,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
// allKnownDomains returns all valid auth domain names (from_meta projects +
// shortcut services), excluding domains that have auth_domain set (they are
// folded into their parent domain).
func allKnownDomains() map[string]bool {
func allKnownDomains(brand core.LarkBrand) map[string]bool {
domains := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
if !registry.HasAuthDomain(p) {
@@ -536,6 +548,9 @@ func allKnownDomains() map[string]bool {
}
}
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
if !registry.HasAuthDomain(sc.Service) {
domains[sc.Service] = true
}
@@ -544,8 +559,8 @@ func allKnownDomains() map[string]bool {
}
// sortedKnownDomains returns all valid domain names sorted alphabetically.
func sortedKnownDomains() []string {
m := allKnownDomains()
func sortedKnownDomains(brand core.LarkBrand) []string {
m := allKnownDomains(brand)
domains := make([]string, 0, len(m))
for d := range m {
domains = append(domains, d)

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

@@ -59,7 +59,7 @@ var loginMsgZh = &loginMsg{
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 只会把最终回复发给用户,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 和 verification_url把 verification_url 作为本轮最终消息原样发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 `lark-cli auth login --device-code <code>` 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL把它视为不可修改的 opaque string不要做 URL 编码解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用包含该 URL 的代码块单独输出。",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code <code>\" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output仅当用户明确要求时才使用 ASCII--ascii。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL再将二维码图片置于 URL 下方完整展示,确保用户可扫码或复制链接。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string不要做任何修改(包括 URL 编码/解码、添加空格或标点),建议用包含该 URL 的代码块单独输出。",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
@@ -95,7 +95,7 @@ var loginMsgEn = &loginMsg{
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness only delivers final turn messages, use `lark-cli auth login --no-wait --json` to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run `lark-cli auth login --device-code <code>` in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code <code>\" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL so the user can either scan or copy the link.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. Prefer a fenced code block containing only the raw URL.",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",
@@ -114,6 +114,7 @@ var loginMsgEn = &loginMsg{
HintFooter: " lark-cli auth login --help",
}
// getLoginMsg returns the login message bundle for the given language.
func getLoginMsg(lang string) *loginMsg {
if lang == "en" {
return loginMsgEn
@@ -125,5 +126,5 @@ func getLoginMsg(lang string) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown"}
return []string{"base", "contact", "docs", "markdown", "apps"}
}

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))
}
@@ -945,12 +945,20 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
}
hint, _ := data["hint"].(string)
for _, want := range []string{
"exactly as returned by the CLI",
"MUST generate QR code AND display it",
"lark-cli auth qrcode",
"Prefer PNG QR code (--output)",
"use ASCII (--ascii) only when the user explicitly requests it",
"This is a required step, do NOT skip it",
"CRITICAL",
"You MUST include the QR image in your response",
"Generating the file alone is NOT enough",
"image tags, inline images, or file attachments",
"Display order",
"place the QR code image below the URL",
"opaque string",
"Do not URL-encode or decode it",
"do not add %20, spaces, or punctuation",
"do not wrap it as Markdown link text",
"fenced code block containing only the raw URL",
"cannot be modified",
"Prefer a fenced code block",
"final message of the turn",
"return control to the user",
"do not block on --device-code in the same turn",
@@ -1054,12 +1062,18 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *
"结束本轮",
"用户回复已完成授权",
"不要在同一轮里展示 URL 后立刻阻塞执行 --device-code",
"逐字原样转发 CLI 返回的 URL",
"必须生成二维码并展示",
"lark-cli auth qrcode",
"优先生成 PNG 二维码(--output",
"仅当用户明确要求时才使用 ASCII--ascii",
"生成后必须在回复中展示图片",
"仅生成文件不算完成",
"image 标签或内联图片",
"二维码图片置于 URL 下方完整展示",
"URL 输出规则",
"opaque string",
"不要做 URL 编码或解码",
"不要补 `%20`、空格或标点",
"不要改写成 Markdown 链接",
"只包含该 URL 的代码块单独输出",
"不要做任何修改",
"仅包含该 URL 的代码块",
} {
if !strings.Contains(hint, want) {
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
@@ -1077,7 +1091,7 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
}
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
domains := allKnownDomains()
domains := allKnownDomains("")
if domains["whiteboard"] {
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
}
@@ -1087,7 +1101,7 @@ func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
}
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
scopes := collectScopesForDomains([]string{"docs"}, "user")
scopes := collectScopesForDomains([]string{"docs"}, "user", "")
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
found := false
for _, s := range scopes {

142
cmd/auth/qrcode.go Normal file
View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"github.com/skip2/go-qrcode"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// QRCodeOptions holds inputs for auth qrcode command.
type QRCodeOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
URL string
Size int
ASCII bool
Output string
}
// NewCmdAuthQRCode creates the auth qrcode subcommand.
func NewCmdAuthQRCode(f *cmdutil.Factory, runF func(*QRCodeOptions) error) *cobra.Command {
opts := &QRCodeOptions{Factory: f, Size: 256}
cmd := &cobra.Command{
Use: "qrcode <url>",
Short: "Generate QR code for verification URL",
Long: `Generate a QR code image or ASCII representation for a verification URL.
This command is designed for AI agents to generate QR codes for OAuth authorization URLs.
For PNG output, the --output flag is required to specify the output file path (must be a relative path within the current directory).
For ASCII output, the result is printed to stdout with fixed size.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.URL = args[0]
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
}
return runQRCode(opts)
},
}
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
return cmd
}
// runQRCode executes the auth qrcode command.
func runQRCode(opts *QRCodeOptions) error {
if opts.URL == "" {
return output.Errorf(output.ExitValidation, "missing_url", "url is required")
}
if opts.ASCII {
var out io.Writer = os.Stdout
if opts.Factory != nil {
out = opts.Factory.IOStreams.Out
}
return generateASCIIQRCode(opts.URL, out)
}
if opts.Output == "" {
return output.Errorf(output.ExitValidation, "missing_output", "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.")
}
if opts.Size < 32 {
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at least 32, got %d", opts.Size))
}
if opts.Size > 1024 {
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at most 1024, got %d", opts.Size))
}
safePath, err := validate.SafeOutputPath(opts.Output)
if err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
return err
}
result := map[string]interface{}{
"ok": true,
"file_path": safePath,
"hint": "You MUST include the QR image in your response. Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.",
}
var out io.Writer = os.Stdout
if opts.Factory != nil {
out = opts.Factory.IOStreams.Out
}
encoder := json.NewEncoder(out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(result); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to write output: %v", err)
}
return nil
}
// generateImageQRCode encodes the URL as a PNG QR code and writes it to outputPath.
func generateImageQRCode(url string, size int, outputPath string) error {
png, err := qrcode.Encode(url, qrcode.Medium, size)
if err != nil {
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to encode QR code: %v", err))
}
err = vfs.WriteFile(outputPath, png, 0644)
if err != nil {
return output.Errorf(output.ExitInternal, "write_error", fmt.Sprintf("failed to write QR code to %s: %v", outputPath, err))
}
return nil
}
// generateASCIIQRCode encodes the URL as an ASCII QR code and prints it to stdout.
func generateASCIIQRCode(url string, w io.Writer) error {
q, err := qrcode.New(url, qrcode.Medium)
if err != nil {
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to create QR code: %v", err))
}
fmt.Fprint(w, q.ToSmallString(false))
return nil
}

368
cmd/auth/qrcode_test.go Normal file
View File

@@ -0,0 +1,368 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png", "--size", "128"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.URL != "https://example.com" {
t.Errorf("URL = %q, want %q", gotOpts.URL, "https://example.com")
}
if gotOpts.Size != 128 {
t.Errorf("Size = %d, want %d", gotOpts.Size, 128)
}
if gotOpts.Output != "qr.png" {
t.Errorf("Output = %q, want %q", gotOpts.Output, "qr.png")
}
if gotOpts.ASCII {
t.Error("ASCII should be false by default")
}
}
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--ascii"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gotOpts.ASCII {
t.Error("ASCII should be true when --ascii is passed")
}
}
func TestNewCmdAuthQRCode_DefaultSize(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--ascii"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Size != 256 {
t.Errorf("default Size = %d, want 256", gotOpts.Size)
}
}
func TestNewCmdAuthQRCode_ExactOneArg(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error when no URL argument provided")
}
}
func TestNewCmdAuthQRCode_RunE_PNGEndToEnd(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(oldWd) })
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile("qr.png")
if err != nil {
t.Fatalf("output file not created: %v", err)
}
if string(data[:4]) != "\x89PNG" {
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
}
var result map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("stdout is not valid JSON: %v, got: %s", err, stdout.String())
}
if result["ok"] != true {
t.Errorf("ok = %v, want true", result["ok"])
}
hint, _ := result["hint"].(string)
if hint == "" {
t.Error("hint is empty")
}
if !strings.Contains(hint, "MUST include") {
t.Errorf("hint missing 'MUST include', got: %s", hint)
}
if !strings.Contains(hint, "NOT enough") {
t.Errorf("hint missing 'NOT enough', got: %s", hint)
}
}
func TestNewCmdAuthQRCode_RunE_MissingOutput(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"https://example.com"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error when --output is missing in PNG mode")
}
}
func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetOut(stdout)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := stdout.String()
for _, want := range []string{
"qrcode <url>",
"QR code",
"--output",
"--ascii",
"relative path",
} {
if !strings.Contains(got, want) {
t.Errorf("help missing %q", want)
}
}
}
func TestRunQRCode_MissingURL(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: ""})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "missing_url" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_url")
}
}
func TestRunQRCode_MissingOutput(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "missing_output" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_output")
}
}
func TestRunQRCode_InvalidSize(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 16,
Output: "qr.png",
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "invalid_size" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
}
}
func TestRunQRCode_SizeTooLarge(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 2048,
Output: "qr.png",
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "invalid_size" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
}
}
func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 256,
Output: "/etc/passwd",
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
}
func TestRunQRCode_PNGWritesFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(oldWd) })
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 256,
Output: "qr.png",
Factory: f,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
info, err := os.Stat("qr.png")
if err != nil {
t.Fatalf("output file not created: %v", err)
}
if info.Size() == 0 {
t.Error("output file is empty")
}
var result map[string]interface{}
if jsonErr := json.Unmarshal(stdout.Bytes(), &result); jsonErr != nil {
t.Fatalf("stdout is not valid JSON: %v, got: %s", jsonErr, stdout.String())
}
if result["ok"] != true {
t.Errorf("ok = %v, want true", result["ok"])
}
}
func TestRunQRCode_ASCIIOutputsToStdout(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
ASCII: true,
Factory: f,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stdout.Len() == 0 {
t.Error("ASCII QR code produced no output")
}
}
func TestGenerateImageQRCode_Success(t *testing.T) {
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "test-qr.png")
if err := generateImageQRCode("https://example.com", 256, outputPath); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("failed to read output file: %v", err)
}
if len(data) == 0 {
t.Error("output file is empty")
}
if len(data) < 8 {
t.Error("output too small to be a valid PNG")
}
if string(data[:4]) != "\x89PNG" {
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
}
}
func TestGenerateImageQRCode_WriteError(t *testing.T) {
err := generateImageQRCode("https://example.com", 256, "/nonexistent/deep/nested/dir/qr.png")
if err == nil {
t.Fatal("expected error writing to nonexistent directory")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitInternal {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
}
if exitErr.Detail.Type != "write_error" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "write_error")
}
}
func TestGenerateASCIIQRCode_Success(t *testing.T) {
var buf strings.Builder
err := generateASCIIQRCode("https://example.com", &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if buf.Len() == 0 {
t.Error("ASCII QR code produced no output")
}
}
func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
var buf strings.Builder
err := generateASCIIQRCode("", &buf)
if err == nil {
t.Fatal("expected error for empty string")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Detail.Type != "encode_error" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "encode_error")
}
}

View File

@@ -10,7 +10,6 @@ import (
"errors"
"fmt"
"io"
"net/url"
"os"
"sort"
"strconv"
@@ -389,8 +388,8 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr.Detail == nil || exitErr.Detail.Type != "permission" {
return
}
// Extract required scopes from API error detail
scopes := extractRequiredScopes(exitErr.Detail.Detail)
// Extract required scopes from API error detail (shared helper)
scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
if len(scopes) == 0 {
return
}
@@ -401,21 +400,10 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
}
// Select the recommended (least-privilege) scope
scopeIfaces := make([]interface{}, len(scopes))
for i, s := range scopes {
scopeIfaces[i] = s
}
recommended := registry.SelectRecommendedScope(scopeIfaces, "tenant")
if recommended == "" {
recommended = scopes[0]
}
recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant")
// Build admin console URL with the recommended scope
host := "open.feishu.cn"
if cfg.Brand == "lark" {
host = "open.larksuite.com"
}
consoleURL := fmt.Sprintf("https://%s/page/scope-apply?clientID=%s&scopes=%s", host, url.QueryEscape(cfg.AppID), url.QueryEscape(recommended))
consoleURL := registry.BuildConsoleScopeURL(cfg.Brand, cfg.AppID, recommended)
// Clear raw API detail — useful info is now in message/hint/console_url
exitErr.Detail.Detail = nil
@@ -452,26 +440,3 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
exitErr.Detail.ConsoleURL = consoleURL
}
}
// extractRequiredScopes extracts scope names from the API error's permission_violations field.
func extractRequiredScopes(detail interface{}) []string {
m, ok := detail.(map[string]interface{})
if !ok {
return nil
}
violations, ok := m["permission_violations"].([]interface{})
if !ok {
return nil
}
var scopes []string
for _, v := range violations {
vm, ok := v.(map[string]interface{})
if !ok {
continue
}
if subject, ok := vm["subject"].(string); ok {
scopes = append(scopes, subject)
}
}
return scopes
}

View File

@@ -39,6 +39,10 @@ const (
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
LarkErrDriveCrossBrand = 1064511 // cross brand not support
// Wiki write-path lock contention (e.g. concurrent wiki +node-create under the
// same parent). Server-side write lock; transient, safe to retry with backoff.
LarkErrWikiLockContention = 131009
// Sheets float image: width/height/offset out of range or invalid.
LarkErrSheetsFloatImageInvalidDims = 1310246
@@ -83,6 +87,8 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
// drive-specific constraints that benefit from actionable hints
case LarkErrDriveResourceContention:
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
case LarkErrWikiLockContention:
return ExitAPI, "conflict", "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes"
case LarkErrDriveCrossTenantUnit:
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
case LarkErrDriveCrossBrand:

View File

@@ -90,3 +90,24 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
})
}
}
// TestClassifyLarkError_WikiLockContention verifies the wiki write-lock
// contention error (131009) maps to an actionable retry hint instead of
// a generic "api_error". Surfaces during concurrent wiki +node-create
// against the same parent (see larksuite/cli#1012).
func TestClassifyLarkError_WikiLockContention(t *testing.T) {
t.Parallel()
gotExitCode, gotType, gotHint := ClassifyLarkError(LarkErrWikiLockContention, "raw msg")
if gotExitCode != ExitAPI {
t.Fatalf("exitCode=%d, want %d", gotExitCode, ExitAPI)
}
if gotType != "conflict" {
t.Fatalf("type=%q, want %q", gotType, "conflict")
}
if !strings.Contains(gotHint, "wiki write lock") {
t.Fatalf("hint=%q, want substring %q", gotHint, "wiki write lock")
}
if !strings.Contains(gotHint, "backoff") {
t.Fatalf("hint=%q, want substring %q", gotHint, "backoff")
}
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"fmt"
"net/url"
"github.com/larksuite/cli/internal/core"
)
// ExtractRequiredScopes pulls scope names out of the API error's
// permission_violations field. The detail argument is the raw `error` block
// that the platform returns alongside lark code 99991672 / 99991679 — typically
// shaped as:
//
// { "permission_violations": [ {"subject": "<scope>"}, ... ] }
//
// Returns nil when the structure does not match or no non-empty subjects are
// present, so callers can branch on a simple len() == 0 check.
func ExtractRequiredScopes(detail interface{}) []string {
m, ok := detail.(map[string]interface{})
if !ok {
return nil
}
violations, ok := m["permission_violations"].([]interface{})
if !ok {
return nil
}
scopes := make([]string, 0, len(violations))
for _, v := range violations {
vm, ok := v.(map[string]interface{})
if !ok {
continue
}
if subject, ok := vm["subject"].(string); ok && subject != "" {
scopes = append(scopes, subject)
}
}
if len(scopes) == 0 {
return nil
}
return scopes
}
// SelectRecommendedScopeFromStrings is a string-typed convenience wrapper
// around SelectRecommendedScope. When no scope is recognized by the priority
// table, it falls back to the first input scope so callers always have
// something to surface to users.
func SelectRecommendedScopeFromStrings(scopes []string, identity string) string {
if len(scopes) == 0 {
return ""
}
ifaces := make([]interface{}, len(scopes))
for i, s := range scopes {
ifaces[i] = s
}
if recommended := SelectRecommendedScope(ifaces, identity); recommended != "" {
return recommended
}
return scopes[0]
}
// BuildConsoleScopeURL returns the developer-console "apply scope" URL for the
// given app and scope, branded for feishu / lark. Returns "" when appID or
// scope is empty so callers can omit the field cleanly.
func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
if appID == "" || scope == "" {
return ""
}
host := "open.feishu.cn"
if brand == core.BrandLark {
host = "open.larksuite.com"
}
return fmt.Sprintf(
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
host,
url.QueryEscape(appID),
url.QueryEscape(scope),
)
}

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestExtractRequiredScopes_HappyPath(t *testing.T) {
detail := map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:permission.member:create"},
map[string]interface{}{"subject": "docs:doc"},
map[string]interface{}{"subject": ""}, // empty subject filtered
"not-a-map", // ignored
},
}
got := ExtractRequiredScopes(detail)
want := []string{"docs:permission.member:create", "docs:doc"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("ExtractRequiredScopes mismatch: got %v, want %v", got, want)
}
}
func TestExtractRequiredScopes_NilOrMalformed(t *testing.T) {
cases := []interface{}{
nil,
"plain string",
map[string]interface{}{},
map[string]interface{}{"permission_violations": "not-a-list"},
map[string]interface{}{"permission_violations": []interface{}{}},
map[string]interface{}{"permission_violations": []interface{}{
map[string]interface{}{"subject": ""},
}},
}
for i, in := range cases {
if got := ExtractRequiredScopes(in); got != nil {
t.Errorf("case %d: expected nil, got %v", i, got)
}
}
}
func TestBuildConsoleScopeURL_BrandSpecificHost(t *testing.T) {
got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", "docs:permission.member:create")
if !strings.Contains(got, "open.feishu.cn") {
t.Errorf("feishu brand should use open.feishu.cn host, got %s", got)
}
if !strings.Contains(got, "clientID=cli_xxx") {
t.Errorf("missing app id in url: %s", got)
}
if !strings.Contains(got, "scopes=docs%3Apermission.member%3Acreate") {
t.Errorf("scope not URL-escaped: %s", got)
}
got = BuildConsoleScopeURL(core.BrandLark, "cli_yyy", "drive:drive")
if !strings.Contains(got, "open.larksuite.com") {
t.Errorf("lark brand should use open.larksuite.com host, got %s", got)
}
}
func TestBuildConsoleScopeURL_EmptyInput(t *testing.T) {
if got := BuildConsoleScopeURL(core.BrandFeishu, "", "docs:doc"); got != "" {
t.Errorf("empty appID should yield empty url, got %s", got)
}
if got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", ""); got != "" {
t.Errorf("empty scope should yield empty url, got %s", got)
}
}
func TestSelectRecommendedScopeFromStrings_FallsBackToFirst(t *testing.T) {
ensureFreshRegistry(t)
// Unknown scopes (not in priority table) → fallback to first
got := SelectRecommendedScopeFromStrings([]string{"unknown:foo", "unknown:bar"}, "tenant")
if got != "unknown:foo" {
t.Errorf("expected fallback to first, got %s", got)
}
}
// When at least one scope is recognized by the priority table, the
// recommended scope wins over the fallback (first input).
func TestSelectRecommendedScopeFromStrings_PicksKnownScopeOverFallback(t *testing.T) {
ensureFreshRegistry(t)
// docs:permission.member:create is recommended (recommend=true) in
// scope_priorities.json. Putting an unknown scope first would otherwise
// win via the fallback path; this ensures the priority table is consulted
// before falling back.
got := SelectRecommendedScopeFromStrings([]string{"unknown:foo", "docs:permission.member:create"}, "tenant")
if got != "docs:permission.member:create" {
t.Errorf("expected priority-table winner, got %s", got)
}
}
func TestSelectRecommendedScopeFromStrings_Empty(t *testing.T) {
if got := SelectRecommendedScopeFromStrings(nil, "tenant"); got != "" {
t.Errorf("nil slice should return empty, got %s", got)
}
if got := SelectRecommendedScopeFromStrings([]string{}, "tenant"); got != "" {
t.Errorf("empty slice should return empty, got %s", got)
}
}

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.36",
"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

@@ -4,9 +4,12 @@
package common
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/validate"
)
@@ -34,6 +37,7 @@ func AutoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
PermissionGrantSkipped,
"",
fmt.Sprintf("The operation did not return a permission target (missing token/type), so current user %s was not granted. You can retry later or continue using bot identity.", permissionGrantPermMessage()),
"No permission target (missing token or type) returned by the operation.",
)
}
@@ -43,11 +47,14 @@ func AutoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourceType string) map[string]interface{} {
userOpenID := strings.TrimSpace(runtime.UserOpenId())
if userOpenID == "" {
return buildPermissionGrantResult(
result := buildPermissionGrantResult(
PermissionGrantSkipped,
"",
fmt.Sprintf("Resource was created with bot identity, but no current CLI user open_id is configured, so current user %s was not granted. You can retry later or continue using bot identity.", permissionGrantPermMessage()),
"No current user identity (not logged in or session expired).",
)
fmt.Fprintf(runtime.IO().ErrOut, "Warning: resource was created with bot identity, but no current user open_id is configured, so auto-grant was skipped. Run `lark-cli auth login` and retry, or grant permission manually.\n")
return result
}
body := map[string]interface{}{
@@ -70,21 +77,32 @@ func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
body,
)
if err != nil {
return buildPermissionGrantResult(
errMsg := compactPermissionGrantError(err)
result := buildPermissionGrantResult(
PermissionGrantFailed,
userOpenID,
fmt.Sprintf("Resource was created, but granting current user %s failed: %s. You can retry later or continue using bot identity.", permissionGrantPermMessage(), compactPermissionGrantError(err)),
fmt.Sprintf("Resource was created, but granting current user %s failed: %s. You can retry later or continue using bot identity.", permissionGrantPermMessage(), errMsg),
fmt.Sprintf("Auto-grant failed: %s. The app may lack the required scope or the resource restricts permission changes.", errMsg),
)
// Best-effort: when the underlying error is a structured permission
// ExitError (lark code 99991672/99991679), surface lark_code,
// required_scope and console_url so agents can guide users straight
// to the dev console. Overrides the generic hint with a more
// actionable one when console_url is available.
annotateGrantPermissionError(runtime, result, err)
fmt.Fprintf(runtime.IO().ErrOut, "Warning: resource was created, but auto-grant failed: %s. Retry later or grant permission manually.\n", errMsg)
return result
}
return buildPermissionGrantResult(
PermissionGrantGranted,
userOpenID,
fmt.Sprintf("Granted the current CLI user %s on the new %s.", permissionGrantPermMessage(), permissionTargetLabel(resourceType)),
"",
)
}
func buildPermissionGrantResult(status, userOpenID, message string) map[string]interface{} {
func buildPermissionGrantResult(status, userOpenID, message, reason string) map[string]interface{} {
result := map[string]interface{}{
"status": status,
"perm": permissionGrantPerm,
@@ -94,6 +112,11 @@ func buildPermissionGrantResult(status, userOpenID, message string) map[string]i
result["user_open_id"] = userOpenID
result["member_type"] = "openid"
}
if status == PermissionGrantSkipped {
result["hint"] = reason + " Run `lark-cli auth login` and retry, or grant permission manually via the Lark document UI."
} else if status == PermissionGrantFailed {
result["hint"] = reason + " Retry later or grant permission manually via the Lark document UI."
}
return result
}
@@ -137,3 +160,54 @@ func compactPermissionGrantError(err error) string {
}
return strings.Join(strings.Fields(err.Error()), " ")
}
// annotateGrantPermissionError enriches a failed permission_grant result with
// structured fields (lark_code / required_scope / console_url) when the
// underlying error is a permission-class *output.ExitError. The CLI's main
// permission-error path (cmd/root.go::enrichPermissionError) handles the same
// case for top-level failures; this helper covers best-effort sub-calls whose
// error is folded into a result map instead of propagated as ExitError.
//
// When console_url is available, the existing generic hint is overridden with
// a more actionable one pointing at the developer console — that's the
// concrete next step a user can take.
func annotateGrantPermissionError(runtime *RuntimeContext, result map[string]interface{}, err error) {
if runtime == nil || result == nil || err == nil {
return
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return
}
if exitErr.Detail.Type != "permission" {
return
}
if exitErr.Detail.Code != 0 {
result["lark_code"] = exitErr.Detail.Code
}
scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
if len(scopes) == 0 {
return
}
recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant")
if recommended == "" {
return
}
result["required_scope"] = recommended
if runtime.Config == nil || runtime.Config.AppID == "" {
return
}
consoleURL := registry.BuildConsoleScopeURL(runtime.Config.Brand, runtime.Config.AppID, recommended)
if consoleURL == "" {
return
}
result["console_url"] = consoleURL
// Override the generic hint: pointing at the dev console is more actionable
// than the generic "retry later" fallback set by buildPermissionGrantResult.
result["hint"] = fmt.Sprintf(
"App is missing the %q scope; enable it in the developer console (see console_url), then retry.",
recommended,
)
}

View File

@@ -0,0 +1,311 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) {
config := &core.CliConfig{
AppID: "perm-grant-test-skip",
AppSecret: "perm-grant-test-secret-skip",
Brand: core.BrandFeishu,
}
f, _, stderr, _ := cmdutil.TestFactory(t, config)
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-1")
runtime := &RuntimeContext{
ctx: ctx,
Config: config,
Factory: f,
resolvedAs: core.AsBot,
}
result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx")
if result == nil {
t.Fatal("expected non-nil result for bot mode with empty user open_id")
}
if result["status"] != PermissionGrantSkipped {
t.Fatalf("status = %v, want %q", result["status"], PermissionGrantSkipped)
}
if !strings.Contains(stderr.String(), "auto-grant was skipped") {
t.Fatalf("stderr missing auto-grant skipped warning; got:\n%s", stderr.String())
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
t.Fatalf("hint = %#v, want string containing 'auth login'", result["hint"])
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "not logged in") {
t.Fatalf("hint = %#v, want string containing 'not logged in'", result["hint"])
}
}
func TestAutoGrantStderrWarning_GrantFailed(t *testing.T) {
config := &core.CliConfig{
AppID: "perm-grant-test-fail",
AppSecret: "perm-grant-test-secret-fail",
Brand: core.BrandFeishu,
UserOpenId: "ou_test_user",
}
f, _, stderr, reg := cmdutil.TestFactory(t, config)
// Register a stub that returns an error code so CallAPI returns an error.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/tkn_doc/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
})
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-2")
runtime := &RuntimeContext{
ctx: ctx,
Config: config,
Factory: f,
resolvedAs: core.AsBot,
}
result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx")
if result == nil {
t.Fatal("expected non-nil result for bot mode with grant failure")
}
if result["status"] != PermissionGrantFailed {
t.Fatalf("status = %v, want %q", result["status"], PermissionGrantFailed)
}
if !strings.Contains(stderr.String(), "auto-grant failed") {
t.Fatalf("stderr missing auto-grant failed warning; got:\n%s", stderr.String())
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
t.Fatalf("hint = %#v, want string containing 'Retry later'", result["hint"])
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "scope") {
t.Fatalf("hint = %#v, want string containing 'scope'", result["hint"])
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "permission changes") {
t.Fatalf("hint = %#v, want string containing 'permission changes'", result["hint"])
}
}
// ── annotateGrantPermissionError unit tests ────────────────────────────────
func newAnnotateRuntime(brand core.LarkBrand, appID string) *RuntimeContext {
return &RuntimeContext{
Config: &core.CliConfig{
AppID: appID,
Brand: brand,
},
}
}
// permission_violations subjects must surface as required_scope, and the
// console_url must be brand-specific. The hint should be overridden to point
// at the developer console.
func TestAnnotateGrantPermissionError_AppScopeNotEnabled(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
result := map[string]interface{}{
"hint": "generic fallback hint",
}
err := output.ErrAPI(99991672, "Permission denied [99991672]", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:permission.member:create"},
},
})
annotateGrantPermissionError(rt, result, err)
if got := result["lark_code"]; got != 99991672 {
t.Errorf("expected lark_code=99991672, got %v", got)
}
if got, _ := result["required_scope"].(string); got != "docs:permission.member:create" {
t.Errorf("required_scope mismatch: got %v", got)
}
consoleURL, _ := result["console_url"].(string)
if !strings.HasPrefix(consoleURL, "https://open.feishu.cn/page/scope-apply") {
t.Errorf("console_url should target open.feishu.cn, got %s", consoleURL)
}
if !strings.Contains(consoleURL, "clientID=cli_demo") {
t.Errorf("console_url missing clientID, got %s", consoleURL)
}
hint, _ := result["hint"].(string)
if !strings.Contains(hint, "console_url") {
t.Errorf("hint should reference console_url, got %s", hint)
}
if !strings.Contains(hint, "docs:permission.member:create") {
t.Errorf("hint should mention required scope, got %s", hint)
}
}
func TestAnnotateGrantPermissionError_LarkBrand(t *testing.T) {
rt := newAnnotateRuntime(core.BrandLark, "cli_demo")
result := map[string]interface{}{}
err := output.ErrAPI(99991679, "Permission denied [99991679]", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:permission.member:create"},
},
})
annotateGrantPermissionError(rt, result, err)
if u, _ := result["console_url"].(string); !strings.Contains(u, "open.larksuite.com") {
t.Errorf("lark brand should yield larksuite host, got %s", u)
}
}
// Non-permission errors (network, validation, plain errors) must not be
// annotated — keep the existing generic hint untouched.
func TestAnnotateGrantPermissionError_NonPermissionErrorNoOp(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
cases := []error{
errors.New("plain error"),
output.ErrNetwork("connection reset"),
output.ErrValidation("bad request"),
// Non-permission API errors (e.g. 230001) — type is "api_error" not "permission"
output.ErrAPI(230001, "no permission", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:doc"},
},
}),
}
for i, e := range cases {
result := map[string]interface{}{
"hint": "untouched hint",
}
annotateGrantPermissionError(rt, result, e)
if _, ok := result["lark_code"]; ok {
t.Errorf("case %d: expected no lark_code, got %v", i, result["lark_code"])
}
if _, ok := result["console_url"]; ok {
t.Errorf("case %d: expected no console_url, got %v", i, result["console_url"])
}
if got, _ := result["hint"].(string); got != "untouched hint" {
t.Errorf("case %d: hint should be unchanged, got %s", i, got)
}
}
}
// permission_violations missing → only lark_code is annotated; no console_url
// and the existing hint stays as-is (caller's generic fallback wins).
func TestAnnotateGrantPermissionError_NoViolations(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
result := map[string]interface{}{
"hint": "untouched fallback",
}
err := output.ErrAPI(99991672, "Permission denied [99991672]", nil)
annotateGrantPermissionError(rt, result, err)
if got := result["lark_code"]; got != 99991672 {
t.Errorf("expected lark_code captured, got %v", got)
}
if _, ok := result["console_url"]; ok {
t.Errorf("console_url must not be set when violations are absent")
}
if got, _ := result["hint"].(string); got != "untouched fallback" {
t.Errorf("hint should remain fallback when no console_url, got %s", got)
}
}
// AppID empty → no console_url even when violations exist.
func TestAnnotateGrantPermissionError_EmptyAppID(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "")
result := map[string]interface{}{}
err := output.ErrAPI(99991672, "Permission denied", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:doc"},
},
})
annotateGrantPermissionError(rt, result, err)
if _, ok := result["console_url"]; ok {
t.Errorf("console_url must not be set when appID is empty")
}
if got, _ := result["required_scope"].(string); got != "docs:doc" {
t.Errorf("required_scope should still be set when appID is empty, got %s", got)
}
}
// Defensive: nil/empty arguments must be safe no-ops.
func TestAnnotateGrantPermissionError_NilArgsSafe(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
annotateGrantPermissionError(nil, map[string]interface{}{}, nil)
annotateGrantPermissionError(rt, nil, nil)
annotateGrantPermissionError(rt, map[string]interface{}{}, nil)
annotateGrantPermissionError(rt, map[string]interface{}{}, errors.New(""))
}
// Integration-style: end-to-end through AutoGrantCurrentUserDrivePermission
// with a mocked 99991672 response — verifies the annotated fields show up
// in the JSON result that callers downstream consume.
func TestAutoGrantStderrWarning_GrantFailed_AppScopeNotEnabled_Annotated(t *testing.T) {
config := &core.CliConfig{
AppID: "cli_app_demo",
AppSecret: "secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test_user",
}
f, _, _, reg := cmdutil.TestFactory(t, config)
// Stub the permission member create endpoint with a 99991672 response that
// includes permission_violations — what the platform returns when the app
// has not enabled the API scope.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/tkn_doc/members",
Body: map[string]interface{}{
"code": 99991672,
"msg": "App scope not enabled",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:permission.member:create"},
},
},
},
})
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-3")
runtime := &RuntimeContext{
ctx: ctx,
Config: config,
Factory: f,
resolvedAs: core.AsBot,
}
result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx")
if result == nil {
t.Fatal("expected non-nil result")
}
if result["status"] != PermissionGrantFailed {
t.Fatalf("status = %v, want failed", result["status"])
}
if result["lark_code"] != 99991672 {
t.Errorf("lark_code = %v, want 99991672", result["lark_code"])
}
if got, _ := result["required_scope"].(string); got != "docs:permission.member:create" {
t.Errorf("required_scope = %v, want docs:permission.member:create", got)
}
consoleURL, _ := result["console_url"].(string)
if !strings.Contains(consoleURL, "open.feishu.cn/page/scope-apply") {
t.Errorf("console_url missing or wrong host: %s", consoleURL)
}
if !strings.Contains(consoleURL, "scopes=docs%3Apermission.member%3Acreate") {
t.Errorf("console_url missing escaped scope: %s", consoleURL)
}
hint, _ := result["hint"].(string)
if !strings.Contains(hint, "console_url") {
t.Errorf("hint should be overridden to mention console_url, got %s", hint)
}
}

View File

@@ -176,7 +176,11 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
if firstErrCode != 0 {
return nil, output.ErrAPI(firstErrCode, msg, "")
}
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg, "")
// No structured API code — the failure was transport, parse, panic, or
// cancellation. Suggest the actionable next step rather than shipping
// an empty hint that would leave the calling agent with nothing to do.
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg,
"retry the command; if it persists, narrow --queries to a single term to isolate the failing input")
}
return out, nil
}

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -18,6 +19,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -1133,6 +1135,33 @@ func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) {
}
}
// When all queries fail with no structured Lark API code (transport, parse,
// panic, ctx-canceled), the returned ExitError must carry an actionable
// hint so the calling agent has a next step to try instead of giving up.
func TestFanoutAssemble_AllFailed_NoCode_HasActionableHint(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", ErrMsg: "transport: connection refused"},
{Index: 1, Query: "bob", ErrMsg: "transport: timeout"},
}
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err == nil {
t.Fatalf("expected error when all queries failed")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail == nil {
t.Fatalf("expected Detail, got nil")
}
if exitErr.Detail.Hint == "" {
t.Errorf("expected non-empty Hint so agents have a next step; got empty")
}
if !strings.Contains(exitErr.Detail.Hint, "retry") {
t.Errorf("hint should suggest retry as the first action; got %q", exitErr.Detail.Hint)
}
}
// Codes from the first failure must propagate through output.ErrAPI so the
// CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
// instead of 0, which would mean "success" in the Lark protocol.

View File

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

View File

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

View File

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

View File

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

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

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

@@ -2331,15 +2331,15 @@ func validateSendTime(runtime *common.RuntimeContext) error {
return nil
}
if !runtime.Bool("confirm-send") {
return fmt.Errorf("--send-time requires --confirm-send to be set")
return output.ErrValidation("--send-time requires --confirm-send to be set")
}
ts, err := strconv.ParseInt(sendTime, 10, 64)
if err != nil {
return fmt.Errorf("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
return output.ErrValidation("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
}
minTime := time.Now().Unix() + 5*60
if ts < minTime {
return fmt.Errorf("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
return output.ErrValidation("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
}
return nil
}
@@ -2444,10 +2444,10 @@ func validateRecipientCount(to, cc, bcc string) error {
func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFlag string, plainText bool, body string) error {
if strings.TrimSpace(inlineFlag) != "" {
if plainText {
return fmt.Errorf("--inline is not supported with --plain-text (inline images require HTML body)")
return output.ErrValidation("--inline is not supported with --plain-text (inline images require HTML body)")
}
if body != "" && !bodyIsHTML(body) {
return fmt.Errorf("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
return output.ErrValidation("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
}
}
inlineSpecs, err := parseInlineSpecs(inlineFlag)
@@ -2529,7 +2529,7 @@ func validateEventFlags(runtime *common.RuntimeContext) error {
hasAll := summary != "" && start != "" && end != ""
if hasAny && !hasAll {
return fmt.Errorf("--event-summary, --event-start, and --event-end must all be provided together")
return output.ErrValidation("--event-summary, --event-start, and --event-end must all be provided together")
}
if summary == "" {
return nil

View File

@@ -13,6 +13,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -241,7 +242,7 @@ func signatureCIDs(sig *signatureResult) []string {
// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set.
func validateSignatureWithPlainText(plainText bool, signatureID string) error {
if plainText && signatureID != "" {
return fmt.Errorf("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode")
return output.ErrValidation("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode")
}
return nil
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -463,7 +464,7 @@ func validateExpectedFlag(s string) error {
}
var arr []interface{}
if err := json.Unmarshal([]byte(s), &arr); err != nil {
return fmt.Errorf("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
return output.ErrValidation("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
}
return nil
}

View File

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

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ const defaultTaskAttachmentResourceType = "task"
var UploadAttachmentTask = common.Shortcut{
Service: "task",
Command: "+upload-attachment",
Description: "upload a local file as an attachment to a task; use --resource-type=task_delivery when uploading to task agents",
Description: "upload a local file as an attachment to a task",
Risk: "write",
Scopes: []string{"task:attachment:write"},
AuthTypes: []string{"user", "bot"},

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

@@ -5,8 +5,11 @@ package wiki
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -24,6 +27,16 @@ const (
wikiResolvedByMyLibrary = "my_library"
)
const (
// wikiNodeCreateMaxRetries is the maximum number of retry attempts after
// the initial request when the API returns lock contention (code 131009).
wikiNodeCreateMaxRetries = 2
// wikiNodeCreateRetryBaseDelay is the initial backoff delay for lock
// contention retries. Subsequent retries double the delay (250ms, 500ms).
wikiNodeCreateRetryBaseDelay = 250 * time.Millisecond
)
var wikiObjectTypes = []string{
"sheet",
"mindnote",
@@ -68,7 +81,7 @@ var WikiNodeCreate = common.Shortcut{
spec := readWikiNodeCreateSpec(runtime)
fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki node...\n")
execution, err := runWikiNodeCreate(ctx, wikiNodeCreateAPI{runtime: runtime}, runtime.As(), spec)
execution, err := runWikiNodeCreate(ctx, wikiNodeCreateAPI{runtime: runtime}, runtime.As(), spec, runtime.IO().ErrOut)
if err != nil {
return err
}
@@ -288,15 +301,37 @@ func needsMyLibraryLookup(spec wikiNodeCreateSpec) bool {
return spec.SpaceID == "" || spec.SpaceID == wikiMyLibrarySpaceID
}
func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identity core.Identity, spec wikiNodeCreateSpec) (*wikiNodeCreateExecution, error) {
func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identity core.Identity, spec wikiNodeCreateSpec, errOut io.Writer) (*wikiNodeCreateExecution, error) {
resolvedSpace, err := resolveWikiNodeCreateSpace(ctx, client, identity, spec)
if err != nil {
return nil, err
}
node, err := client.CreateNode(ctx, resolvedSpace.SpaceID, spec)
if err != nil {
return nil, err
var (
node *wikiNodeRecord
lastErr error
)
for attempt := 0; attempt <= wikiNodeCreateMaxRetries; attempt++ {
if attempt > 0 {
delay := wikiNodeCreateRetryBaseDelay << uint(attempt-1)
fmt.Fprintf(errOut, "Wiki node create encountered lock contention, retrying (attempt %d/%d) in %v...\n", attempt, wikiNodeCreateMaxRetries, delay)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
node, lastErr = client.CreateNode(ctx, resolvedSpace.SpaceID, spec)
if lastErr == nil {
break
}
if !isWikiNodeLockContention(lastErr) {
return nil, lastErr
}
}
if lastErr != nil {
return nil, wrapWikiNodeCreateRetryError(lastErr)
}
if node == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node create returned no node")
@@ -308,6 +343,50 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit
}, nil
}
// isWikiNodeLockContention returns true if the error is a Lark API error with
// code 131009 (wiki node lock contention), which is retryable with backoff.
func isWikiNodeLockContention(err error) bool {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return false
}
return exitErr.Detail.Code == output.LarkErrWikiLockContention
}
// wrapWikiNodeCreateRetryError appends a retry-exhaustion hint to the original
// API error. It builds the ExitError by hand (instead of using ErrWithHint) so
// the original Lark error code survives in the envelope.
func wrapWikiNodeCreateRetryError(err error) error {
if err == nil {
return nil
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return err
}
hint := fmt.Sprintf(
"wiki node create failed after %d retries due to lock contention; try again later or reduce concurrent node creations under the same parent",
wikiNodeCreateMaxRetries,
)
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
hint = existing + "\n" + hint
}
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: exitErr.Detail.Message,
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: exitErr.Detail.Detail,
},
Err: exitErr.Err,
Raw: exitErr.Raw,
}
}
// resolveWikiNodeCreateSpace applies the shortcut's precedence rules:
// explicit space ID wins, then parent-node inference, then my_library fallback.
func resolveWikiNodeCreateSpace(ctx context.Context, client wikiNodeCreateClient, identity core.Identity, spec wikiNodeCreateSpec) (wikiResolvedSpace, error) {

View File

@@ -7,7 +7,9 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"sync/atomic"
"testing"
@@ -17,6 +19,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -31,6 +34,7 @@ type fakeWikiNodeCreateClient struct {
createNode *wikiNodeRecord
returnNilNode bool
createErr error
createErrs []error // consumed in order; takes precedence over createErr
getSpaceErr error
getNodeErr error
createInvoked []fakeWikiNodeCreateCall
@@ -63,6 +67,11 @@ func (fake *fakeWikiNodeCreateClient) CreateNode(ctx context.Context, spaceID st
SpaceID: spaceID,
Spec: spec,
})
if len(fake.createErrs) > 0 {
err := fake.createErrs[0]
fake.createErrs = fake.createErrs[1:]
return nil, err
}
if fake.createErr != nil {
return nil, fake.createErr
}
@@ -248,7 +257,7 @@ func TestRunWikiNodeCreateCreatesNodeInResolvedSpace(t *testing.T) {
ObjType: "docx",
Title: "Roadmap",
}
execution, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec)
execution, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec, io.Discard)
if err != nil {
t.Fatalf("runWikiNodeCreate() error = %v", err)
}
@@ -280,7 +289,7 @@ func TestRunWikiNodeCreateRejectsNilCreatedNode(t *testing.T) {
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
})
}, io.Discard)
if err == nil || !strings.Contains(err.Error(), "wiki node create returned no node") {
t.Fatalf("expected missing node error, got %v", err)
}
@@ -577,6 +586,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())
@@ -673,3 +781,237 @@ func TestWikiNodeURL(t *testing.T) {
})
}
}
func TestRunWikiNodeCreateRetriesOnLockContention(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
wikiMyLibrarySpaceID: {SpaceID: "space_my_library"},
},
createNode: &wikiNodeRecord{
SpaceID: "space_my_library",
NodeToken: "wik_created",
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
},
createErrs: []error{lockErr, lockErr}, // fail twice, then succeed
}
var stderr bytes.Buffer
spec := wikiNodeCreateSpec{
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
}
execution, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec, &stderr)
if err != nil {
t.Fatalf("runWikiNodeCreate() error = %v", err)
}
if len(client.createInvoked) != 3 {
t.Fatalf("create invoked %d times, want 3", len(client.createInvoked))
}
if execution.Node.NodeToken != "wik_created" {
t.Fatalf("node token = %q, want %q", execution.Node.NodeToken, "wik_created")
}
if !strings.Contains(stderr.String(), "lock contention") {
t.Fatalf("stderr = %q, want lock contention log", stderr.String())
}
if !strings.Contains(stderr.String(), "retrying (attempt 1/") {
t.Fatalf("stderr = %q, want attempt 1 log", stderr.String())
}
if !strings.Contains(stderr.String(), "retrying (attempt 2/") {
t.Fatalf("stderr = %q, want attempt 2 log", stderr.String())
}
}
func TestRunWikiNodeCreateRetriesExhausted(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
wikiMyLibrarySpaceID: {SpaceID: "space_my_library"},
},
createErrs: []error{lockErr, lockErr, lockErr}, // all 3 attempts fail
}
var stderr bytes.Buffer
spec := wikiNodeCreateSpec{
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
}
_, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec, &stderr)
if err == nil {
t.Fatalf("expected error after retries exhausted")
}
if len(client.createInvoked) != 3 {
t.Fatalf("create invoked %d times, want 3", len(client.createInvoked))
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError, got %T: %v", err, err)
}
if exitErr.Detail.Code != output.LarkErrWikiLockContention {
t.Fatalf("error code = %d, want %d", exitErr.Detail.Code, output.LarkErrWikiLockContention)
}
if !strings.Contains(exitErr.Detail.Hint, "failed after 2 retries") {
t.Fatalf("hint = %q, want retry exhaustion message", exitErr.Detail.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, "lock contention") {
t.Fatalf("hint = %q, want original classification hint preserved", exitErr.Detail.Hint)
}
}
func TestRunWikiNodeCreateNoRetryOnNonContentionError(t *testing.T) {
t.Parallel()
otherErr := output.ErrAPI(output.LarkErrRateLimit, "rate limit", nil) // rate limit, not lock contention
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
wikiMyLibrarySpaceID: {SpaceID: "space_my_library"},
},
createErrs: []error{otherErr},
}
var stderr bytes.Buffer
spec := wikiNodeCreateSpec{
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
}
_, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec, &stderr)
if err == nil {
t.Fatalf("expected error")
}
if len(client.createInvoked) != 1 {
t.Fatalf("create invoked %d times, want 1 (no retry)", len(client.createInvoked))
}
if strings.Contains(stderr.String(), "retrying") {
t.Fatalf("stderr = %q, should not contain retry log for non-contention error", stderr.String())
}
}
func TestRunWikiNodeCreateRetriesOnFirstLockThenSucceeds(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
wikiMyLibrarySpaceID: {SpaceID: "space_my_library"},
},
createNode: &wikiNodeRecord{
SpaceID: "space_my_library",
NodeToken: "wik_created",
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
},
createErrs: []error{lockErr}, // fail once, then succeed
}
var stderr bytes.Buffer
spec := wikiNodeCreateSpec{
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
}
execution, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec, &stderr)
if err != nil {
t.Fatalf("runWikiNodeCreate() error = %v", err)
}
if len(client.createInvoked) != 2 {
t.Fatalf("create invoked %d times, want 2", len(client.createInvoked))
}
if execution.Node.NodeToken != "wik_created" {
t.Fatalf("node token = %q, want %q", execution.Node.NodeToken, "wik_created")
}
if !strings.Contains(stderr.String(), "retrying (attempt 1/") {
t.Fatalf("stderr = %q, want attempt 1 log", stderr.String())
}
if strings.Contains(stderr.String(), "retrying (attempt 2/") {
t.Fatalf("stderr = %q, should not contain attempt 2 log", stderr.String())
}
}
func TestRunWikiNodeCreateRetryContextCancelled(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
wikiMyLibrarySpaceID: {SpaceID: "space_my_library"},
},
createErrs: []error{lockErr, lockErr, lockErr}, // always fail
}
var stderr bytes.Buffer
spec := wikiNodeCreateSpec{
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
}
// Pre-cancel the context so the retry loop's select picks up
// ctx.Done() immediately during the first backoff wait.
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := runWikiNodeCreate(ctx, client, core.AsUser, spec, &stderr)
if err == nil {
t.Fatalf("expected error due to context cancellation")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
// The initial attempt runs (context is checked only during backoff
// wait), but no retries should complete.
if len(client.createInvoked) != 1 {
t.Fatalf("create invoked %d times, want 1 (no retries after cancel)", len(client.createInvoked))
}
}
func TestRunWikiNodeCreateNoRetryOnSuccess(t *testing.T) {
t.Parallel()
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
wikiMyLibrarySpaceID: {SpaceID: "space_my_library"},
},
createNode: &wikiNodeRecord{
SpaceID: "space_my_library",
NodeToken: "wik_created",
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
},
}
var stderr bytes.Buffer
spec := wikiNodeCreateSpec{
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
Title: "Roadmap",
}
execution, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec, &stderr)
if err != nil {
t.Fatalf("runWikiNodeCreate() error = %v", err)
}
if len(client.createInvoked) != 1 {
t.Fatalf("create invoked %d times, want 1", len(client.createInvoked))
}
if execution.Node.NodeToken != "wik_created" {
t.Fatalf("node token = %q, want %q", execution.Node.NodeToken, "wik_created")
}
if strings.Contains(stderr.String(), "retrying") {
t.Fatalf("stderr = %q, should not contain retry log on success", stderr.String())
}
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// wikiNodeGetURLObjTypes maps a Lark URL path prefix (slash-bounded) to the
@@ -57,14 +58,26 @@ var WikiNodeGet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "token", Desc: "wiki node_token, obj_token, or a Lark URL embedding one of them", Required: true},
{Name: "obj-type", Desc: "obj_type when --token is an obj_token; auto-inferred from URL path when omitted", Enum: wikiNodeGetObjTypeEnum},
// --node-token is the canonical flag, matching sibling wiki commands
// (+node-delete / +node-copy / +move). --token is the original name
// and is kept as a hidden deprecated alias for backward compatibility;
// MarkDeprecated (registered in PostMount) prints a stderr warning
// when --token is used.
{Name: "node-token", Desc: "wiki node_token, obj_token, or a Lark URL embedding one of them"},
{Name: "token", Desc: "DEPRECATED: use --node-token", Hidden: true},
{Name: "obj-type", Desc: "obj_type when --node-token is an obj_token; auto-inferred from URL path when omitted", Enum: wikiNodeGetObjTypeEnum},
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
},
Tips: []string{
"--token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
"--token is the deprecated original name and still works for backward compatibility; new scripts should use --node-token.",
},
PostMount: func(cmd *cobra.Command) {
// cobra's MarkDeprecated prints "Flag --token has been deprecated, use --node-token instead"
// to stderr on use, and hides the flag from --help (matching the Hidden: true marker above).
_ = cmd.Flags().MarkDeprecated("token", "use --node-token instead")
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readWikiNodeGetSpec(runtime)
@@ -142,20 +155,45 @@ func (spec wikiNodeGetSpec) RequestParams() map[string]interface{} {
}
func readWikiNodeGetSpec(runtime *common.RuntimeContext) (wikiNodeGetSpec, error) {
return parseWikiNodeGetSpec(
rawToken, err := resolveWikiNodeGetRawToken(
runtime.Str("node-token"),
runtime.Str("token"),
)
if err != nil {
return wikiNodeGetSpec{}, err
}
return parseWikiNodeGetSpec(
rawToken,
runtime.Str("obj-type"),
runtime.Str("space-id"),
)
}
// resolveWikiNodeGetRawToken picks between the canonical --node-token and the
// deprecated --token alias. Both empty is fine (parseWikiNodeGetSpec will
// surface the required-flag error). Both set with different values is rejected
// upfront so callers fix the obvious bug rather than silently picking one.
func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) {
canonical := strings.TrimSpace(nodeToken)
legacy := strings.TrimSpace(legacyToken)
switch {
case canonical != "" && legacy != "" && canonical != legacy:
return "", output.ErrValidation(
"--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)")
case canonical != "":
return nodeToken, nil
default:
return legacyToken, nil
}
}
// parseWikiNodeGetSpec normalizes the raw flag values: extracts a token from a
// URL when needed, picks the obj_type (URL path > explicit flag > none for
// node_tokens), and validates the token shape.
func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetSpec, error) {
tokenInput := strings.TrimSpace(rawToken)
if tokenInput == "" {
return wikiNodeGetSpec{}, output.ErrValidation("--token is required")
return wikiNodeGetSpec{}, output.ErrValidation("--node-token is required")
}
spec := wikiNodeGetSpec{
@@ -166,12 +204,12 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
if strings.Contains(tokenInput, "://") {
u, err := url.Parse(tokenInput)
if err != nil || u.Path == "" {
return wikiNodeGetSpec{}, output.ErrValidation("--token URL is malformed: %q", tokenInput)
return wikiNodeGetSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
}
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
if !ok {
return wikiNodeGetSpec{}, output.ErrValidation(
"unsupported --token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
u.Path,
)
}
@@ -192,7 +230,7 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
}
} else if strings.ContainsAny(tokenInput, "/?#") {
return wikiNodeGetSpec{}, output.ErrValidation(
"--token must be a raw token or a full URL; partial paths are not accepted: %q",
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
tokenInput,
)
} else {
@@ -223,7 +261,7 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
}
}
if err := validateOptionalResourceName(spec.Token, "--token"); err != nil {
if err := validateOptionalResourceName(spec.Token, "--node-token"); err != nil {
return wikiNodeGetSpec{}, err
}
if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil {

View File

@@ -4,6 +4,7 @@
package wiki
import (
"bytes"
"encoding/json"
"net/http"
"reflect"
@@ -12,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
@@ -98,7 +100,7 @@ func TestParseWikiNodeGetSpecRejectsUnsupportedURLPath(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeGetSpec("https://feishu.cn/im/chat/oc_123", "", "")
if err == nil || !strings.Contains(err.Error(), "unsupported --token URL path") {
if err == nil || !strings.Contains(err.Error(), "unsupported --node-token URL path") {
t.Fatalf("expected unsupported URL path error, got %v", err)
}
}
@@ -115,11 +117,61 @@ func TestParseWikiNodeGetSpecRejectsPartialPath(t *testing.T) {
func TestParseWikiNodeGetSpecRejectsEmptyToken(t *testing.T) {
t.Parallel()
if _, err := parseWikiNodeGetSpec(" ", "", ""); err == nil || !strings.Contains(err.Error(), "--token is required") {
if _, err := parseWikiNodeGetSpec(" ", "", ""); err == nil || !strings.Contains(err.Error(), "--node-token is required") {
t.Fatalf("expected required-token error, got %v", err)
}
}
func TestResolveWikiNodeGetRawTokenPrefersNodeToken(t *testing.T) {
t.Parallel()
got, err := resolveWikiNodeGetRawToken("wikcnNEW", "")
if err != nil || got != "wikcnNEW" {
t.Fatalf("resolve(node-token only) = (%q, %v), want (wikcnNEW, nil)", got, err)
}
}
func TestResolveWikiNodeGetRawTokenAcceptsLegacyToken(t *testing.T) {
t.Parallel()
got, err := resolveWikiNodeGetRawToken("", "wikcnLEGACY")
if err != nil || got != "wikcnLEGACY" {
t.Fatalf("resolve(legacy only) = (%q, %v), want (wikcnLEGACY, nil)", got, err)
}
}
func TestResolveWikiNodeGetRawTokenAcceptsBothWhenEqual(t *testing.T) {
t.Parallel()
// Same value on both flags is harmless (e.g. a script doubled the input
// while migrating to --node-token) — prefer the canonical one and don't
// surface a conflict error.
got, err := resolveWikiNodeGetRawToken("wikcnSAME", "wikcnSAME")
if err != nil || got != "wikcnSAME" {
t.Fatalf("resolve(both same) = (%q, %v), want (wikcnSAME, nil)", got, err)
}
}
func TestResolveWikiNodeGetRawTokenRejectsConflict(t *testing.T) {
t.Parallel()
_, err := resolveWikiNodeGetRawToken("wikcnNEW", "wikcnOLD")
if err == nil || !strings.Contains(err.Error(), "both set with different values") {
t.Fatalf("expected conflict error, got %v", err)
}
}
func TestResolveWikiNodeGetRawTokenEmptyDefersToParser(t *testing.T) {
t.Parallel()
// Both empty is not an error here — the caller (parseWikiNodeGetSpec) is
// where the required-flag check lives and produces the user-facing message.
got, err := resolveWikiNodeGetRawToken("", "")
if err != nil || got != "" {
t.Fatalf("resolve(empty) = (%q, %v), want ('', nil)", got, err)
}
}
func TestBuildWikiNodeGetDryRunSendsObjType(t *testing.T) {
t.Parallel()
@@ -204,7 +256,7 @@ func TestWikiNodeGetMountedExecuteParsesURLAndFormatsOutput(t *testing.T) {
err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--token", "https://feishu.cn/docx/docxXYZ",
"--node-token", "https://feishu.cn/docx/docxXYZ",
"--as", "bot",
}, factory, stdout)
if err != nil {
@@ -245,6 +297,150 @@ func TestWikiNodeGetMountedExecuteParsesURLAndFormatsOutput(t *testing.T) {
}
}
func TestWikiNodeGetMountedAcceptsNodeTokenFlag(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_123",
"node_token": "wikcnABC",
"obj_token": "docxXYZ",
"obj_type": "docx",
"node_type": "origin",
"title": "Via Node-Token",
},
},
"msg": "success",
},
}
var capturedQuery string
stub.OnMatch = func(req *http.Request) {
capturedQuery = req.URL.RawQuery
}
reg.Register(stub)
// Mount inline (rather than using mountAndRunWiki) so we can redirect the
// subcommand's pflag output and assert that no deprecation warning leaks
// when the canonical --node-token is used. The deprecation message comes
// from pflag, not cobra, so SetErr on the cobra root is NOT enough — pflag
// writes to FlagSet.Output(), which we redirect via Flags().SetOutput.
var flagOut bytes.Buffer
parent := mountWikiNodeGetWithFlagOut(t, factory, &flagOut)
parent.SetArgs([]string{
"+node-get",
"--node-token", "https://feishu.cn/docx/docxXYZ",
"--as", "bot",
})
stdout.Reset()
if err := parent.Execute(); err != nil {
t.Fatalf("parent.Execute() error = %v", err)
}
if !strings.Contains(capturedQuery, "token=docxXYZ") || !strings.Contains(capturedQuery, "obj_type=docx") {
t.Fatalf("captured query = %q, want token=docxXYZ and obj_type=docx", capturedQuery)
}
data := decodeWikiEnvelope(t, stdout)
if data["title"] != "Via Node-Token" {
t.Fatalf("title = %#v, want Via Node-Token", data["title"])
}
if got := flagOut.String(); strings.Contains(got, "deprecated") {
t.Fatalf("pflag output unexpectedly contains deprecation warning when using --node-token: %q", got)
}
}
// mountWikiNodeGetWithFlagOut mounts +node-get on a fresh parent and redirects
// the subcommand's pflag output to w so tests can capture cobra/pflag-level
// deprecation messages (which bypass the runtime IO stderr exposed by
// TestFactory).
func mountWikiNodeGetWithFlagOut(t *testing.T, factory *cmdutil.Factory, w *bytes.Buffer) *cobra.Command {
t.Helper()
parent := &cobra.Command{Use: "wiki"}
WikiNodeGet.Mount(parent, factory)
parent.SilenceErrors = true
parent.SilenceUsage = true
parent.SetErr(w)
for _, child := range parent.Commands() {
if child.Use == WikiNodeGet.Command {
child.Flags().SetOutput(w)
return parent
}
}
t.Fatalf("mountWikiNodeGetWithFlagOut: subcommand %q not registered on parent", WikiNodeGet.Command)
return nil
}
func TestWikiNodeGetMountedLegacyTokenFlagWarnsButWorks(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/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_123",
"node_token": "wikcnABC",
"obj_token": "docxXYZ",
"obj_type": "docx",
"node_type": "origin",
"title": "Legacy Token Path",
},
},
"msg": "success",
},
})
var flagOut bytes.Buffer
parent := mountWikiNodeGetWithFlagOut(t, factory, &flagOut)
parent.SetArgs([]string{
"+node-get",
"--token", "wikcnABC",
"--as", "bot",
})
stdout.Reset()
if err := parent.Execute(); err != nil {
t.Fatalf("parent.Execute() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["title"] != "Legacy Token Path" {
t.Fatalf("title = %#v, want Legacy Token Path", data["title"])
}
// pflag MarkDeprecated prints "Flag --token has been deprecated, use --node-token instead".
got := flagOut.String()
if !strings.Contains(got, "deprecated") || !strings.Contains(got, "--node-token") {
t.Fatalf("pflag output = %q, want a deprecation warning pointing to --node-token", got)
}
}
func TestWikiNodeGetMountedRejectsConflictingTokenFlags(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
// reg is unused: conflict is caught in Validate before any HTTP call.
factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--node-token", "wikcnNEW",
"--token", "wikcnOLD",
"--as", "bot",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "both set with different values") {
t.Fatalf("expected conflict error, got %v", err)
}
}
func TestWikiNodeGetFallsBackToCreatorWhenNodeCreatorMissing(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -272,7 +468,7 @@ func TestWikiNodeGetFallsBackToCreatorWhenNodeCreatorMissing(t *testing.T) {
err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--token", "wikcnABC",
"--node-token", "wikcnABC",
"--as", "bot",
}, factory, stdout)
if err != nil {
@@ -311,7 +507,7 @@ func TestWikiNodeGetRejectsSpaceIDMismatch(t *testing.T) {
err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--token", "wikcnABC",
"--node-token", "wikcnABC",
"--space-id", "space_expected",
"--as", "bot",
}, factory, stdout)

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
}

View File

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

View File

@@ -34,6 +34,7 @@
- **Base token 口径统一**:无论 Shortcut 还是原生 API都统一使用 `base_token`
- **附件字段**:上传本地文件时只能走 `lark-cli base +record-upload-attachment`
- **地理位置字段**:写入必须使用 `{lng,lat}`;读取、筛选和转文本等场景使用 `full_address` 字符串,筛选优先用包含匹配;只有公式能访问坐标
- **能力边界**:当前 `base/v3` 原生 spec 以单表 / 单记录 / 视图筛选配置为主,批量写入和旧 `search` 场景优先走 unified Shortcut 组合能力
- **视图重命名确认规则**:用户已经明确“把哪个视图改成什么名字”时,执行 `table.views patch` / 对应 shortcut 直接改名即可,不需要再补一句确认
- **删除确认规则(记录 / 字段 / 表)**:执行 `table.records delete / table.fields delete / tables delete` 或对应 shortcut 时,如果用户已经明确要求删除且目标明确,可以直接执行;只有目标不明确时才先追问

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
---
name: lark-base
version: 1.2.0
version: 1.2.1
description: "当需要用 lark-cli 操作飞书多维表格Base时调用搜索 Base、建表、字段管理、记录读写、记录分享链接、视图配置、历史查询以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
metadata:
requires:
@@ -40,7 +40,7 @@ metadata:
1. 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
2. Base 业务命令仅使用 `lark-cli base +...` 形式的 shortcut 命令。
3. 如果输入是 Wiki 链接或 Wiki token并且用户想读取/操作其中的 Base先执行 `lark-cli wiki +node-get --token <wiki_url_or_token>`;当返回 `data.obj_type=bitable` 时,把 `data.obj_token` 当作 `--base-token`。不要把 URL 里的 `/wiki/{token}` 当成 Base token。
3. 如果输入是 Wiki 链接或 Wiki token并且用户想读取/操作其中的 Base先执行 `lark-cli wiki +node-get --node-token <wiki_url_or_token>`;当返回 `data.obj_type=bitable` 时,把 `data.obj_token` 当作 `--base-token`。不要把 URL 里的 `/wiki/{token}` 当成 Base token。(旧的 `--token` flag 仍可用,但已 deprecated会在 stderr 打印迁移提示。)
4. 定位到命令后,先读该命令对应的 reference再执行命令。
5. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
6. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`
@@ -216,6 +216,7 @@ metadata:
|----------|------|-----------------------------------------------------------|------|
| 存储字段 | 真实存用户输入的数据 | 可以 | 常见如文本、数字、日期、单选、多选、人员、关联 |
| 附件字段 | 存储文件附件 | 不应直接按普通字段写 | 上传附件走 `+record-upload-attachment`;下载附件走 `+record-download-attachment`;删除附件走 `+record-remove-attachment` |
| 地理位置字段 | 存储坐标并由平台解析地址 | 可以 | 写入必须使用 `{lng,lat}`;读取、筛选和转文本等场景使用 `full_address` 字符串;只有公式能访问坐标 |
| 系统字段 | 平台自动维护 | 不可以 | 常见如创建时间、更新时间、创建人、修改人、自动编号 |
| `formula` 字段 | 通过表达式计算 | 不可以 | 只读字段 |
| `lookup` 字段 | 通过跨表规则查找引用 | 不可以 | 只读字段 |
@@ -230,6 +231,7 @@ metadata:
| 读取原始记录明细 / 关键词检索 / 导出 | `+record-search / +record-list / +record-get` | 不要拿 `+data-query` 当取数命令 |
| 上传附件到记录 | `+record-upload-attachment` | 不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
| 下载记录里的附件文件 | `+record-download-attachment --record-id <record_id> --output <dir>`,可加 `--file-token <file_token>` 只下指定附件 | Base 附件必须用这个命令下载;用其他下载入口可能失败 |
| 写入地理位置 | `+record-upsert` / `+record-batch-*``{lng,lat}` | 不要把纯地址文本当成 CellValue |
| 基于视图做筛选读取 | `+view-set-filter` + `+record-list` | 不要跳过视图筛选直接猜条件 |
| 本地 Excel / CSV / `.base` 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create``+table-create``+record-upsert` |
@@ -264,7 +266,7 @@ metadata:
Wiki Base fast path:
```bash
BASE_TOKEN="$(lark-cli wiki +node-get --as user --token "<wiki_url_or_token>" --jq '.data | select(.obj_type == "bitable") | .obj_token')"
BASE_TOKEN="$(lark-cli wiki +node-get --as user --node-token "<wiki_url_or_token>" --jq '.data | select(.obj_type == "bitable") | .obj_token')"
```
| `lark-cli wiki +node-get` 返回的 `data.obj_type` | 后续路线 | 说明 |
@@ -350,7 +352,7 @@ lark-cli auth login --domain base
| `1254066` | 人员字段错误 | `[{ "id": "ou_xxx" }]` |
| `1254045` | 字段名不存在 | 检查字段名(含空格、大小写) |
| `1254015` | 字段值类型不匹配 | 先 `+field-list`,再按类型构造 |
| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki +node-get --token <wiki_url_or_token>` 取真实 `data.obj_token`;当 `data.obj_type=bitable` 时,用 `data.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` |
| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki +node-get --node-token <wiki_url_or_token>` 取真实 `data.obj_token`;当 `data.obj_type=bitable` 时,用 `data.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` |
| `not found` 且用户给的是 wiki 链接 | 常见于把 wiki token 当成 base token | 优先回退检查 wiki 解析,而不是改走 `bitable/v1` |
| formula / lookup 创建失败 | 指南未读或结构不合法 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 |
| `ignored_fields` / `READONLY` | 只读字段被当成可写字段常见于系统字段、formula、lookup | 移除只读字段,只写存储字段;计算结果交给 formula / lookup / 系统字段自动产出 |

View File

@@ -106,7 +106,7 @@
### 2.8 location
对象 `{lng, lat}`,两者都是数字;`lng` 是经度,`lat` 是纬度。
写入对象必须使`{lng, lat}`,两者都是数字;`lng` 是经度,`lat` 是纬度。不需要手动传 `full_address`,平台会根据坐标解析地址。
```json
{
@@ -117,6 +117,8 @@
}
```
读取、筛选、转文本等场景使用 `full_address` 字符串;只有公式能访问坐标。如果用户只给地址文本,先获取或确认坐标后再写入;不要把仅有地址文本直接当作 location CellValue。
### 2.9 attachment不作为普通 CellValue 写入)
- 追加附件:使用 `lark-cli base +record-upload-attachment --record-id <record_id> --field-id <field_id> --file <path>`;可重复 `--file` 一次追加多个附件,不能用普通记录操作接口写附件值。

View File

@@ -277,6 +277,7 @@ POST /open-apis/base/v3/bases/:base_token/data/query
| `isEmpty` / `isNotEmpty` | `[]` | 0 个 | `[]` |
> **不支持** `isGreater` / `isGreaterEqual` / `isLess` / `isLessEqual`:地理位置无自然顺序。
> location 按 `full_address` 字符串筛选,不支持经纬度空间筛选;查城市/片区时优先用 `contains`,避免用 `is` 匹配短地址词。
*`checkbox`*

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