mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
23 Commits
eval-searc
...
v1.0.39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce5b4f24e1 | ||
|
|
4b2223194b | ||
|
|
4582dfd281 | ||
|
|
5c01a7f7f0 | ||
|
|
d5d2fee848 | ||
|
|
ffcf7781b4 | ||
|
|
fbe4cc689a | ||
|
|
ac85c3e34d | ||
|
|
daba3c9afd | ||
|
|
e54220ade1 | ||
|
|
d3fbc88527 | ||
|
|
652e96906c | ||
|
|
6cea6c9af0 | ||
|
|
816927f8b8 | ||
|
|
56749e70cb | ||
|
|
8c700aea00 | ||
|
|
42746d6c9d | ||
|
|
94b103dbf6 | ||
|
|
e19e09019c | ||
|
|
3bab9a0692 | ||
|
|
6840bb7415 | ||
|
|
ce485eb3f5 | ||
|
|
c98a49f2a3 |
71
CHANGELOG.md
71
CHANGELOG.md
@@ -2,6 +2,72 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.39] - 2026-05-22
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+export` shortcut to export slides (#988)
|
||||
- **sidecar**: Support multi-client identity isolation in `server-demo` via per-client HMAC keys, preventing UAT cross-contamination when multiple CLI sandboxes share one sidecar (#934)
|
||||
- **im**: Support Markdown image rendering in post content (#893)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scope**: Add 22 new scope entries to scope priorities (#1050)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Update location `full_address` guidance (#754)
|
||||
- **apps**: Refine `lark-apps` skill description and surface, document `index.html` / `--path` hard constraints (#1040)
|
||||
|
||||
## [v1.0.38] - 2026-05-22
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Gate the Miaoda apps domain off on the Lark brand — the `apps` shortcut subtree returns a structured brand-restriction error, `auth login --domain apps` is rejected, `--domain all` skips it, and `spark:*` scopes are no longer requested (#1025)
|
||||
|
||||
## [v1.0.37] - 2026-05-21
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add miaoda apps domain with 6 shortcuts covering `+create` / `+update` / `+list` / `+access-scope-get` / `+access-scope-set` / `+html-publish` (#1002)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **permission**: Surface auto-grant skipped/failed cases via stderr warnings and a `hint` field in the `permission_grant` JSON output (#1015)
|
||||
- **sheets**: Use `FileIO` for `+write-image` input so stdin / `-` works consistently (#996)
|
||||
|
||||
## [v1.0.36] - 2026-05-21
|
||||
|
||||
### Features
|
||||
|
||||
- **drive/markdown**: Return real tenant URLs for `drive +upload` and `markdown +create` (#992)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **auth**: Return validation error when `--scope` is empty in `auth check` (#999)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **lark-drive**: Improve search evidence guidance (#864)
|
||||
|
||||
## [v1.0.35] - 2026-05-20
|
||||
|
||||
### Features
|
||||
|
||||
- **markdown**: Support wiki node target in `+create` (#883)
|
||||
- **markdown**: Add `+diff` shortcut (#876)
|
||||
- **base**: Add form `+detail` / `+submit` shortcuts (#759)
|
||||
- **skills**: Add incremental skills sync (#965)
|
||||
- **doc**: Warn before overwrite when document contains whiteboard or file blocks (#825)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Clarify media key formats for message media flags (#991)
|
||||
- **im**: Add media-preview reference (#990)
|
||||
- **drive**: Migrate `docs +search` to `drive +search` and fix `creator_ids` owner semantic (#951)
|
||||
- **drive**: Prefer local comments for drive reviews (#981)
|
||||
- **wiki**: Add wiki base fast path (#982)
|
||||
|
||||
## [v1.0.34] - 2026-05-19
|
||||
|
||||
### Features
|
||||
@@ -774,6 +840,11 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
|
||||
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38
|
||||
[v1.0.37]: https://github.com/larksuite/cli/releases/tag/v1.0.37
|
||||
[v1.0.36]: https://github.com/larksuite/cli/releases/tag/v1.0.36
|
||||
[v1.0.35]: https://github.com/larksuite/cli/releases/tag/v1.0.35
|
||||
[v1.0.34]: https://github.com/larksuite/cli/releases/tag/v1.0.34
|
||||
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33
|
||||
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 26 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/)
|
||||
- **Wide Coverage** — 18 business domains, 200+ curated commands, 26 AI Agent [Skills](./skills/)
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
||||
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||
@@ -41,6 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
||||
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
|
||||
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 26 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 26 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 18 大业务域、200+ 精选命令、26 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -41,6 +41,7 @@
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
|
||||
@@ -47,8 +47,7 @@ func authCheckRun(opts *CheckOptions) error {
|
||||
|
||||
required := strings.Fields(opts.Scope)
|
||||
if len(required) == 0 {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": true, "granted": []string{}, "missing": []string{}})
|
||||
return nil
|
||||
return output.ErrValidation("--scope cannot be empty")
|
||||
}
|
||||
|
||||
config, err := f.Config()
|
||||
|
||||
@@ -68,7 +68,13 @@ run --device-code in a later step after the user confirms authorization.`,
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
available := sortedKnownDomains()
|
||||
var helpBrand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
helpBrand = cfg.Brand
|
||||
}
|
||||
}
|
||||
available := sortedKnownDomains(helpBrand)
|
||||
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
|
||||
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
|
||||
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
|
||||
@@ -139,14 +145,14 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
// Expand --domain all to all available domains (from_meta projects + shortcut services)
|
||||
for _, d := range selectedDomains {
|
||||
if strings.EqualFold(d, "all") {
|
||||
selectedDomains = sortedKnownDomains()
|
||||
selectedDomains = sortedKnownDomains(config.Brand)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domain names and suggest corrections for unknown ones
|
||||
if len(selectedDomains) > 0 {
|
||||
knownDomains := allKnownDomains()
|
||||
knownDomains := allKnownDomains(config.Brand)
|
||||
for _, d := range selectedDomains {
|
||||
if !knownDomains[d] {
|
||||
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
|
||||
@@ -170,7 +176,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
if !hasAnyOption {
|
||||
if !opts.JSON && f.IOStreams.IsTerminal {
|
||||
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
|
||||
result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -208,10 +214,10 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
if len(selectedDomains) > 0 || opts.Recommend {
|
||||
var candidateScopes []string
|
||||
if len(selectedDomains) > 0 {
|
||||
candidateScopes = collectScopesForDomains(selectedDomains, "user")
|
||||
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
|
||||
} else {
|
||||
// --recommend without --domain: all domains
|
||||
candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user")
|
||||
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
|
||||
}
|
||||
|
||||
// Filter to auto-approve scopes if --recommend or interactive "common"
|
||||
@@ -490,7 +496,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
|
||||
// shortcut scopes for the given domain names.
|
||||
// Domains with auth_domain children are automatically expanded to include
|
||||
// their children's scopes.
|
||||
func collectScopesForDomains(domains []string, identity string) []string {
|
||||
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
|
||||
scopeSet := make(map[string]bool)
|
||||
|
||||
// 1. API scopes from from_meta projects
|
||||
@@ -509,6 +515,9 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
continue
|
||||
}
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.DeclaredScopesForIdentity(identity) {
|
||||
scopeSet[s] = true
|
||||
@@ -528,7 +537,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
// allKnownDomains returns all valid auth domain names (from_meta projects +
|
||||
// shortcut services), excluding domains that have auth_domain set (they are
|
||||
// folded into their parent domain).
|
||||
func allKnownDomains() map[string]bool {
|
||||
func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
if !registry.HasAuthDomain(p) {
|
||||
@@ -536,6 +545,9 @@ func allKnownDomains() map[string]bool {
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
continue
|
||||
}
|
||||
if !registry.HasAuthDomain(sc.Service) {
|
||||
domains[sc.Service] = true
|
||||
}
|
||||
@@ -544,8 +556,8 @@ func allKnownDomains() map[string]bool {
|
||||
}
|
||||
|
||||
// sortedKnownDomains returns all valid domain names sorted alphabetically.
|
||||
func sortedKnownDomains() []string {
|
||||
m := allKnownDomains()
|
||||
func sortedKnownDomains(brand core.LarkBrand) []string {
|
||||
m := allKnownDomains(brand)
|
||||
domains := make([]string, 0, len(m))
|
||||
for d := range m {
|
||||
domains = append(domains, d)
|
||||
|
||||
32
cmd/auth/login_brand_filter_test.go
Normal file
32
cmd/auth/login_brand_filter_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
|
||||
feishuDomains := allKnownDomains(core.BrandFeishu)
|
||||
if !feishuDomains["apps"] {
|
||||
t.Errorf("expected apps domain to be known on Feishu brand")
|
||||
}
|
||||
|
||||
larkDomains := allKnownDomains(core.BrandLark)
|
||||
if larkDomains["apps"] {
|
||||
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
|
||||
}
|
||||
|
||||
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
|
||||
if len(feishuScopes) == 0 {
|
||||
t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes))
|
||||
}
|
||||
|
||||
larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark)
|
||||
if len(larkScopes) != 0 {
|
||||
t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
@@ -105,7 +106,7 @@ func buildDomainMeta(name, lang string) domainMeta {
|
||||
}
|
||||
|
||||
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
|
||||
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*interactiveResult, error) {
|
||||
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
|
||||
allDomains := getDomainMetadata(lang)
|
||||
|
||||
// Build multi-select options
|
||||
@@ -165,7 +166,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i
|
||||
}
|
||||
|
||||
// Compute scope summary
|
||||
scopes := collectScopesForDomains(selectedDomains, "user")
|
||||
scopes := collectScopesForDomains(selectedDomains, "user", brand)
|
||||
if permLevel == "common" {
|
||||
scopes = registry.FilterAutoApproveScopes(scopes)
|
||||
}
|
||||
|
||||
@@ -125,5 +125,5 @@ func getLoginMsg(lang string) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs", "markdown"}
|
||||
return []string{"base", "contact", "docs", "markdown", "apps"}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllKnownDomains(t *testing.T) {
|
||||
domains := allKnownDomains()
|
||||
domains := allKnownDomains("")
|
||||
if len(domains) == 0 {
|
||||
t.Fatal("expected non-empty known domains")
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func TestAllKnownDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSortedKnownDomains(t *testing.T) {
|
||||
sorted := sortedKnownDomains()
|
||||
sorted := sortedKnownDomains("")
|
||||
if len(sorted) == 0 {
|
||||
t.Fatal("expected non-empty sorted domains")
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func TestSortedKnownDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should match allKnownDomains
|
||||
known := allKnownDomains()
|
||||
known := allKnownDomains("")
|
||||
if len(sorted) != len(known) {
|
||||
t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known))
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func TestCollectScopesForDomains(t *testing.T) {
|
||||
t.Skip("no from_meta data available")
|
||||
}
|
||||
|
||||
scopes := collectScopesForDomains([]string{"calendar"}, "user")
|
||||
scopes := collectScopesForDomains([]string{"calendar"}, "user", "")
|
||||
if len(scopes) == 0 {
|
||||
t.Fatal("expected non-empty scopes for calendar domain")
|
||||
}
|
||||
@@ -247,7 +247,7 @@ func TestCollectScopesForDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user")
|
||||
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user", "")
|
||||
if len(scopes) != 0 {
|
||||
t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes))
|
||||
}
|
||||
@@ -1077,7 +1077,7 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := allKnownDomains()
|
||||
domains := allKnownDomains("")
|
||||
if domains["whiteboard"] {
|
||||
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
|
||||
}
|
||||
@@ -1087,7 +1087,7 @@ func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"docs"}, "user")
|
||||
scopes := collectScopesForDomains([]string{"docs"}, "user", "")
|
||||
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
|
||||
found := false
|
||||
for _, s := range scopes {
|
||||
|
||||
@@ -536,8 +536,11 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that missing state
|
||||
// produces no skills key in the composed notice.
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
|
||||
// produces no skills key in the composed notice. Users who installed
|
||||
// skills via `npx skills add` (no stamp) must not see the misleading
|
||||
// "not installed" notice — only `lark-cli update` users opt into the
|
||||
// drift tracker.
|
||||
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
@@ -568,13 +571,13 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_InSync verifies that matching state produces no
|
||||
// TestSetupNotices_InSync verifies that a matching stamp produces no
|
||||
// skills key in the composed notice.
|
||||
func TestSetupNotices_InSync(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -601,13 +604,13 @@ func TestSetupNotices_InSync(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_Drift verifies mismatching state produces the
|
||||
// TestSetupNotices_Drift verifies a mismatching stamp produces the
|
||||
// drift message with both current and target populated.
|
||||
func TestSetupNotices_Drift(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -656,7 +659,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,12 +31,11 @@ var (
|
||||
currentVersion = func() string { return build.Version }
|
||||
currentOS = runtime.GOOS
|
||||
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
|
||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { return skillscheck.SyncSkills(opts) }
|
||||
)
|
||||
|
||||
func isWindows() bool { return currentOS == osWindows }
|
||||
|
||||
// normalizeVersion canonicalizes a version string for state comparison.
|
||||
// normalizeVersion canonicalizes a version string for stamp comparison.
|
||||
// Strips a leading "v" so versions written from Makefile (git describe →
|
||||
// "v1.0.0") and npm (no prefix → "1.0.0") compare equal.
|
||||
func normalizeVersion(s string) string {
|
||||
@@ -122,9 +121,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
if !opts.Check {
|
||||
updater.CleanupStaleFiles()
|
||||
}
|
||||
updater.CleanupStaleFiles()
|
||||
output.PendingNotice = nil
|
||||
|
||||
// 1. Fetch latest version
|
||||
@@ -140,9 +137,13 @@ func updateRun(opts *UpdateOptions) error {
|
||||
|
||||
// 3. Compare versions
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
var skillsResult *skillscheck.SyncResult
|
||||
// Run skills sync before returning — covers the case where the
|
||||
// binary is already current but skills were never synced.
|
||||
// Stamp dedup makes this a no-op if skills are already in sync.
|
||||
// Skip side-effects under --check (pure report path per spec §3.6).
|
||||
var skillsResult *selfupdate.NpmResult
|
||||
if !opts.Check {
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
|
||||
skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||
}
|
||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
@@ -184,7 +185,16 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
}
|
||||
applySkillsStatus(out, cur)
|
||||
// skills_status: pure report, no side effect, no stamp write.
|
||||
// ReadStamp errors are silently swallowed — if we can't read the
|
||||
// stamp we just omit the block rather than fail the --check.
|
||||
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||
out["skills_status"] = map[string]interface{}{
|
||||
"current": stamp,
|
||||
"target": cur,
|
||||
"in_sync": stamp == cur,
|
||||
}
|
||||
}
|
||||
output.PrintJson(io.Out, out)
|
||||
return nil
|
||||
}
|
||||
@@ -200,7 +210,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
|
||||
skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
@@ -278,7 +288,10 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
|
||||
// Skills update (best-effort) — uses runSkillsAndStamp so the
|
||||
// stamp gets persisted on success and dedup applies if a previous
|
||||
// run already stamped this version.
|
||||
skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force)
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
@@ -315,21 +328,27 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
|
||||
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
|
||||
// stamp on success. Skips the npx invocation when the stamp already
|
||||
// matches stampVersion (unless force is true). The stamp write failure
|
||||
// emits a warning to io.ErrOut but does NOT fail the update command —
|
||||
// best-effort. ReadStamp errors are swallowed (fail-closed: treated as
|
||||
// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp
|
||||
// dedup; otherwise returns the underlying *NpmResult with Err semantics
|
||||
// from RunSkillsUpdate.
|
||||
func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult {
|
||||
if !force {
|
||||
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
result := syncSkills(skillscheck.SyncOptions{
|
||||
Version: stateVersion,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
})
|
||||
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
|
||||
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
|
||||
r := updater.RunSkillsUpdate()
|
||||
if r.Err == nil {
|
||||
if err := skillscheck.WriteStamp(stampVersion); err != nil {
|
||||
fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err)
|
||||
}
|
||||
}
|
||||
return result
|
||||
return r
|
||||
}
|
||||
|
||||
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
||||
@@ -337,7 +356,7 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
|
||||
// fields derived from skillsResult. When check is true, this is the pure
|
||||
// report path (spec §3.6): no side-effects, JSON envelope uses
|
||||
// skills_status (spec §4.2) instead of skills_action.
|
||||
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error {
|
||||
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error {
|
||||
if opts.JSON {
|
||||
out := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
@@ -345,7 +364,16 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
|
||||
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
|
||||
}
|
||||
if check {
|
||||
applySkillsStatus(out, cur)
|
||||
// Pure report — read stamp directly, emit skills_status block.
|
||||
// ReadStamp errors are silently swallowed — if we can't read
|
||||
// the stamp we just omit the block rather than fail the --check.
|
||||
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||
out["skills_status"] = map[string]interface{}{
|
||||
"current": stamp,
|
||||
"target": cur,
|
||||
"in_sync": stamp == cur,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
applySkillsResult(out, skillsResult)
|
||||
}
|
||||
@@ -359,70 +387,36 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
|
||||
return nil
|
||||
}
|
||||
|
||||
func applySkillsStatus(env map[string]interface{}, target string) {
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable || state.Version == "" {
|
||||
return
|
||||
}
|
||||
status := map[string]interface{}{
|
||||
"current": state.Version,
|
||||
"target": target,
|
||||
"in_sync": normalizeVersion(state.Version) == normalizeVersion(target),
|
||||
}
|
||||
if len(state.OfficialSkills) > 0 {
|
||||
status["official"] = len(state.OfficialSkills)
|
||||
}
|
||||
if len(state.UpdatedSkills) > 0 {
|
||||
status["updated"] = len(state.UpdatedSkills)
|
||||
}
|
||||
if len(state.SkippedDeletedSkills) > 0 {
|
||||
status["skipped_deleted"] = state.SkippedDeletedSkills
|
||||
}
|
||||
env["skills_status"] = status
|
||||
}
|
||||
|
||||
func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
|
||||
// applySkillsResult mutates the JSON envelope to include skills_action
|
||||
// (and skills_warning when failed). nil result = "in_sync" (dedup hit).
|
||||
func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) {
|
||||
switch {
|
||||
case r == nil:
|
||||
env["skills_action"] = "in_sync"
|
||||
case r.Err != nil:
|
||||
env["skills_action"] = "failed"
|
||||
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||
env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
|
||||
}
|
||||
default:
|
||||
env["skills_action"] = "synced"
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
}
|
||||
}
|
||||
|
||||
func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
|
||||
summary := map[string]interface{}{
|
||||
"official": len(r.Official),
|
||||
"updated": len(r.Updated),
|
||||
"added": len(r.Added),
|
||||
"skipped_deleted": len(r.SkippedDeleted),
|
||||
}
|
||||
if len(r.Failed) > 0 {
|
||||
summary["failed"] = r.Failed
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
|
||||
// emitSkillsTextHints prints human-readable feedback about the skills
|
||||
// sync result for non-JSON output.
|
||||
func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) {
|
||||
switch {
|
||||
case r == nil:
|
||||
// dedup hit — silent (already up to date)
|
||||
case r.Err != nil:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err)
|
||||
if len(r.Failed) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
|
||||
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
|
||||
case r.Force:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
|
||||
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
|
||||
default:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
|
||||
if len(r.SkippedDeleted) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -26,6 +28,7 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
|
||||
}
|
||||
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
|
||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
@@ -38,34 +41,22 @@ func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
}
|
||||
|
||||
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
|
||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(string) *selfupdate.NpmResult) {
|
||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
|
||||
npmFn func(string) *selfupdate.NpmResult,
|
||||
skillsFn func() *selfupdate.NpmResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.NpmInstallOverride = npmFn
|
||||
u.SkillsUpdateOverride = skillsFn
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
return func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
switch strings.Join(args, " ") {
|
||||
case "-y skills add https://open.feishu.cn --list":
|
||||
r.Stdout.WriteString("lark-calendar\nlark-mail\n")
|
||||
case "-y skills ls -g":
|
||||
r.Stdout.WriteString("lark-calendar\ncustom-skill\n")
|
||||
default:
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
|
||||
@@ -177,7 +168,9 @@ func TestUpdateManual_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateNpm_JSON(t *testing.T) {
|
||||
// Isolate config dir because skills sync writes skills-state.json.
|
||||
// Isolate config dir: this test mocks fetchLatest="2.0.0" and lets
|
||||
// runSkillsAndStamp → WriteStamp succeed, which without isolation would
|
||||
// clobber the real ~/.lark-cli/skills.stamp with "2.0.0".
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -193,6 +186,7 @@ func TestUpdateNpm_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -222,6 +216,7 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -235,7 +230,7 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -251,6 +246,7 @@ func TestUpdateForce_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -327,7 +323,7 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -343,6 +339,7 @@ func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -454,8 +451,8 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||
u.RestoreAvailableOverride = func() bool { return false }
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
t.Fatal("skills sync should not run when binary verification fails")
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
t.Fatal("skills update should not run when binary verification fails")
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
@@ -652,7 +649,7 @@ func TestPermissionHint(t *testing.T) {
|
||||
|
||||
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
// With the rename trick, Windows npm installs can now auto-update.
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -671,6 +668,7 @@ func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -752,6 +750,7 @@ func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -786,7 +785,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
// Skills update fails
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
@@ -812,8 +812,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
if !strings.Contains(out, "skills_warning") {
|
||||
t.Errorf("expected skills_warning in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "skills_summary") {
|
||||
t.Errorf("expected skills_summary in output, got: %s", out)
|
||||
if !strings.Contains(out, "skills_detail") {
|
||||
t.Errorf("expected skills_detail in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,7 +838,7 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
@@ -861,96 +861,100 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
if !strings.Contains(out, "Skills update failed") {
|
||||
t.Errorf("expected skills failure warning, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "lark-cli update --force") {
|
||||
t.Errorf("expected force retry hint, got: %s", out)
|
||||
if !strings.Contains(out, "npx -y skills add") {
|
||||
t.Errorf("expected manual skills command hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers.
|
||||
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable
|
||||
// for direct calls to internals like runSkillsAndStamp that write to
|
||||
// io.ErrOut.
|
||||
func newTestIO() *cmdutil.IOStreams {
|
||||
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
func TestRunSkillsAndStamp_DedupHit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
called = true
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
if got != nil {
|
||||
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
|
||||
t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got)
|
||||
}
|
||||
if called {
|
||||
t.Error("SkillsCommandOverride called, want skipped due to dedup")
|
||||
t.Error("SkillsUpdateOverride called, want skipped due to dedup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
func TestRunSkillsAndStamp_DedupForceBypass(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
called = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true)
|
||||
if got == nil {
|
||||
t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil")
|
||||
}
|
||||
if !called {
|
||||
t.Error("SkillsCommandOverride not called with force=true")
|
||||
t.Error("SkillsUpdateOverride not called with force=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
func TestRunSkillsAndStamp_SuccessWritesStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
func TestRunSkillsAndStamp_FailureKeepsOldStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("npx failed")
|
||||
return r
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got)
|
||||
}
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.20\" (failure must not overwrite)", state.Version)
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.20" {
|
||||
t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -969,7 +973,8 @@ func TestTruncate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -982,9 +987,9 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -995,19 +1000,17 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("skills sync not called in already-up-to-date branch")
|
||||
t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called")
|
||||
}
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -1026,9 +1029,9 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
ResolvedPath: "/usr/local/bin/lark-cli",
|
||||
}
|
||||
},
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1039,19 +1042,17 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("skills sync not called in manual branch")
|
||||
t.Error("RunSkillsUpdate not called in manual branch, want called")
|
||||
}
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\" (manual path records current binary)", state.Version)
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -1074,9 +1075,9 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
VerifyOverride: func(expectedVersion string) error { return nil },
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1087,25 +1088,18 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("skills sync not called in npm branch")
|
||||
t.Error("RunSkillsUpdate not called in npm branch")
|
||||
}
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.22" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.22\" (npm path records latest binary)", state.Version)
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.22" {
|
||||
t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{
|
||||
Version: "1.0.20",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail"},
|
||||
UpdatedSkills: []string{"lark-calendar"},
|
||||
SkippedDeletedSkills: []string{"lark-mail"},
|
||||
}); err != nil {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1123,9 +1117,9 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
||||
},
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1136,7 +1130,7 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
t.Fatalf("updateRun(--check) err = %v, want nil", err)
|
||||
}
|
||||
if skillsCalled {
|
||||
t.Error("skills sync called under --check, want skipped")
|
||||
t.Error("RunSkillsUpdate called under --check, want skipped (pure report)")
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
@@ -1150,14 +1144,12 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false {
|
||||
t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status)
|
||||
}
|
||||
if status["official"] != float64(2) || status["updated"] != float64(1) {
|
||||
t.Errorf("skills_status counts = %+v, want official:2 updated:1", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1172,9 +1164,9 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1185,15 +1177,12 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err)
|
||||
}
|
||||
if skillsCalled {
|
||||
t.Error("skills sync called under --check (already-latest), want skipped")
|
||||
t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)")
|
||||
}
|
||||
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version mutated to %q under --check, want \"1.0.20\"", state.Version)
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.20" {
|
||||
t.Errorf("stamp mutated to %q under --check, want \"1.0.20\" (pure report must not write stamp)", stamp)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
@@ -1215,26 +1204,38 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
|
||||
origSync := syncSkills
|
||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
|
||||
return &skillscheck.SyncResult{Err: fmt.Errorf("skills synced but state not written: denied")}
|
||||
// TestRunSkillsAndStamp_StampWriteFailureWarns verifies the stderr warning
|
||||
// emission when RunSkillsUpdate succeeds but WriteStamp fails.
|
||||
func TestRunSkillsAndStamp_StampWriteFailureWarns(t *testing.T) {
|
||||
// Force WriteStamp to fail by pointing config dir at a path that exists
|
||||
// as a regular file (so MkdirAll fails).
|
||||
tmp := t.TempDir()
|
||||
badPath := filepath.Join(tmp, "blocker")
|
||||
if err := os.WriteFile(badPath, []byte("not-a-dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { syncSkills = origSync })
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath)
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
return &selfupdate.NpmResult{} // success
|
||||
},
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: skills synced but state not written") {
|
||||
got := runSkillsAndStamp(updater, f.IOStreams, "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: skills synced but stamp not written") {
|
||||
t.Errorf("stderr does not contain warning: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitSkillsTextHints_Success verifies the "Skills updated" success
|
||||
// message is printed to ErrOut on a successful (Err == nil) result.
|
||||
func TestEmitSkillsTextHints_Success(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
emitSkillsTextHints(f.IOStreams, &skillscheck.SyncResult{Official: []string{"lark-calendar"}, Updated: []string{"lark-calendar"}})
|
||||
emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success
|
||||
if !strings.Contains(stderr.String(), "Skills updated") {
|
||||
t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String())
|
||||
}
|
||||
|
||||
@@ -5568,5 +5568,115 @@
|
||||
"scope_name": "speech_to_text:speech",
|
||||
"final_score": "70.8755",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app:publish",
|
||||
"final_score": "75.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app.access_scope:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app.access_scope:write",
|
||||
"final_score": "83.6587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app:write",
|
||||
"final_score": "76.7173",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "docs:secure_label:write_only",
|
||||
"final_score": "75.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "corehr:job_change_v2:read",
|
||||
"final_score": "75.9982",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "corehr:pre_hire.contract_file_id:read",
|
||||
"final_score": "80.0587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.user_setting:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "minutes:minutes.upload:write",
|
||||
"final_score": "83.6587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:feed.flag:write",
|
||||
"final_score": "79.5982",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:feed.flag:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "search:bot",
|
||||
"final_score": "67.0587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "application:bot.basic_info:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "drive:quota_detail:read_one",
|
||||
"final_score": "75.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "docs:permission.member:apply",
|
||||
"final_score": "83.6587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "corehr:employment.custom_field:write",
|
||||
"final_score": "75.6587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:message.group_at_msg.include_bot:readonly",
|
||||
"final_score": "88.9982",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "okr:okr.setting:read",
|
||||
"final_score": "80.0587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "directory:employee.base.leader_id:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "directory:employee.base.dotted_line_leaders:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "directory:employee.base.active_status:read",
|
||||
"final_score": "80.0587",
|
||||
"recommend": "false"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"en": { "title": "Approval", "description": "Approval instance, and task management" },
|
||||
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
|
||||
},
|
||||
"apps": {
|
||||
"en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" },
|
||||
"zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" }
|
||||
},
|
||||
"base": {
|
||||
"en": { "title": "Base", "description": "Table, field, record, view, dashboard, workflow, form, role & permission management" },
|
||||
"zh": { "title": "多维表格", "description": "数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限管理" }
|
||||
|
||||
@@ -84,7 +84,6 @@ type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
SkillsUpdateOverride func() *NpmResult
|
||||
SkillsCommandOverride func(args ...string) *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
RestoreAvailableOverride func() bool
|
||||
|
||||
@@ -167,46 +166,7 @@ func (u *Updater) RunSkillsUpdate() *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkills() *NpmResult {
|
||||
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsListOfficial("larksuite/cli")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListGlobalSkills() *NpmResult {
|
||||
return u.runSkillsListGlobal()
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSkill(name string) *NpmResult {
|
||||
r := u.runSkillsInstall("https://open.feishu.cn", name)
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsInstall("larksuite/cli", name)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsListOfficial(source string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "--list")
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsListGlobal() *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "ls", "-g")
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsInstall(source string, name string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "-s", name, "-g", "-y")
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||
if u.SkillsCommandOverride != nil {
|
||||
return u.SkillsCommandOverride(args...)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
npxPath, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
@@ -215,7 +175,7 @@ func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
||||
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -167,87 +166,3 @@ func TestVerifyBinaryEmptyOutput(t *testing.T) {
|
||||
t.Fatal("VerifyBinary(empty output) expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(*Updater) *NpmResult
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "list official primary",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn --list",
|
||||
},
|
||||
{
|
||||
name: "list global",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsListGlobal()
|
||||
},
|
||||
want: "-y skills ls -g",
|
||||
},
|
||||
{
|
||||
name: "install skill primary",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsInstall("https://open.feishu.cn", "lark-mail")
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell script")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "npx")
|
||||
logPath := filepath.Join(dir, "npx.log")
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \""+logPath+"\"\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
result := tt.run(New())
|
||||
if result.Err != nil {
|
||||
t.Fatalf("command err = %v, want nil", result.Err)
|
||||
}
|
||||
raw, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(string(raw)) != tt.want {
|
||||
t.Fatalf("args = %q, want %q", strings.TrimSpace(string(raw)), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
called := []string{}
|
||||
updater := &Updater{
|
||||
SkillsCommandOverride: func(args ...string) *NpmResult {
|
||||
called = append(called, strings.Join(args, " "))
|
||||
r := &NpmResult{}
|
||||
if strings.Contains(strings.Join(args, " "), "https://open.feishu.cn") {
|
||||
r.Err = fmt.Errorf("primary failed")
|
||||
return r
|
||||
}
|
||||
r.Stdout.WriteString("lark-calendar\n")
|
||||
return r
|
||||
},
|
||||
}
|
||||
|
||||
result := updater.ListOfficialSkills()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("ListOfficialSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if len(called) != 2 {
|
||||
t.Fatalf("called %d commands, want 2: %#v", len(called), called)
|
||||
}
|
||||
if !strings.Contains(called[1], "larksuite/cli --list") {
|
||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,29 +3,46 @@
|
||||
|
||||
package skillscheck
|
||||
|
||||
import "strings"
|
||||
|
||||
// Init runs the synchronous skills version check. Stores a StaleNotice when
|
||||
// the local skills state records a version that does not match currentVersion.
|
||||
// Safe to call from cmd/root.go before rootCmd.Execute(); zero network, zero
|
||||
// subprocess — only a local state file read.
|
||||
// Init runs the synchronous skills version check. Stores a StaleNotice
|
||||
// when the local stamp records a version that does not match
|
||||
// currentVersion. Safe to call from cmd/root.go before rootCmd.Execute();
|
||||
// zero network, zero subprocess — only a local stamp file read.
|
||||
//
|
||||
// Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver,
|
||||
// LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out).
|
||||
//
|
||||
// Failure modes (all → no notice, no nag):
|
||||
// - shouldSkip rule met
|
||||
// - ReadStamp returns an I/O error other than ENOENT
|
||||
// - Stamp matches currentVersion (in-sync)
|
||||
// - Stamp is missing (cold start) — only users who ran `lark-cli update`
|
||||
// opt into drift tracking; npx-only installs are intentionally silent.
|
||||
func Init(currentVersion string) {
|
||||
// Clear any stale notice from a prior call so early returns below
|
||||
// (skip rules / read errors / cold start / in-sync) leave pending == nil
|
||||
// instead of preserving a stale value from a previous Init invocation.
|
||||
SetPending(nil)
|
||||
if shouldSkip(currentVersion) {
|
||||
return
|
||||
}
|
||||
version, ok := ReadSyncedVersion()
|
||||
if !ok {
|
||||
stamp, err := ReadStamp()
|
||||
if err != nil {
|
||||
// Fail closed — don't nag for a transient FS problem.
|
||||
return
|
||||
}
|
||||
if strings.TrimPrefix(strings.TrimPrefix(version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") {
|
||||
if stamp == "" {
|
||||
// Cold start: the stamp is written exclusively by `lark-cli update`
|
||||
// (runSkillsAndStamp). Users who installed skills via
|
||||
// `npx skills add larksuite/cli -g` have no stamp yet — they must
|
||||
// not be nagged with "skills not installed", since the on-disk
|
||||
// skills directory may already be fully populated.
|
||||
return
|
||||
}
|
||||
if stamp == currentVersion {
|
||||
return
|
||||
}
|
||||
SetPending(&StaleNotice{
|
||||
Current: version,
|
||||
Current: stamp, // guaranteed non-empty under the new contract
|
||||
Target: currentVersion,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ func resetPending(t *testing.T) {
|
||||
func TestInit_InSync_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("1.0.21")
|
||||
@@ -38,24 +39,12 @@ func TestInit_ColdStart_NoNotice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_NormalizedVersion_NoNotice(t *testing.T) {
|
||||
func TestInit_Drift_NoticeWithStampVersion(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("v1.0.21")
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (normalized versions are in-sync)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_Drift_NoticeWithStateVersion(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := WriteState(SkillsState{Version: "1.0.20"}); err != nil {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("1.0.21")
|
||||
@@ -72,18 +61,22 @@ func TestInit_Skipped_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
// Even with an empty config dir (no stamp), DEV version should skip
|
||||
// the check entirely and never emit a notice.
|
||||
Init("DEV")
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (skip rules met)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_ReadStateError_FailsClosed(t *testing.T) {
|
||||
func TestInit_ReadStampError_FailsClosed(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "skills-state.json"), 0o755); err != nil {
|
||||
// Make the stamp path a directory so vfs.ReadFile returns a
|
||||
// non-ENOENT I/O error.
|
||||
if err := os.MkdirAll(filepath.Join(dir, "skills.stamp"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("1.0.21")
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
|
||||
// Package skillscheck verifies that the locally installed lark-cli
|
||||
// skills are in sync with the running binary version, by comparing
|
||||
// the current binary version against skills-state.json. On mismatch it
|
||||
// stores a notice for injection into JSON envelopes via output.PendingNotice.
|
||||
// the current binary version against a stamp file written when skills
|
||||
// are last synced (by `lark-cli update`). On mismatch it stores a
|
||||
// notice for injection into JSON envelopes via output.PendingNotice.
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
@@ -25,7 +26,8 @@ type StaleNotice struct {
|
||||
// Message returns a single-line, AI-agent-parseable description of the
|
||||
// drift plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
|
||||
// in style ("..., run: lark-cli update" suffix). Current is guaranteed
|
||||
// non-empty because Init only emits a StaleNotice for the drift case.
|
||||
// non-empty because Init only emits a StaleNotice for the drift case
|
||||
// (stamp present and != binary version).
|
||||
func (s *StaleNotice) Message() string {
|
||||
return fmt.Sprintf(
|
||||
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",
|
||||
|
||||
49
internal/skillscheck/stamp.go
Normal file
49
internal/skillscheck/stamp.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const stampFile = "skills.stamp"
|
||||
|
||||
// stampPath returns ~/.lark-cli/skills.stamp.
|
||||
// Uses the BASE config dir (not workspace-aware) because skills install
|
||||
// globally via `npx -g`; per-workspace tracking would produce false
|
||||
// drift signals when switching workspaces.
|
||||
func stampPath() string {
|
||||
return filepath.Join(core.GetBaseConfigDir(), stampFile)
|
||||
}
|
||||
|
||||
// ReadStamp returns the version recorded in the stamp file. Returns
|
||||
// ("", nil) when the file does not exist (interpreted as "never synced").
|
||||
// Other I/O errors are returned as-is so callers can fail closed.
|
||||
func ReadStamp() (string, error) {
|
||||
data, err := vfs.ReadFile(stampPath())
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
// WriteStamp records `version` as the last successfully synced skills
|
||||
// version. Atomic via tmp + rename (validate.AtomicWrite). Creates
|
||||
// the base config directory if it does not exist.
|
||||
func WriteStamp(version string) error {
|
||||
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(stampPath(), []byte(version), 0o644)
|
||||
}
|
||||
113
internal/skillscheck/stamp_test.go
Normal file
113
internal/skillscheck/stamp_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadStamp_Missing(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
got, err := ReadStamp()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadStamp() err = %v, want nil for ENOENT", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("ReadStamp() = %q, want \"\" for missing file", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStamp_Normal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadStamp()
|
||||
if err != nil || got != "1.0.21" {
|
||||
t.Errorf("ReadStamp() = (%q, %v), want (\"1.0.21\", nil)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStamp_TrailingNewlineTolerated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := ReadStamp()
|
||||
if got != "1.0.21" {
|
||||
t.Errorf("ReadStamp() = %q, want \"1.0.21\" (newline trimmed)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStamp_EmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte(""), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadStamp()
|
||||
if err != nil || got != "" {
|
||||
t.Errorf("ReadStamp() = (%q, %v), want (\"\", nil)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStamp_CreatesDir(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatalf("WriteStamp() = %v, want nil", err)
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
|
||||
if string(got) != "1.0.21" {
|
||||
t.Errorf("file content = %q, want \"1.0.21\"", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStamp_OverwritesExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := ReadStamp()
|
||||
if got != "1.0.21" {
|
||||
t.Errorf("ReadStamp() after overwrite = %q, want \"1.0.21\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStamp_NoTrailingNewline(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
|
||||
if string(raw) != "1.0.21" {
|
||||
t.Errorf("raw file = %q, want exactly \"1.0.21\" (no newline)", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteStamp_MkdirAllFailure verifies WriteStamp returns the mkdir error
|
||||
// when the base config dir cannot be created (parent path is a regular file).
|
||||
func TestWriteStamp_MkdirAllFailure(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
blocker := filepath.Join(tmp, "blocker")
|
||||
// Create a regular file where MkdirAll wants to create a directory.
|
||||
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Point the config dir at a path UNDER the regular file — MkdirAll must fail.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(blocker, "child"))
|
||||
|
||||
if err := WriteStamp("1.0.21"); err == nil {
|
||||
t.Fatal("WriteStamp() = nil, want non-nil error from MkdirAll failure")
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
stateFile = "skills-state.json"
|
||||
stateSchemaVersion = 1
|
||||
)
|
||||
|
||||
type SkillsState struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Version string `json:"version"`
|
||||
OfficialSkills []string `json:"official_skills"`
|
||||
UpdatedSkills []string `json:"updated_skills"`
|
||||
AddedSkills []string `json:"added_skills"`
|
||||
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func statePath() string {
|
||||
return filepath.Join(core.GetBaseConfigDir(), stateFile)
|
||||
}
|
||||
|
||||
func ReadState() (*SkillsState, bool, error) {
|
||||
data, err := vfs.ReadFile(statePath())
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var state SkillsState
|
||||
if json.Unmarshal(data, &state) != nil {
|
||||
state = SkillsState{}
|
||||
}
|
||||
if state.SchemaVersion != stateSchemaVersion {
|
||||
return nil, false, nil
|
||||
}
|
||||
return &state, true, nil
|
||||
}
|
||||
|
||||
func WriteState(state SkillsState) error {
|
||||
state.SchemaVersion = stateSchemaVersion
|
||||
state.ensureNonNilSlices()
|
||||
|
||||
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(statePath(), append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func ReadSyncedVersion() (string, bool) {
|
||||
state, ok, err := ReadState()
|
||||
if err != nil || !ok || state.Version == "" {
|
||||
return "", false
|
||||
}
|
||||
return state.Version, true
|
||||
}
|
||||
|
||||
func (s *SkillsState) ensureNonNilSlices() {
|
||||
if s.OfficialSkills == nil {
|
||||
s.OfficialSkills = []string{}
|
||||
}
|
||||
if s.UpdatedSkills == nil {
|
||||
s.UpdatedSkills = []string{}
|
||||
}
|
||||
if s.AddedSkills == nil {
|
||||
s.AddedSkills = []string{}
|
||||
}
|
||||
if s.SkippedDeletedSkills == nil {
|
||||
s.SkippedDeletedSkills = []string{}
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadState_Missing(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
state, ok, err := ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadState() err = %v, want nil for missing file", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("ReadState() ok = true, want false for missing file")
|
||||
}
|
||||
if state != nil {
|
||||
t.Fatalf("ReadState() state = %#v, want nil for missing file", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadState_Valid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
want := SkillsState{
|
||||
SchemaVersion: 1,
|
||||
Version: "1.2.3",
|
||||
OfficialSkills: []string{"lark-doc", "lark-im"},
|
||||
UpdatedSkills: []string{"lark-doc"},
|
||||
AddedSkills: []string{"lark-task"},
|
||||
SkippedDeletedSkills: []string{"custom-skill"},
|
||||
UpdatedAt: "2026-05-18T10:00:00Z",
|
||||
}
|
||||
data, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, stateFile), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, ok, err := ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadState() err = %v, want nil", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ReadState() ok = false, want true")
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("ReadState() state = nil, want state")
|
||||
}
|
||||
if !reflect.DeepEqual(*got, want) {
|
||||
t.Fatalf("ReadState() state = %#v, want %#v", *got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadState_CorruptOrUnknownSchemaUnreadable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{name: "corrupt json", data: []byte(`{"schema_version":`)},
|
||||
{name: "unknown schema", data: []byte(`{"schema_version":2,"version":"1.2.3"}`)},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, stateFile), tt.data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
state, ok, err := ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadState() err = %v, want nil", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("ReadState() ok = true, want false")
|
||||
}
|
||||
if state != nil {
|
||||
t.Fatalf("ReadState() state = %#v, want nil", state)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteState_CreatesDirAndWritesState(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
state := SkillsState{
|
||||
Version: "1.2.3",
|
||||
UpdatedAt: "2026-05-18T10:00:00Z",
|
||||
}
|
||||
if err := WriteState(state); err != nil {
|
||||
t.Fatalf("WriteState() err = %v, want nil", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(dir, stateFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got SkillsState
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("written state is invalid JSON: %v", err)
|
||||
}
|
||||
if got.SchemaVersion != 1 {
|
||||
t.Fatalf("schema_version = %d, want 1", got.SchemaVersion)
|
||||
}
|
||||
if got.Version != state.Version {
|
||||
t.Fatalf("version = %q, want %q", got.Version, state.Version)
|
||||
}
|
||||
if got.OfficialSkills == nil {
|
||||
t.Fatal("official_skills decoded as nil, want empty slice")
|
||||
}
|
||||
if got.UpdatedSkills == nil {
|
||||
t.Fatal("updated_skills decoded as nil, want empty slice")
|
||||
}
|
||||
if got.AddedSkills == nil {
|
||||
t.Fatal("added_skills decoded as nil, want empty slice")
|
||||
}
|
||||
if got.SkippedDeletedSkills == nil {
|
||||
t.Fatal("skipped_deleted_skills decoded as nil, want empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSyncedVersionFromState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
if got, ok := ReadSyncedVersion(); ok || got != "" {
|
||||
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for missing state", got, ok)
|
||||
}
|
||||
if err := WriteState(SkillsState{Version: "1.2.3"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, ok := ReadSyncedVersion(); !ok || got != "1.2.3" {
|
||||
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"1.2.3\", true)", got, ok)
|
||||
}
|
||||
if err := WriteState(SkillsState{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, ok := ReadSyncedVersion(); ok || got != "" {
|
||||
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for empty version", got, ok)
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
)
|
||||
|
||||
var skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
|
||||
|
||||
type SyncInput struct {
|
||||
Version string
|
||||
OfficialSkills []string
|
||||
LocalSkills []string
|
||||
PreviousState *SkillsState
|
||||
StateReadable bool
|
||||
Force bool
|
||||
}
|
||||
|
||||
type SyncPlan struct {
|
||||
Version string
|
||||
OfficialSkills []string
|
||||
ToUpdate []string
|
||||
Added []string
|
||||
SkippedDeleted []string
|
||||
}
|
||||
|
||||
func ParseSkillsList(text string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
token := strings.TrimSpace(line)
|
||||
token = strings.TrimPrefix(token, "-")
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" || strings.Contains(token, " ") || strings.HasSuffix(token, ":") {
|
||||
continue
|
||||
}
|
||||
if !skillNamePattern.MatchString(token) {
|
||||
continue
|
||||
}
|
||||
if at := strings.Index(token, "@"); at > 0 {
|
||||
token = token[:at]
|
||||
}
|
||||
seen[token] = true
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func PlanSync(input SyncInput) SyncPlan {
|
||||
official := uniqueSorted(input.OfficialSkills)
|
||||
if input.Force {
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: official,
|
||||
Added: []string{},
|
||||
SkippedDeleted: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
officialSet := toSet(official)
|
||||
localOfficial := intersection(input.LocalSkills, officialSet)
|
||||
|
||||
previousOfficial := []string{}
|
||||
if input.StateReadable && input.PreviousState != nil {
|
||||
previousOfficial = input.PreviousState.OfficialSkills
|
||||
}
|
||||
previousSet := toSet(previousOfficial)
|
||||
|
||||
newOfficial := []string{}
|
||||
for _, skill := range official {
|
||||
if !previousSet[skill] {
|
||||
newOfficial = append(newOfficial, skill)
|
||||
}
|
||||
}
|
||||
|
||||
updateSet := toSet(localOfficial)
|
||||
for _, skill := range newOfficial {
|
||||
updateSet[skill] = true
|
||||
}
|
||||
toUpdate := sortedKeys(updateSet)
|
||||
updateSet = toSet(toUpdate)
|
||||
|
||||
skipped := []string{}
|
||||
for _, skill := range official {
|
||||
if !updateSet[skill] {
|
||||
skipped = append(skipped, skill)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: toUpdate,
|
||||
Added: uniqueSorted(newOfficial),
|
||||
SkippedDeleted: skipped,
|
||||
}
|
||||
}
|
||||
|
||||
type SkillsRunner interface {
|
||||
ListOfficialSkills() *selfupdate.NpmResult
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
InstallSkill(name string) *selfupdate.NpmResult
|
||||
}
|
||||
|
||||
type SyncOptions struct {
|
||||
Version string
|
||||
Force bool
|
||||
Runner SkillsRunner
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
Action string
|
||||
Official []string
|
||||
Updated []string
|
||||
Added []string
|
||||
SkippedDeleted []string
|
||||
Failed []string
|
||||
Err error
|
||||
Detail string
|
||||
Force bool
|
||||
}
|
||||
|
||||
func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
if opts.Runner == nil {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
|
||||
}
|
||||
|
||||
officialResult := opts.Runner.ListOfficialSkills()
|
||||
if officialResult == nil {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("failed to list official skills: empty result")}
|
||||
}
|
||||
if officialResult.Err != nil {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("failed to list official skills: %w", officialResult.Err), Detail: resultDetail(officialResult)}
|
||||
}
|
||||
official := ParseSkillsList(officialResult.Stdout.String())
|
||||
|
||||
localResult := opts.Runner.ListGlobalSkills()
|
||||
if localResult == nil {
|
||||
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to list installed skills: empty result")}
|
||||
}
|
||||
if localResult.Err != nil {
|
||||
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to list installed skills: %w", localResult.Err), Detail: resultDetail(localResult)}
|
||||
}
|
||||
local := ParseSkillsList(localResult.Stdout.String())
|
||||
|
||||
previous, readable, err := ReadState()
|
||||
if err != nil {
|
||||
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to read skills state: %w", err)}
|
||||
}
|
||||
|
||||
plan := PlanSync(SyncInput{
|
||||
Version: opts.Version,
|
||||
OfficialSkills: official,
|
||||
LocalSkills: local,
|
||||
PreviousState: previous,
|
||||
StateReadable: readable,
|
||||
Force: opts.Force,
|
||||
})
|
||||
|
||||
result := &SyncResult{
|
||||
Action: "synced",
|
||||
Official: plan.OfficialSkills,
|
||||
Updated: plan.ToUpdate,
|
||||
Added: plan.Added,
|
||||
SkippedDeleted: plan.SkippedDeleted,
|
||||
Force: opts.Force,
|
||||
}
|
||||
|
||||
failed := []string{}
|
||||
var details []string
|
||||
for _, skill := range plan.ToUpdate {
|
||||
installResult := opts.Runner.InstallSkill(skill)
|
||||
if installResult == nil {
|
||||
failed = append(failed, skill)
|
||||
details = append(details, skill+": empty result")
|
||||
continue
|
||||
}
|
||||
if installResult.Err != nil {
|
||||
failed = append(failed, skill)
|
||||
details = append(details, skill+": "+resultDetail(installResult))
|
||||
}
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
result.Action = "failed"
|
||||
result.Failed = failed
|
||||
result.Err = fmt.Errorf("%d skill(s) failed", len(failed))
|
||||
result.Detail = strings.Join(details, "\n")
|
||||
return result
|
||||
}
|
||||
|
||||
state := SkillsState{
|
||||
Version: opts.Version,
|
||||
OfficialSkills: plan.OfficialSkills,
|
||||
UpdatedSkills: plan.ToUpdate,
|
||||
AddedSkills: plan.Added,
|
||||
SkippedDeletedSkills: plan.SkippedDeleted,
|
||||
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := WriteState(state); err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("skills synced but state not written: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func resultDetail(result *selfupdate.NpmResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{}
|
||||
if output := strings.TrimSpace(result.CombinedOutput()); output != "" {
|
||||
parts = append(parts, output)
|
||||
}
|
||||
if result.Err != nil {
|
||||
parts = append(parts, result.Err.Error())
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func uniqueSorted(values []string) []string {
|
||||
return sortedKeys(toSet(values))
|
||||
}
|
||||
|
||||
func toSet(values []string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
out[value] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func intersection(values []string, allowed map[string]bool) []string {
|
||||
out := map[string]bool{}
|
||||
for _, value := range values {
|
||||
if allowed[value] {
|
||||
out[value] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(out)
|
||||
}
|
||||
|
||||
func sortedKeys(values map[string]bool) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for value := range values {
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
)
|
||||
|
||||
func TestParseSkillsList(t *testing.T) {
|
||||
input := `Installed skills:
|
||||
- lark-calendar
|
||||
- lark-mail
|
||||
lark-im
|
||||
custom-skill
|
||||
lark-base@1.0.0
|
||||
lark-cli-harness:dev@0.1.0
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-cli-harness:dev", "lark-im", "lark-mail"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
|
||||
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
||||
got := PlanSync(SyncInput{
|
||||
Version: "1.0.33",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
|
||||
LocalSkills: []string{"lark-calendar", "lark-custom"},
|
||||
PreviousState: previous,
|
||||
StateReadable: true,
|
||||
Force: false,
|
||||
})
|
||||
|
||||
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-new"})
|
||||
assertStrings(t, got.Added, []string{"lark-new"})
|
||||
assertStrings(t, got.SkippedDeleted, []string{"lark-mail"})
|
||||
}
|
||||
|
||||
func TestPlanNormal_MissingStateInstallsAllOfficial(t *testing.T) {
|
||||
got := PlanSync(SyncInput{
|
||||
Version: "1.0.33",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
|
||||
LocalSkills: []string{"lark-calendar"},
|
||||
StateReadable: false,
|
||||
Force: false,
|
||||
})
|
||||
|
||||
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, got.Added, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, got.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
func TestPlanForceRestoresAllOfficial(t *testing.T) {
|
||||
got := PlanSync(SyncInput{
|
||||
Version: "1.0.33",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
|
||||
LocalSkills: []string{"lark-calendar"},
|
||||
PreviousState: &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}},
|
||||
StateReadable: true,
|
||||
Force: true,
|
||||
})
|
||||
|
||||
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, got.Added, []string{})
|
||||
assertStrings(t, got.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
type fakeSkillsRunner struct {
|
||||
officialOut string
|
||||
globalOut string
|
||||
officialErr error
|
||||
globalErr error
|
||||
installErr map[string]error
|
||||
installed []string
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialOut)
|
||||
r.Err = f.officialErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.globalOut)
|
||||
r.Err = f.globalErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) InstallSkill(name string) *selfupdate.NpmResult {
|
||||
f.installed = append(f.installed, name)
|
||||
r := &selfupdate.NpmResult{}
|
||||
if f.installErr != nil {
|
||||
r.Err = f.installErr[name]
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteState(SkillsState{
|
||||
Version: "1.0.30",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail"},
|
||||
UpdatedAt: "2026-05-18T00:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "lark-calendar\nlark-mail\nlark-new\n",
|
||||
globalOut: "lark-calendar\nlark-custom\n",
|
||||
}
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Runner: runner,
|
||||
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, runner.installed, []string{"lark-calendar", "lark-new"})
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-new"})
|
||||
assertStrings(t, state.AddedSkills, []string{"lark-new"})
|
||||
assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"})
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills.stamp")); !os.IsNotExist(err) {
|
||||
t.Fatalf("skills.stamp exists or stat failed with unexpected err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ListFailureDoesNotInstallOrWriteState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{officialErr: fmt.Errorf("list failed")}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "failed to list official skills") {
|
||||
t.Fatalf("SyncSkills() err = %v, want official list failure", result.Err)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want none", runner.installed)
|
||||
}
|
||||
if _, readable, err := ReadState(); err != nil || readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want unreadable missing state", readable, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_GlobalListFailureDoesNotInstallOrWriteState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "lark-calendar\nlark-mail\n",
|
||||
globalErr: fmt.Errorf("global list failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "failed to list installed skills") {
|
||||
t.Fatalf("SyncSkills() err = %v, want installed list failure", result.Err)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want none", runner.installed)
|
||||
}
|
||||
if _, readable, err := ReadState(); err != nil || readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want unreadable missing state", readable, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_InstallFailureContinuesAndDoesNotWriteState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "lark-calendar\nlark-mail\n",
|
||||
globalOut: "lark-calendar\nlark-mail\n",
|
||||
installErr: map[string]error{"lark-calendar": fmt.Errorf("boom")},
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "1 skill(s) failed") {
|
||||
t.Fatalf("SyncSkills() err = %v, want install failure", result.Err)
|
||||
}
|
||||
assertStrings(t, runner.installed, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Failed, []string{"lark-calendar"})
|
||||
if !strings.Contains(result.Detail, "boom") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want install error text", result.Detail)
|
||||
}
|
||||
if _, readable, err := ReadState(); err != nil || readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want no success state", readable, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_NilRunnerFails(t *testing.T) {
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Now: time.Now})
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "skills runner is nil") {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil runner failure", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStrings(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.34",
|
||||
"version": "1.0.39",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -10,8 +10,6 @@ const p = require("@clack/prompts");
|
||||
const PKG = "@larksuite/cli";
|
||||
const SKILLS_REPO = "https://open.feishu.cn";
|
||||
const SKILLS_REPO_FALLBACK = "larksuite/cli";
|
||||
const CONFIG_DIR = process.env.LARKSUITE_CLI_CONFIG_DIR || path.join(process.env.HOME || process.env.USERPROFILE || "", ".lark-cli");
|
||||
const SKILLS_STATE_FILE = path.join(CONFIG_DIR, "skills-state.json");
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -238,7 +236,7 @@ async function stepInstallGlobally(msg) {
|
||||
|
||||
if (installedVer && !needsUpgrade) {
|
||||
p.log.info(fmt(msg.step1Skip, installedVer));
|
||||
return installedVer;
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = p.spinner();
|
||||
@@ -250,111 +248,41 @@ async function stepInstallGlobally(msg) {
|
||||
try {
|
||||
await runSilentAsync("npm", ["install", "-g", PKG], { timeout: 120000 });
|
||||
s.stop(needsUpgrade ? fmt(msg.step1Upgraded, latestVer) : msg.step1Done);
|
||||
return latestVer || getGloballyInstalledVersion() || installedVer || null;
|
||||
return needsUpgrade;
|
||||
} catch (_) {
|
||||
s.stop(fmt(msg.step1Fail, PKG));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSkillsList(text) {
|
||||
const seen = new Set();
|
||||
for (const rawLine of text.split("\n")) {
|
||||
let token = rawLine.trim();
|
||||
if (token.startsWith("-")) token = token.slice(1).trim();
|
||||
if (!token || token.includes(" ") || token.endsWith(":")) continue;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_:-]*(?:@\S+)?$/.test(token)) continue;
|
||||
const at = token.indexOf("@");
|
||||
if (at > 0) token = token.slice(0, at);
|
||||
seen.add(token);
|
||||
}
|
||||
return [...seen].sort();
|
||||
}
|
||||
|
||||
function readSkillsState() {
|
||||
async function skillsAlreadyInstalled() {
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(SKILLS_STATE_FILE, "utf8"));
|
||||
if (state.schema_version !== 1 || !Array.isArray(state.official_skills)) return null;
|
||||
return state;
|
||||
const out = await runSilentAsync("npx", ["-y", "skills", "ls", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
return /^lark-/m.test(out.toString());
|
||||
} catch (_) {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSkillsState(version, official, updated, added, skipped) {
|
||||
if (!CONFIG_DIR) return;
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(SKILLS_STATE_FILE, JSON.stringify({
|
||||
schema_version: 1,
|
||||
version,
|
||||
official_skills: official,
|
||||
updated_skills: updated,
|
||||
added_skills: added,
|
||||
skipped_deleted_skills: skipped,
|
||||
updated_at: new Date().toISOString(),
|
||||
}, null, 2) + "\n");
|
||||
}
|
||||
|
||||
async function listOfficialSkills() {
|
||||
try {
|
||||
return parseSkillsList(await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "--list"], { timeout: 120000 }));
|
||||
} catch (_) {
|
||||
return parseSkillsList(await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "--list"], { timeout: 120000 }));
|
||||
}
|
||||
}
|
||||
|
||||
async function listGlobalSkills() {
|
||||
return parseSkillsList(await runSilentAsync("npx", ["-y", "skills", "ls", "-g"], { timeout: 120000 }));
|
||||
}
|
||||
|
||||
function planSkillsSync(version, official, local, previousState) {
|
||||
const officialSet = new Set(official);
|
||||
const previousSet = new Set(previousState ? previousState.official_skills : []);
|
||||
const localOfficial = local.filter((skill) => officialSet.has(skill));
|
||||
const added = official.filter((skill) => !previousSet.has(skill));
|
||||
const updateSet = new Set([...localOfficial, ...added]);
|
||||
const updated = official.filter((skill) => updateSet.has(skill));
|
||||
return {
|
||||
version,
|
||||
official,
|
||||
updated,
|
||||
added,
|
||||
skipped: official.filter((skill) => !updateSet.has(skill)),
|
||||
};
|
||||
}
|
||||
|
||||
async function installSkill(name) {
|
||||
try {
|
||||
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-s", name, "-g", "-y"], { timeout: 120000 });
|
||||
} catch (_) {
|
||||
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "-s", name, "-g", "-y"], { timeout: 120000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function stepInstallSkills(msg, cliVersion) {
|
||||
async function stepInstallSkills(msg) {
|
||||
const s = p.spinner();
|
||||
s.start(msg.step2Spinner);
|
||||
try {
|
||||
const official = await listOfficialSkills();
|
||||
const local = await listGlobalSkills();
|
||||
const plan = planSkillsSync(cliVersion || "unknown", official, local, readSkillsState());
|
||||
if (plan.updated.length === 0) {
|
||||
writeSkillsState(plan.version, plan.official, plan.updated, plan.added, plan.skipped);
|
||||
if (await skillsAlreadyInstalled()) {
|
||||
s.stop(msg.step2Skip);
|
||||
return;
|
||||
}
|
||||
const failed = [];
|
||||
for (const skill of plan.updated) {
|
||||
try {
|
||||
await installSkill(skill);
|
||||
} catch (_) {
|
||||
failed.push(skill);
|
||||
}
|
||||
try {
|
||||
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-y", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
} catch (_) {
|
||||
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "-y", "-g"], {
|
||||
timeout: 120000,
|
||||
});
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
throw new Error(`${failed.length} skill(s) failed: ${failed.join(", ")}`);
|
||||
}
|
||||
writeSkillsState(plan.version, plan.official, plan.updated, plan.added, plan.skipped);
|
||||
s.stop(msg.step2Done);
|
||||
} catch (_) {
|
||||
s.stop(fmt(msg.step2Fail, SKILLS_REPO_FALLBACK));
|
||||
@@ -433,15 +361,15 @@ async function main() {
|
||||
|
||||
if (isInteractive) {
|
||||
p.intro(msg.setup);
|
||||
const cliVersion = await stepInstallGlobally(msg);
|
||||
await stepInstallSkills(msg, cliVersion);
|
||||
await stepInstallGlobally(msg);
|
||||
await stepInstallSkills(msg);
|
||||
await stepConfigInit(msg, lang);
|
||||
await stepAuthLogin(msg);
|
||||
p.outro(msg.done);
|
||||
} else {
|
||||
console.log(msg.setup);
|
||||
const cliVersion = await stepInstallGlobally(msg);
|
||||
await stepInstallSkills(msg, cliVersion);
|
||||
await stepInstallGlobally(msg);
|
||||
await stepInstallSkills(msg);
|
||||
console.log(msg.nonTtyHint);
|
||||
}
|
||||
}
|
||||
|
||||
55
shortcuts/apps/apps_access_scope_get.go
Normal file
55
shortcuts/apps/apps_access_scope_get.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAccessScopeGet reads the current access scope configuration of a Miaoda app.
|
||||
// 响应原样透传服务端契约(字符串 scope 枚举 All/Tenant/Range + 拆分的 users/departments/chats 数组)。
|
||||
var AppsAccessScopeGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+access-scope-get",
|
||||
Description: "Get Miaoda app access scope configuration",
|
||||
Risk: "read",
|
||||
Scopes: []string{"spark:app.access_scope:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Get Miaoda app access scope")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPI("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 原样透传 — 保留服务端字符串枚举 (All/Tenant/Range),不合并 users/departments/chats。
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "scope: %v\n", data["scope"])
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
123
shortcuts/apps/apps_access_scope_get_test.go
Normal file
123
shortcuts/apps/apps_access_scope_get_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsAccessScopeGet_Specific(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"scope": "Range",
|
||||
"users": []interface{}{"ou_x", "ou_y"},
|
||||
"departments": []interface{}{"od_z"},
|
||||
"chats": []interface{}{"oc_g"},
|
||||
"apply_config": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"approvers": []interface{}{"ou_appr"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsAccessScopeGet,
|
||||
[]string{"+access-scope-get", "--app-id", "app_x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"scope": "Range"`) {
|
||||
t.Fatalf("scope string not preserved (expect raw \"Range\"): %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od_z"`) || !strings.Contains(got, `"oc_g"`) {
|
||||
t.Fatalf("users/departments/chats fields missing in envelope: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"ou_appr"`) {
|
||||
t.Fatalf("apply_config.approvers missing: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeGet_Public(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"scope": "All", "require_login": false},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsAccessScopeGet,
|
||||
[]string{"+access-scope-get", "--app-id", "app_x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"scope": "All"`) {
|
||||
t.Fatalf("scope=All missing: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"require_login": false`) {
|
||||
t.Fatalf("require_login missing: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeGet_Tenant(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"scope": "Tenant"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsAccessScopeGet,
|
||||
[]string{"+access-scope-get", "--app-id", "app_x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"scope": "Tenant"`) {
|
||||
t.Fatalf("scope=Tenant missing: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeGet_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsAccessScopeGet,
|
||||
[]string{"+access-scope-get", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "app-id") {
|
||||
t.Fatalf("expected --app-id required, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeGet_TrimsAppIDInPath(t *testing.T) {
|
||||
// 与 +update 的 D1.2 修复对称:URL 拼接前必须 TrimSpace(app-id),
|
||||
// 否则 " app_x " 会被 EncodePathSegment 编码进 path segment 出现空格转义。
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"scope": "Tenant"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsAccessScopeGet,
|
||||
[]string{"+access-scope-get", "--app-id", " app_x ", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
}
|
||||
208
shortcuts/apps/apps_access_scope_set.go
Normal file
208
shortcuts/apps/apps_access_scope_set.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var allowedAccessTargetTypes = map[string]bool{
|
||||
"user": true,
|
||||
"department": true,
|
||||
"chat": true,
|
||||
}
|
||||
|
||||
// AppsAccessScopeSet sets the app's access scope (specific / public / tenant).
|
||||
var AppsAccessScopeSet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+access-scope-set",
|
||||
Description: "Set Miaoda app access scope (specific / public / tenant)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app.access_scope:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "scope", Desc: "scope: specific | public | tenant", Required: true, Enum: []string{"specific", "public", "tenant"}},
|
||||
{Name: "targets", Desc: `targets JSON array: [{"type":"user|department|chat","id":"..."}, ...]`},
|
||||
{Name: "apply-enabled", Type: "bool", Desc: "allow apply for access (scope=specific)"},
|
||||
{Name: "approver", Desc: "approver open_id (when --apply-enabled; server allows exactly one)"},
|
||||
{Name: "require-login", Type: "bool", Desc: "require login (scope=public)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
return validateAccessScopeFlags(rctx)
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
dry := common.NewDryRunAPI().
|
||||
PUT(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Set Miaoda app access scope")
|
||||
body, bodyErr := buildAccessScopeBody(rctx)
|
||||
if bodyErr != nil {
|
||||
dry.Set("body_error", bodyErr.Error())
|
||||
} else {
|
||||
dry.Body(body)
|
||||
}
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
body, err := buildAccessScopeBody(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPI("PUT", path, nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "access-scope set: %s\n", rctx.Str("scope"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func validateAccessScopeFlags(rctx *common.RuntimeContext) error {
|
||||
scope := rctx.Str("scope")
|
||||
targets := strings.TrimSpace(rctx.Str("targets"))
|
||||
applyEnabled := rctx.Bool("apply-enabled")
|
||||
approver := strings.TrimSpace(rctx.Str("approver"))
|
||||
requireLogin := rctx.Bool("require-login")
|
||||
|
||||
switch scope {
|
||||
case "specific":
|
||||
if targets == "" {
|
||||
return output.ErrValidation("--targets is required when --scope=specific")
|
||||
}
|
||||
if err := validateTargetsJSON(targets); err != nil {
|
||||
return err
|
||||
}
|
||||
if approver != "" && !applyEnabled {
|
||||
return output.ErrValidation("--approver requires --apply-enabled")
|
||||
}
|
||||
if requireLogin {
|
||||
return output.ErrValidation("--require-login is not allowed when --scope=specific")
|
||||
}
|
||||
case "public":
|
||||
if targets != "" {
|
||||
return output.ErrValidation("--targets is not allowed when --scope=public")
|
||||
}
|
||||
if applyEnabled {
|
||||
return output.ErrValidation("--apply-enabled is not allowed when --scope=public")
|
||||
}
|
||||
if approver != "" {
|
||||
return output.ErrValidation("--approver is not allowed when --scope=public")
|
||||
}
|
||||
if !rctx.Cmd.Flags().Changed("require-login") {
|
||||
return output.ErrValidation("--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
|
||||
}
|
||||
case "tenant":
|
||||
if targets != "" || applyEnabled || approver != "" || requireLogin {
|
||||
return output.ErrValidation("no extra flags allowed when --scope=tenant")
|
||||
}
|
||||
default:
|
||||
return output.ErrValidation("--scope must be specific / public / tenant")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTargetsJSON(targetsJSON string) error {
|
||||
var items []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(targetsJSON), &items); err != nil {
|
||||
return output.ErrValidation("--targets is not valid JSON: %v", err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return output.ErrValidation("--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
|
||||
}
|
||||
for i, t := range items {
|
||||
typ, _ := t["type"].(string)
|
||||
if !allowedAccessTargetTypes[typ] {
|
||||
return output.ErrValidation("--targets[%d].type %q must be one of: user / department / chat", i, typ)
|
||||
}
|
||||
if id, _ := t["id"].(string); strings.TrimSpace(id) == "" {
|
||||
return output.ErrValidation("--targets[%d].id is empty", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scopeStringToServerEnum 把 CLI 友好的 scope 字符串映射成后端字符串枚举。
|
||||
// CLI 用户 / Agent 仍然写 specific / public / tenant,body 里发后端枚举名。
|
||||
// 后端语义:All=互联网公开 / Tenant=组织内 / Range=部分人员。
|
||||
var scopeStringToServerEnum = map[string]string{
|
||||
"public": "All",
|
||||
"tenant": "Tenant",
|
||||
"specific": "Range",
|
||||
}
|
||||
|
||||
func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
scope := rctx.Str("scope")
|
||||
enum, ok := scopeStringToServerEnum[scope]
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("--scope must be specific / public / tenant, got %q", scope)
|
||||
}
|
||||
body := map[string]interface{}{"scope": enum}
|
||||
|
||||
switch scope {
|
||||
case "specific":
|
||||
// 用户传统一格式 [{type:user|department|chat, id:...}],body 里拆 3 个并列数组发后端。
|
||||
var targets []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(rctx.Str("targets")), &targets); err != nil {
|
||||
return nil, output.ErrValidation("--targets is not valid JSON: %v", err)
|
||||
}
|
||||
users, departments, chats := splitAccessScopeTargets(targets)
|
||||
if len(users) > 0 {
|
||||
body["users"] = users
|
||||
}
|
||||
if len(departments) > 0 {
|
||||
body["departments"] = departments
|
||||
}
|
||||
if len(chats) > 0 {
|
||||
body["chats"] = chats
|
||||
}
|
||||
if rctx.Bool("apply-enabled") {
|
||||
applyConfig := map[string]interface{}{"enabled": true}
|
||||
if approver := strings.TrimSpace(rctx.Str("approver")); approver != "" {
|
||||
applyConfig["approvers"] = []string{approver}
|
||||
}
|
||||
body["apply_config"] = applyConfig
|
||||
}
|
||||
case "public":
|
||||
body["require_login"] = rctx.Bool("require-login")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// splitAccessScopeTargets 把统一 [{type,id}] 形态拆成后端要求的 users/departments/chats 三个数组。
|
||||
func splitAccessScopeTargets(targets []map[string]interface{}) (users, departments, chats []string) {
|
||||
for _, t := range targets {
|
||||
typ, _ := t["type"].(string)
|
||||
id, _ := t["id"].(string)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
switch typ {
|
||||
case "user":
|
||||
users = append(users, id)
|
||||
case "department":
|
||||
departments = append(departments, id)
|
||||
case "chat":
|
||||
chats = append(chats, id)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
203
shortcuts/apps/apps_access_scope_set_test.go
Normal file
203
shortcuts/apps/apps_access_scope_set_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsAccessScopeSet_Specific(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsAccessScopeSet, []string{
|
||||
"+access-scope-set",
|
||||
"--app-id", "app_x",
|
||||
"--scope", "specific",
|
||||
"--targets", `[{"type":"user","id":"ou_xxx"},{"type":"chat","id":"oc_xxx"}]`,
|
||||
"--apply-enabled",
|
||||
"--approver", "ou_yyy",
|
||||
"--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
// 新协议:scope 是 string 枚举 (specific=Range),targets 拆成 users/departments/chats
|
||||
if got, _ := sent["scope"].(string); got != "Range" {
|
||||
t.Fatalf("scope = %v, want %q", sent["scope"], "Range")
|
||||
}
|
||||
if _, present := sent["targets"]; present {
|
||||
t.Fatalf("legacy 'targets' field should not be sent: %v", sent)
|
||||
}
|
||||
users, _ := sent["users"].([]interface{})
|
||||
if len(users) != 1 || users[0] != "ou_xxx" {
|
||||
t.Fatalf("users = %v, want [ou_xxx]", sent["users"])
|
||||
}
|
||||
chats, _ := sent["chats"].([]interface{})
|
||||
if len(chats) != 1 || chats[0] != "oc_xxx" {
|
||||
t.Fatalf("chats = %v, want [oc_xxx]", sent["chats"])
|
||||
}
|
||||
if _, present := sent["departments"]; present {
|
||||
t.Fatalf("departments should be omitted when empty: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeSet_Public(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsAccessScopeSet, []string{
|
||||
"+access-scope-set",
|
||||
"--app-id", "app_x",
|
||||
"--scope", "public",
|
||||
"--require-login=false",
|
||||
"--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeSet_Tenant(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsAccessScopeSet, []string{
|
||||
"+access-scope-set",
|
||||
"--app-id", "app_x",
|
||||
"--scope", "tenant",
|
||||
"--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeSet_SpecificRequiresTargets(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
|
||||
"+access-scope-set", "--app-id", "app_x", "--scope", "specific", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "targets") {
|
||||
t.Fatalf("expected targets required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeSet_TenantRejectsExtraFlags(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
|
||||
"+access-scope-set", "--app-id", "app_x", "--scope", "tenant",
|
||||
"--targets", `[]`, "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when --targets passed with scope=tenant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeSet_RejectsBadTargetType(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
|
||||
"+access-scope-set", "--app-id", "app_x",
|
||||
"--scope", "specific",
|
||||
"--targets", `[{"type":"group","id":"oc_xxx"}]`,
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "type") {
|
||||
t.Fatalf("expected bad target type rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeSet_ApproverRequiresApplyEnabled(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
|
||||
"+access-scope-set", "--app-id", "app_x",
|
||||
"--scope", "specific",
|
||||
"--targets", `[{"type":"user","id":"ou_x"}]`,
|
||||
"--approver", "ou_y",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "apply-enabled") {
|
||||
t.Fatalf("expected --approver requires --apply-enabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeSet_PublicRejectsApprover(t *testing.T) {
|
||||
// --approver 只在 specific + apply 流程下有意义;public 模式带它当前会被静默丢弃,
|
||||
// 是真实用户语义 bug。这条测试钉死 Validate 阶段拦截。
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
|
||||
"+access-scope-set", "--app-id", "app_x",
|
||||
"--scope", "public",
|
||||
"--require-login=false",
|
||||
"--approver", "ou_y",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--approver is not allowed when --scope=public") {
|
||||
t.Fatalf("expected --approver rejected for scope=public, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeSet_PublicRequiresExplicitRequireLogin(t *testing.T) {
|
||||
// bare --scope public without --require-login defaults silently to
|
||||
// require_login=false (Internet-public + no auth). Reject so the caller
|
||||
// has to make an explicit choice; matches SKILL.md "public 必传 --require-login".
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
|
||||
"+access-scope-set", "--app-id", "app_x",
|
||||
"--scope", "public",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--require-login is required when --scope=public") {
|
||||
t.Fatalf("expected --require-login required for public, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeSet_SpecificRejectsEmptyTargets(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
|
||||
"+access-scope-set", "--app-id", "app_x",
|
||||
"--scope", "specific",
|
||||
"--targets", "[]",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--targets must contain at least one entry") {
|
||||
t.Fatalf("expected empty --targets rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAccessScopeSet_TrimsAppIDInPath(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsAccessScopeSet, []string{
|
||||
"+access-scope-set", "--app-id", " app_x ",
|
||||
"--scope", "tenant",
|
||||
"--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
}
|
||||
79
shortcuts/apps/apps_create.go
Normal file
79
shortcuts/apps/apps_create.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsCreate creates a new Miaoda app.
|
||||
var AppsCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+create",
|
||||
Description: "Create a new Miaoda app",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "app display name", Required: true},
|
||||
{Name: "app-type", Desc: "app type (currently only: HTML)", Required: true},
|
||||
{Name: "description", Desc: "app description"},
|
||||
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return output.ErrValidation("--name is required")
|
||||
}
|
||||
appType := strings.TrimSpace(rctx.Str("app-type"))
|
||||
if appType == "" {
|
||||
return output.ErrValidation("--app-type is required")
|
||||
}
|
||||
if !validAppTypes[appType] {
|
||||
return output.ErrValidation(fmt.Sprintf("--app-type %q is not supported (allowed: HTML)", appType))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST(apiBasePath + "/apps").
|
||||
Desc("Create a Miaoda app").
|
||||
Body(buildAppsCreateBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPI("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "created: %s\n", common.GetString(data, "app_id"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// 应用类型枚举。当前只有 HTML,未来会扩展(SPA、NATIVE、...)。
|
||||
var validAppTypes = map[string]bool{
|
||||
"HTML": true,
|
||||
}
|
||||
|
||||
func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"name": strings.TrimSpace(rctx.Str("name")),
|
||||
"app_type": strings.TrimSpace(rctx.Str("app-type")),
|
||||
}
|
||||
if desc := strings.TrimSpace(rctx.Str("description")); desc != "" {
|
||||
body["description"] = desc
|
||||
}
|
||||
if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" {
|
||||
body["icon_url"] = icon
|
||||
}
|
||||
return body
|
||||
}
|
||||
157
shortcuts/apps/apps_create_test.go
Normal file
157
shortcuts/apps/apps_create_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// 测试基础设施 —— 后续 Task 2.2-2.4 / Task 3.4 复用
|
||||
|
||||
func newAppsExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
return factory, stdout, reg
|
||||
}
|
||||
|
||||
func runAppsShortcut(t *testing.T, sc common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "apps"}
|
||||
sc.Mount(parent, factory)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
// +create 测试
|
||||
|
||||
func TestAppsCreate_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app_id": "app_x",
|
||||
"name": "Demo",
|
||||
"icon_url": "https://lf3-static.bytednsdoc.com/.../default.svg",
|
||||
"created_at": "2026-05-18T10:00:00Z",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--description", "d", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"app_id": "app_x"`) {
|
||||
t.Fatalf("stdout missing app_id: %s", got)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["name"] != "Demo" {
|
||||
t.Fatalf("body.name = %v", sent["name"])
|
||||
}
|
||||
if sent["app_type"] != "HTML" {
|
||||
t.Fatalf("body.app_type = %v (want HTML)", sent["app_type"])
|
||||
}
|
||||
if sent["description"] != "d" {
|
||||
t.Fatalf("body.description = %v", sent["description"])
|
||||
}
|
||||
if _, present := sent["icon_url"]; present {
|
||||
t.Fatalf("icon_url should be omitted when not provided: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_WithIconURL(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_id": "app_x", "name": "Demo"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--icon-url", "https://example.com/icon.svg", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_RequiresName(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "HTML", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "name") {
|
||||
t.Fatalf("expected name required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_RequiresAppType(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "app-type") {
|
||||
t.Fatalf("expected --app-type required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_RejectsInvalidAppType(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "spa", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported") {
|
||||
t.Fatalf("expected unsupported app-type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsCreate_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"name": "Demo"`) {
|
||||
t.Fatalf("dry-run missing body: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"app_type": "HTML"`) {
|
||||
t.Fatalf("dry-run missing app_type: %s", got)
|
||||
}
|
||||
}
|
||||
192
shortcuts/apps/apps_html_publish.go
Normal file
192
shortcuts/apps/apps_html_publish.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsHTMLPublish packs --path as tar.gz and uploads + publishes via one multipart POST.
|
||||
var AppsHTMLPublish = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+html-publish",
|
||||
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app:publish"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
|
||||
{Name: "path", Desc: "path to HTML file or directory", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
path := strings.TrimSpace(rctx.Str("path"))
|
||||
if path == "" {
|
||||
return output.ErrValidation("--path is required")
|
||||
}
|
||||
// Reject --path equal to the current working directory. Publishing
|
||||
// cwd recursively packs .git/ / .env / node_modules / .aws/credentials
|
||||
// alongside the intended HTML, and combined with --scope public puts
|
||||
// those on an internet-reachable URL.
|
||||
if filepath.Clean(path) == "." {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
"--path 不能指向当前工作目录(避免误把整个工程一并发布出去)",
|
||||
"改成具体的子目录或文件,如 './dist' / './public' / './index.html'")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := strings.TrimSpace(rctx.Str("path"))
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload tar.gz + publish HTML (multipart, returns url)")
|
||||
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Set("content_type", "multipart/form-data")
|
||||
|
||||
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
dry.Set("path_error", err.Error())
|
||||
return dry
|
||||
}
|
||||
if err := ensureIndexHTML(candidates); err != nil {
|
||||
// Surface the same failure Execute would hit, but as a structured
|
||||
// envelope field so dry-run still exits 0 (matches repo convention
|
||||
// for dry-run "advisory preview" semantics).
|
||||
dry.Set("validation_error", err.Error())
|
||||
}
|
||||
dry.Set("file_count", len(candidates))
|
||||
var totalSize int64
|
||||
names := make([]string, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
totalSize += c.Size
|
||||
names = append(names, c.RelPath)
|
||||
}
|
||||
dry.Set("total_size_bytes", totalSize)
|
||||
dry.Set("files", names)
|
||||
// Advisory scan: surface paths matching well-known secret / credential
|
||||
// patterns so the caller can review before going public. Dry-run still
|
||||
// exits 0; this is non-blocking by design (legit doc sites may ship
|
||||
// example .env files).
|
||||
var warnings []string
|
||||
for _, c := range candidates {
|
||||
if isSensitiveRelPath(c.RelPath) {
|
||||
warnings = append(warnings, c.RelPath)
|
||||
}
|
||||
}
|
||||
if len(warnings) > 0 {
|
||||
dry.Set("warnings", warnings)
|
||||
dry.Set("warning_summary", fmt.Sprintf("manifest contains %d sensitive path(s); review before publishing", len(warnings)))
|
||||
}
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
spec := appsHTMLPublishSpec{
|
||||
AppID: strings.TrimSpace(rctx.Str("app-id")),
|
||||
Path: strings.TrimSpace(rctx.Str("path")),
|
||||
}
|
||||
client := appsHTMLPublishAPI{runtime: rctx}
|
||||
out, err := runHTMLPublish(ctx, rctx.FileIO(), client, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
if url, ok := out["url"].(string); ok && url != "" {
|
||||
fmt.Fprintf(w, "url: %s\n", url)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type appsHTMLPublishSpec struct {
|
||||
AppID string
|
||||
Path string
|
||||
}
|
||||
|
||||
// maxHTMLPublishTarballBytes 是 client 端 tar.gz 包体上限,对齐 OAPI 设计 20MB 约束。
|
||||
// 用 var 而非 const,便于单测调小覆盖拦截路径。
|
||||
var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
|
||||
|
||||
// maxHTMLPublishRawBytes caps the total UNCOMPRESSED candidate size before
|
||||
// tar+gzip writes them into the in-memory buffer. Defends against
|
||||
// highly-compressible "decompression bomb" inputs (e.g. 50GB of zeros)
|
||||
// that would balloon process memory before the gzip-after check fires.
|
||||
// 200MB is much higher than any plausible legitimate HTML/static-site
|
||||
// payload but low enough to stay well under typical container memory.
|
||||
// Mutable for tests.
|
||||
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
|
||||
|
||||
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
|
||||
// 目录形态:根目录下必须有 index.html。
|
||||
// 单文件形态:文件名必须就是 index.html。
|
||||
// 妙搭服务端用 index.html 作为应用入口。
|
||||
func ensureIndexHTML(candidates []htmlPublishCandidate) error {
|
||||
for _, c := range candidates {
|
||||
if c.RelPath == "index.html" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return output.ErrWithHint(output.ExitAPI, "validation",
|
||||
"--path 中缺少 index.html",
|
||||
"妙搭以 index.html 作为应用入口;目录形态把首页放在根目录命名 index.html,单文件形态把文件命名为 index.html")
|
||||
}
|
||||
|
||||
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
// Defense in depth: callers reaching runHTMLPublish bypass the shortcut's
|
||||
// Validate closure. Re-check that --path is not cwd before walking.
|
||||
if filepath.Clean(spec.Path) == "." {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
"--path 不能指向当前工作目录(避免误把整个工程一并发布出去)",
|
||||
"改成具体的子目录或文件,如 './dist' / './public' / './index.html'")
|
||||
}
|
||||
candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "io", "scan --path %s: %v", spec.Path, err)
|
||||
}
|
||||
if err := ensureIndexHTML(candidates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rawTotal int64
|
||||
for _, c := range candidates {
|
||||
rawTotal += c.Size
|
||||
}
|
||||
if rawTotal > maxHTMLPublishRawBytes {
|
||||
return nil, output.ErrWithHint(output.ExitAPI, "validation",
|
||||
fmt.Sprintf("--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes),
|
||||
"在 tar+gzip 进入内存前拦截,避免 OOM;精简 --path 内容或选择更小的子目录")
|
||||
}
|
||||
tarball, err := buildHTMLPublishTarball(fio, candidates)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "io", "pack: %v", err)
|
||||
}
|
||||
|
||||
if tarball.Size > maxHTMLPublishTarballBytes {
|
||||
return nil, output.ErrWithHint(output.ExitAPI, "validation",
|
||||
fmt.Sprintf("packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes),
|
||||
"请精简 --path 目录(去掉无关大文件 / 压缩资源)后重试;本期接口上限 20MB")
|
||||
}
|
||||
|
||||
resp, err := client.HTMLPublish(ctx, spec.AppID, tarball)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{}
|
||||
if resp.URL != "" {
|
||||
out["url"] = resp.URL
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
338
shortcuts/apps/apps_html_publish_test.go
Normal file
338
shortcuts/apps/apps_html_publish_test.go
Normal file
@@ -0,0 +1,338 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type fakeAppsHTMLPublishClient struct {
|
||||
resp *htmlPublishResponse
|
||||
err error
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeAppsHTMLPublishClient) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
|
||||
f.calls = append(f.calls, appID)
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.resp, nil
|
||||
}
|
||||
|
||||
func writeAppsSampleSite(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_HappyPath(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
fake := &fakeAppsHTMLPublishClient{
|
||||
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
|
||||
}
|
||||
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if out["url"] != "https://miaoda/app_x" {
|
||||
t.Fatalf("url=%v", out["url"])
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0] != "app_x" {
|
||||
t.Fatalf("calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) {
|
||||
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步":
|
||||
// envelope 只含 url,未来若有人加 status / release_id 字段会被这个测试拦截。
|
||||
site := writeAppsSampleSite(t)
|
||||
fake := &fakeAppsHTMLPublishClient{
|
||||
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
|
||||
}
|
||||
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("envelope should only contain 'url', got %d keys: %v", len(out), out)
|
||||
}
|
||||
if _, ok := out["url"]; !ok {
|
||||
t.Fatalf("envelope missing 'url': %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_ClientErrorPropagated(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
wantErr := errors.New("server timeout")
|
||||
fake := &fakeAppsHTMLPublishClient{err: wantErr}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_PathNotFound(t *testing.T) {
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: "/nonexistent"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client should not be called when path invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
|
||||
// 目录形态:缺 index.html 应该被拦
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing index.html")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "index.html") {
|
||||
t.Fatalf("message missing 'index.html': %v", exitErr.Detail.Message)
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client should not be called when index.html missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_DirWithIndexHTMLPasses(t *testing.T) {
|
||||
// 目录含 index.html 应该正常走完
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "extra.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called when index.html present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
|
||||
// 单文件形态:文件名不是 index.html 也要拦
|
||||
dir := t.TempDir()
|
||||
single := filepath.Join(dir, "foo.html")
|
||||
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single})
|
||||
if err == nil {
|
||||
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("expected ExitError type=validation, got %v", err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when index.html missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_SingleFileNamedIndexPasses(t *testing.T) {
|
||||
// 单文件形态:文件名恰好就是 index.html → 放行
|
||||
dir := t.TempDir()
|
||||
single := filepath.Join(dir, "index.html")
|
||||
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single}); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called for single index.html")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
|
||||
// 把上限调到 100 字节验证拦截,defer 恢复原值避免污染其它测试。
|
||||
orig := maxHTMLPublishTarballBytes
|
||||
maxHTMLPublishTarballBytes = 100
|
||||
defer func() { maxHTMLPublishTarballBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
// 写 index.html(满足新加的 index 校验)+ 大文件超 100 字节上限。
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.html"),
|
||||
[]byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected oversize error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "exceeds") {
|
||||
t.Fatalf("message missing 'exceeds': %v", exitErr.Detail.Message)
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client should not be called when tarball oversize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHTMLPublishTarballBytes_Default(t *testing.T) {
|
||||
// Pin 20MB 常量值,typo 到 20*1000*1024 之类会被拦截。
|
||||
if maxHTMLPublishTarballBytes != 20*1024*1024 {
|
||||
t.Fatalf("default = %d, want %d (20MiB)", maxHTMLPublishTarballBytes, 20*1024*1024)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublish_RequiresAppID(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsHTMLPublish,
|
||||
[]string{"+html-publish", "--path", site}, factory, stdout)
|
||||
// cobra Required:true may report flag name without "--" prefix
|
||||
if err == nil || !strings.Contains(err.Error(), "app-id") {
|
||||
t.Fatalf("expected --app-id required, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublish_RequiresPath(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsHTMLPublish,
|
||||
[]string{"+html-publish", "--app-id", "app_x"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "path") {
|
||||
t.Fatalf("expected --path required, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublish_DryRunPrintsManifest(t *testing.T) {
|
||||
// 这个用例走真实 shortcut → 真实 LocalFileIO(cwd-bounded)。
|
||||
// 必须 chdir 进 tmp 用相对路径,否则 SafeInputPath 会拒绝绝对 --path。
|
||||
// --path "." 被 Validate 拒绝,因此改为在 tmp 下建 dist 子目录并传 ./dist。
|
||||
dir := t.TempDir()
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
if err := os.MkdirAll(filepath.Join(dir, "dist"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir dist: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsHTMLPublish,
|
||||
[]string{"+html-publish", "--app-id", "app_x", "--path", "./dist", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "index.html") {
|
||||
t.Fatalf("dry-run missing file list: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
orig := maxHTMLPublishRawBytes
|
||||
maxHTMLPublishRawBytes = 100
|
||||
defer func() { maxHTMLPublishRawBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake,
|
||||
appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected raw-size cap to fire")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "raw") || !strings.Contains(exitErr.Detail.Message, "bytes") {
|
||||
t.Fatalf("expected message to explain raw-byte cap, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when raw cap hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_RejectsCurrentDirectoryPath(t *testing.T) {
|
||||
// Publishing the entire current working directory is the canonical
|
||||
// secrets-exfiltration footgun (.git/.env/node_modules all end up in the
|
||||
// tarball). Reject --path "." (and Clean equivalents) at runHTMLPublish
|
||||
// entry so any direct caller cannot accidentally trigger it. (Validate
|
||||
// also rejects at flag layer; this is defense in depth.)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake,
|
||||
appsHTMLPublishSpec{AppID: "app_x", Path: "."})
|
||||
if err == nil {
|
||||
t.Fatalf("expected --path '.' to be rejected")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
|
||||
t.Fatalf("expected ExitError type=validation, got %v", err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "当前工作目录") {
|
||||
t.Fatalf("error message should explain cwd is forbidden, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when --path is cwd")
|
||||
}
|
||||
}
|
||||
80
shortcuts/apps/apps_list.go
Normal file
80
shortcuts/apps/apps_list.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsList lists Miaoda apps owned by the calling user (cursor pagination).
|
||||
//
|
||||
// Hidden from --help / tab completion (Hidden: true) so agents do not discover it
|
||||
// as a way to enumerate / search applications. Direct invocation still works for
|
||||
// humans who know the command. When agents need an existing app_id, they should
|
||||
// ask the user to provide either the Miaoda app URL (extract app_id from the
|
||||
// path segment after /app/) or the app_id string directly; see lark-apps SKILL.md.
|
||||
var AppsList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+list",
|
||||
Description: "List Miaoda apps owned by the calling user (cursor pagination)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Hidden: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET(apiBasePath + "/apps").
|
||||
Desc("List Miaoda apps").
|
||||
Params(buildAppsListParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
data, err := rctx.CallAPI("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
// Table view (--format table) intentionally shows only the columns
|
||||
// most useful for visual scanning: app_id (to copy-paste downstream),
|
||||
// name (to match what the user sees in the UI), and updated_at (to
|
||||
// pick the most recent variant). description / icon_url / created_at
|
||||
// stay in the underlying JSON (--format json) but would make the
|
||||
// table too wide for a terminal.
|
||||
rows := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"app_id": m["app_id"],
|
||||
"name": m["name"],
|
||||
"updated_at": m["updated_at"],
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildAppsListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
return params
|
||||
}
|
||||
80
shortcuts/apps/apps_list_test.go
Normal file
80
shortcuts/apps/apps_list_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsList_FirstPage(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps?page_size=20",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"app_id": "app_a", "name": "Alpha", "updated_at": "2026-05-18T10:00:00Z"},
|
||||
map[string]interface{}{"app_id": "app_b", "name": "Beta", "updated_at": "2026-05-18T09:00:00Z"},
|
||||
},
|
||||
"page_token": "next_cursor",
|
||||
"has_more": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsList, []string{"+list", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "app_a") || !strings.Contains(got, "app_b") {
|
||||
t.Fatalf("output missing items: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "Alpha") || !strings.Contains(got, "Beta") {
|
||||
t.Fatalf("output missing item names: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_WithPageToken(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps?page_size=50&page_token=cursor_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--page-size", "50", "--page-token", "cursor_abc", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsList_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsList,
|
||||
[]string{"+list", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "page_size") {
|
||||
t.Fatalf("dry-run missing page_size param: %s", got)
|
||||
}
|
||||
}
|
||||
71
shortcuts/apps/apps_update.go
Normal file
71
shortcuts/apps/apps_update.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsUpdate partially updates a Miaoda app's name / description.
|
||||
var AppsUpdate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+update",
|
||||
Description: "Partially update a Miaoda app (only provided fields are sent)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "name", Desc: "new app display name"},
|
||||
{Name: "description", Desc: "new app description"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
}
|
||||
body := buildAppsUpdateBody(rctx)
|
||||
if len(body) == 0 {
|
||||
return output.ErrValidation("provide at least one of --name or --description")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
PATCH(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Update a Miaoda app").
|
||||
Body(buildAppsUpdateBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPI("PATCH", path, nil, buildAppsUpdateBody(rctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "updated: %s\n", common.GetString(data, "app_id"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildAppsUpdateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if v := strings.TrimSpace(rctx.Str("name")); v != "" {
|
||||
body["name"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(rctx.Str("description")); v != "" {
|
||||
body["description"] = v
|
||||
}
|
||||
return body
|
||||
}
|
||||
86
shortcuts/apps/apps_update_test.go
Normal file
86
shortcuts/apps/apps_update_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsUpdate_PartialFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/spark/v1/apps/app_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"app_id": "app_x",
|
||||
"name": "renamed",
|
||||
"updated_at": "2026-05-18T10:05:00Z",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsUpdate,
|
||||
[]string{"+update", "--app-id", "app_x", "--name", "renamed", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["name"] != "renamed" {
|
||||
t.Fatalf("body.name = %v", sent["name"])
|
||||
}
|
||||
if _, present := sent["description"]; present {
|
||||
t.Fatalf("description should not be in body when not provided: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsUpdate_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsUpdate,
|
||||
[]string{"+update", "--name", "renamed", "--as", "user"}, factory, stdout)
|
||||
// cobra Required:true may match "app-id" instead of "--app-id"
|
||||
if err == nil || !strings.Contains(err.Error(), "app-id") {
|
||||
t.Fatalf("expected --app-id required, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsUpdate_RequiresAtLeastOneField(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsUpdate,
|
||||
[]string{"+update", "--app-id", "app_x", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when no field provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsUpdate_TrimsAppIDInPath(t *testing.T) {
|
||||
// 钉死 --app-id 在拼进 URL 前要先 TrimSpace —— 与 create / access-scope-* 等保持一致,
|
||||
// 避免 " app_x " 这种取值被原样 EncodePathSegment 编进 path 出现空格转义。
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/spark/v1/apps/app_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"app_id": "app_x"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsUpdate,
|
||||
[]string{"+update", "--app-id", " app_x ", "--name", "renamed", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
}
|
||||
10
shortcuts/apps/common.go
Normal file
10
shortcuts/apps/common.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
// appsService 是 CLI 命令的 service 前缀(lark-cli apps ...)。
|
||||
const appsService = "apps"
|
||||
|
||||
// apiBasePath is the registered OAPI prefix for the Miaoda apps domain.
|
||||
const apiBasePath = "/open-apis/spark/v1"
|
||||
83
shortcuts/apps/html_publish_client.go
Normal file
83
shortcuts/apps/html_publish_client.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type htmlPublishResponse struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
type appsHTMLPublishClient interface {
|
||||
HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error)
|
||||
}
|
||||
|
||||
type appsHTMLPublishAPI struct {
|
||||
runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddFile("file", bytes.NewReader(tarball.Body))
|
||||
|
||||
apiResp, err := api.runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID)),
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseHTMLPublishResponse(apiResp.RawBody)
|
||||
}
|
||||
|
||||
func parseHTMLPublishResponse(raw []byte) (*htmlPublishResponse, error) {
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("decode html-publish response: %w", err)
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
return nil, output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
fmt.Sprintf("html-publish failed (code=%d): %s", envelope.Code, envelope.Msg),
|
||||
buildHTMLPublishFailureHint(envelope.Code))
|
||||
}
|
||||
return &htmlPublishResponse{URL: envelope.Data.URL}, nil
|
||||
}
|
||||
|
||||
// OAPI business error codes returned by the Miaoda
|
||||
// /apps/{id}/upload_and_release_html_code endpoint. Owned by the backend
|
||||
// service; update when new codes are documented in the OAPI spec.
|
||||
const (
|
||||
errCodeBuildFailed = 90001 // tar.gz uploaded but server-side build failed
|
||||
errCodeAppNotFound = 90002 // app_id unknown or caller lacks permission
|
||||
)
|
||||
|
||||
func buildHTMLPublishFailureHint(code int) string {
|
||||
switch code {
|
||||
case errCodeBuildFailed:
|
||||
return "构建失败:用 `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` 检查打包文件清单"
|
||||
case errCodeAppNotFound:
|
||||
return "应用不存在或无权访问;请用户确认 app_id(从妙搭应用链接 https://miaoda.feishu.cn/app/app_xxx 的 /app/ 后面提取,或直接给 app_xxx 字符串)"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
139
shortcuts/apps/html_publish_client_test.go
Normal file
139
shortcuts/apps/html_publish_client_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
rctx := common.TestNewRuntimeContextForAPI(context.Background(), nil, cfg, factory, core.AsUser)
|
||||
return rctx, reg
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_Success(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"url": "https://miaoda.feishu.cn/app/app_x",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
tarball := &htmlPublishTarball{Body: []byte("fake"), Size: 4, SHA256: "abc"}
|
||||
resp, err := api.HTMLPublish(context.Background(), "app_x", tarball)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if resp.URL != "https://miaoda.feishu.cn/app/app_x" {
|
||||
t.Fatalf("url=%q", resp.URL)
|
||||
}
|
||||
|
||||
ct := stub.CapturedHeaders.Get("Content-Type")
|
||||
mt, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil || mt != "multipart/form-data" {
|
||||
t.Fatalf("content type %q wrong", ct)
|
||||
}
|
||||
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
saw := false
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if p.FormName() == "file" {
|
||||
saw = true
|
||||
}
|
||||
}
|
||||
if !saw {
|
||||
t.Fatalf("multipart missing 'file' part")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": 90001,
|
||||
"msg": "build failed: dependency conflict",
|
||||
},
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with detail, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint on code 90001")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "build failed") {
|
||||
t.Fatalf("missing failure message: %v", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishFailureHint_UnknownCodeReturnsEmpty(t *testing.T) {
|
||||
// 默认分支:未识别的 code 返回空 hint,让 Agent 用 message 兜底。
|
||||
if hint := buildHTMLPublishFailureHint(99999); hint != "" {
|
||||
t.Fatalf("unknown code should return empty hint, got %q", hint)
|
||||
}
|
||||
if hint := buildHTMLPublishFailureHint(0); hint != "" {
|
||||
t.Fatalf("zero code should return empty hint, got %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishFailureHint_KnownCodes(t *testing.T) {
|
||||
if hint := buildHTMLPublishFailureHint(90001); hint == "" {
|
||||
t.Fatalf("code 90001 should return non-empty hint")
|
||||
}
|
||||
if hint := buildHTMLPublishFailureHint(90002); hint == "" {
|
||||
t.Fatalf("code 90002 should return non-empty hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing.T) {
|
||||
hint := buildHTMLPublishFailureHint(90002)
|
||||
if hint == "" {
|
||||
t.Fatalf("code 90002 should return non-empty hint")
|
||||
}
|
||||
if strings.Contains(hint, "+list") {
|
||||
t.Fatalf("hint must not point at hidden +list command, got: %q", hint)
|
||||
}
|
||||
if !strings.Contains(hint, "app_id") {
|
||||
t.Fatalf("hint should reference app_id, got: %q", hint)
|
||||
}
|
||||
}
|
||||
85
shortcuts/apps/html_publish_tarball.go
Normal file
85
shortcuts/apps/html_publish_tarball.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// htmlPublishTarball is the in-memory packed tar.gz ready for multipart upload.
|
||||
// Body is bounded by maxHTMLPublishTarballBytes (20MiB) — see runHTMLPublish.
|
||||
type htmlPublishTarball struct {
|
||||
Body []byte
|
||||
Size int64
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidate) (*htmlPublishTarball, error) {
|
||||
if len(candidates) == 0 {
|
||||
return nil, errors.New("no files to pack")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
hasher := sha256.New()
|
||||
multi := io.MultiWriter(&buf, hasher)
|
||||
gz := gzip.NewWriter(multi)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
for _, c := range candidates {
|
||||
if err := writeHTMLPublishTarEntry(fio, tw, c); err != nil {
|
||||
_ = tw.Close()
|
||||
_ = gz.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
_ = gz.Close()
|
||||
return nil, fmt.Errorf("tar close: %w", err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, fmt.Errorf("gzip close: %w", err)
|
||||
}
|
||||
|
||||
return &htmlPublishTarball{
|
||||
Body: buf.Bytes(),
|
||||
Size: int64(buf.Len()),
|
||||
SHA256: hex.EncodeToString(hasher.Sum(nil)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCandidate) error {
|
||||
if isUnsafeRelPath(c.RelPath) {
|
||||
return fmt.Errorf("invalid tar entry name %q", c.RelPath)
|
||||
}
|
||||
|
||||
src, err := fio.Open(c.AbsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", c.AbsPath, err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
hdr := &tar.Header{
|
||||
Name: c.RelPath,
|
||||
Size: c.Size,
|
||||
Mode: 0o644,
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return fmt.Errorf("write header %s: %w", c.RelPath, err)
|
||||
}
|
||||
if _, err := io.Copy(tw, src); err != nil {
|
||||
return fmt.Errorf("copy %s: %w", c.RelPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
193
shortcuts/apps/html_publish_tarball_test.go
Normal file
193
shortcuts/apps/html_publish_tarball_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// readFailingFIO opens a File whose Read always returns the configured error,
|
||||
// letting tests exercise the io.Copy failure branch without filesystem games.
|
||||
type readFailingFIO struct{ readErr error }
|
||||
|
||||
func (f readFailingFIO) Open(string) (fileio.File, error) {
|
||||
return &readFailingFile{err: f.readErr}, nil
|
||||
}
|
||||
func (f readFailingFIO) Stat(string) (fileio.FileInfo, error) {
|
||||
return nil, errors.New("Stat not used")
|
||||
}
|
||||
func (readFailingFIO) ResolvePath(p string) (string, error) { return p, nil }
|
||||
func (readFailingFIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
|
||||
return nil, errors.New("Save not used")
|
||||
}
|
||||
|
||||
type readFailingFile struct{ err error }
|
||||
|
||||
func (f *readFailingFile) Read([]byte) (int, error) { return 0, f.err }
|
||||
func (f *readFailingFile) ReadAt([]byte, int64) (int, error) { return 0, f.err }
|
||||
func (f *readFailingFile) Close() error { return nil }
|
||||
|
||||
func TestBuildHTMLPublishTarball_RoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
fio := newTestFIO()
|
||||
candidates, err := walkHTMLPublishCandidates(fio, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
tarball, err := buildHTMLPublishTarball(fio, candidates)
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
|
||||
if len(tarball.SHA256) != 64 {
|
||||
t.Fatalf("SHA256 wrong len: %d", len(tarball.SHA256))
|
||||
}
|
||||
if tarball.Size <= 0 || int64(len(tarball.Body)) != tarball.Size {
|
||||
t.Fatalf("size=%d body=%d", tarball.Size, len(tarball.Body))
|
||||
}
|
||||
|
||||
gz, err := gzip.NewReader(bytes.NewReader(tarball.Body))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip: %v", err)
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
hdr, err := tr.Next()
|
||||
if err != nil {
|
||||
t.Fatalf("tar.Next: %v", err)
|
||||
}
|
||||
if hdr.Name != "index.html" {
|
||||
t.Fatalf("entry name = %q, want index.html", hdr.Name)
|
||||
}
|
||||
body, err := io.ReadAll(tr)
|
||||
if err != nil || string(body) != "<html></html>" {
|
||||
t.Fatalf("body=%q err=%v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishTarball_EmptyCandidates(t *testing.T) {
|
||||
if _, err := buildHTMLPublishTarball(newTestFIO(), nil); err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteHTMLPublishTarEntry_OpenFailure(t *testing.T) {
|
||||
// candidate 指向不存在文件 → fio.Open 失败 → 错误返回
|
||||
tw := tar.NewWriter(io.Discard)
|
||||
defer tw.Close()
|
||||
err := writeHTMLPublishTarEntry(newTestFIO(), tw, htmlPublishCandidate{
|
||||
RelPath: "x.html",
|
||||
AbsPath: "/nonexistent-path-for-test/x.html",
|
||||
Size: 0,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for nonexistent abs path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "open") {
|
||||
t.Fatalf("expected open error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteHTMLPublishTarEntry_WriteHeaderFailure(t *testing.T) {
|
||||
// 在已 close 的 tar.Writer 上写 header → WriteHeader 失败
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "x.html")
|
||||
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
tw := tar.NewWriter(io.Discard)
|
||||
_ = tw.Close() // 先 close,下次 WriteHeader 必失败
|
||||
|
||||
err := writeHTMLPublishTarEntry(newTestFIO(), tw, htmlPublishCandidate{
|
||||
RelPath: "x.html",
|
||||
AbsPath: file,
|
||||
Size: 1,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when writing to closed tar.Writer")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "write header") {
|
||||
t.Fatalf("expected 'write header' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteHTMLPublishTarEntry_CopyFailure(t *testing.T) {
|
||||
// 注入一个 Read 必失败的 fileio.File,让 io.Copy 在 tar 写入阶段出错。
|
||||
// 避免 chmod 0o000 的跨平台 / root 用户 flake。
|
||||
fio := readFailingFIO{readErr: errors.New("synthetic read failure")}
|
||||
tw := tar.NewWriter(io.Discard)
|
||||
defer tw.Close()
|
||||
|
||||
err := writeHTMLPublishTarEntry(fio, tw, htmlPublishCandidate{
|
||||
RelPath: "x.html",
|
||||
AbsPath: "fixtures/x.html", // 任意路径,Open 由 stub 接管
|
||||
Size: 7,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when underlying Read fails")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "copy") {
|
||||
t.Fatalf("expected copy-stage error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishTarball_EntryWriteFailureReturnsError(t *testing.T) {
|
||||
// candidate 指向不存在文件 → writeHTMLPublishTarEntry 失败
|
||||
// → buildHTMLPublishTarball 返回 nil tarball + error。
|
||||
candidates := []htmlPublishCandidate{
|
||||
{RelPath: "x.html", AbsPath: "/nonexistent-path-for-test/x.html", Size: 0},
|
||||
}
|
||||
|
||||
tarball, err := buildHTMLPublishTarball(newTestFIO(), candidates)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got tarball=%+v", tarball)
|
||||
}
|
||||
if tarball != nil {
|
||||
t.Fatalf("expected nil tarball on error, got %+v", tarball)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteHTMLPublishTarEntry_RejectsPathTraversal(t *testing.T) {
|
||||
tw := tar.NewWriter(io.Discard)
|
||||
defer tw.Close()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rel string
|
||||
}{
|
||||
{"parent traversal", "../etc/passwd"},
|
||||
{"absolute path", "/etc/passwd"},
|
||||
{"embedded traversal", "a/../../etc/passwd"},
|
||||
{"null byte", "evil\x00.html"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := writeHTMLPublishTarEntry(newTestFIO(), tw, htmlPublishCandidate{
|
||||
RelPath: c.rel,
|
||||
AbsPath: "fixtures/whatever",
|
||||
Size: 0,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for RelPath=%q", c.rel)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid tar entry name") {
|
||||
t.Fatalf("expected 'invalid tar entry name' error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
47
shortcuts/apps/sensitive_paths.go
Normal file
47
shortcuts/apps/sensitive_paths.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import "strings"
|
||||
|
||||
// isSensitiveRelPath reports whether a relative path inside the candidate
|
||||
// manifest looks like something that should not ship to a public-internet
|
||||
// share URL — secrets, credentials, SCM internals, SSH keys. The check is
|
||||
// path-element-wise (each "/"-delimited segment is inspected) so secrets
|
||||
// nested under arbitrary subdirectories are still caught.
|
||||
//
|
||||
// Used by +html-publish dry-run to populate a "warnings" field; the
|
||||
// caller still proceeds (this is advisory, not a hard block) so legit
|
||||
// edge cases (e.g. a documentation site that has a .env example file
|
||||
// on purpose) are not gated, but the user/agent sees the list.
|
||||
func isSensitiveRelPath(rel string) bool {
|
||||
if rel == "" {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
for i, p := range parts {
|
||||
switch {
|
||||
case p == ".git":
|
||||
return true
|
||||
case p == ".env" || strings.HasPrefix(p, ".env."):
|
||||
return true
|
||||
case p == ".npmrc" || p == ".netrc":
|
||||
return true
|
||||
case p == "credentials" || p == "config":
|
||||
if i > 0 {
|
||||
parent := parts[i-1]
|
||||
if parent == ".aws" || parent == ".docker" || parent == ".gcloud" || parent == ".kube" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case strings.HasPrefix(p, "id_rsa") || strings.HasPrefix(p, "id_ed25519") || strings.HasPrefix(p, "id_ecdsa") || strings.HasPrefix(p, "id_dsa"):
|
||||
return true
|
||||
case strings.HasSuffix(p, ".pem") || strings.HasSuffix(p, ".key"):
|
||||
return true
|
||||
case strings.HasSuffix(p, ".json") && p == "config.json" && i > 0 && parts[i-1] == ".docker":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
50
shortcuts/apps/sensitive_paths_test.go
Normal file
50
shortcuts/apps/sensitive_paths_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsSensitiveRelPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
rel string
|
||||
want bool
|
||||
}{
|
||||
// dotfiles and well-known secret stores
|
||||
{".env", true},
|
||||
{".env.local", true},
|
||||
{".env.production", true},
|
||||
{"backend/.env", true},
|
||||
{".npmrc", true},
|
||||
{"sub/.npmrc", true},
|
||||
{".netrc", true},
|
||||
// .git tree
|
||||
{".git/config", true},
|
||||
{".git/HEAD", true},
|
||||
{"subdir/.git/config", true},
|
||||
{".gitignore", false}, // NOT sensitive (intended to be committed)
|
||||
// SSH keys
|
||||
{".ssh/id_rsa", true},
|
||||
{".ssh/id_ed25519", true},
|
||||
{"backup/id_rsa.pub", true}, // pub also flagged (often near private)
|
||||
// Cloud creds
|
||||
{".aws/credentials", true},
|
||||
{".aws/config", true},
|
||||
{".docker/config.json", true},
|
||||
// Generic crypto
|
||||
{"server.pem", true},
|
||||
{"certs/private.key", true},
|
||||
{"path/to/whatever.pem", true},
|
||||
// Benign
|
||||
{"index.html", false},
|
||||
{"dist/main.js", false},
|
||||
{"assets/logo.svg", false},
|
||||
{"README.md", false},
|
||||
{"package.json", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isSensitiveRelPath(c.rel); got != c.want {
|
||||
t.Errorf("isSensitiveRelPath(%q) = %v, want %v", c.rel, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
shortcuts/apps/shortcuts.go
Normal file
18
shortcuts/apps/shortcuts.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all apps domain shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
AppsCreate,
|
||||
AppsUpdate,
|
||||
AppsList,
|
||||
AppsAccessScopeSet,
|
||||
AppsAccessScopeGet,
|
||||
AppsHTMLPublish,
|
||||
}
|
||||
}
|
||||
14
shortcuts/apps/shortcuts_test.go
Normal file
14
shortcuts/apps/shortcuts_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import "testing"
|
||||
|
||||
// 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。
|
||||
func TestAppsShortcuts_Returns6(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
if len(got) != 6 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 6", len(got))
|
||||
}
|
||||
}
|
||||
91
shortcuts/apps/walk_html_publish_candidates.go
Normal file
91
shortcuts/apps/walk_html_publish_candidates.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
type htmlPublishCandidate struct {
|
||||
RelPath string
|
||||
AbsPath string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// isUnsafeRelPath reports whether a forward-slash relative path contains
|
||||
// anything that should never be written into a tar header or treated as
|
||||
// inside-root: leading slash (absolute), .. as a path component (start /
|
||||
// middle / end / whole), or an embedded null byte. Component-aware so it
|
||||
// does not false-positive on legitimate filenames that contain ".." as a
|
||||
// substring (e.g. "archive.tar..bak").
|
||||
func isUnsafeRelPath(rel string) bool {
|
||||
return strings.HasPrefix(rel, "/") ||
|
||||
rel == ".." ||
|
||||
strings.HasPrefix(rel, "../") ||
|
||||
strings.Contains(rel, "/../") ||
|
||||
strings.HasSuffix(rel, "/..") ||
|
||||
strings.ContainsRune(rel, 0)
|
||||
}
|
||||
|
||||
// walkHTMLPublishCandidates walks rootPath and returns each regular file as a
|
||||
// candidate. Stat goes through fileio so SafeInputPath validation runs on the
|
||||
// root; the directory walk itself uses filepath.WalkDir because runtime.FileIO
|
||||
// has no WalkDir equivalent today.
|
||||
func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublishCandidate, error) {
|
||||
stat, err := fio.Stat(rootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat %s: %w", rootPath, err)
|
||||
}
|
||||
if !stat.IsDir() {
|
||||
return []htmlPublishCandidate{{
|
||||
RelPath: filepath.Base(rootPath),
|
||||
AbsPath: rootPath,
|
||||
Size: stat.Size(),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
var out []htmlPublishCandidate
|
||||
//nolint:forbidigo // fileio has no WalkDir; rootPath is already validated above via fio.Stat -> SafeInputPath.
|
||||
err = filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 只接受 regular file —— symlink / device / pipe / socket 都跳过。
|
||||
// symlink 不跟随是设计决策(避免 loop + out-of-root 引用),且 fio.Open 也会拒非 regular。
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(rootPath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
// Defense in depth: WalkDir + Rel inside rootPath should never yield a
|
||||
// path with .. components, but a future logic change or unusual
|
||||
// filesystem layout shouldn't be able to inject one into RelPath.
|
||||
// Mirrors the same guard at tar entry write time.
|
||||
if isUnsafeRelPath(relSlash) {
|
||||
return fmt.Errorf("walker produced unsafe relative path %q for %s", relSlash, path)
|
||||
}
|
||||
out = append(out, htmlPublishCandidate{
|
||||
RelPath: relSlash,
|
||||
AbsPath: path,
|
||||
Size: info.Size(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
140
shortcuts/apps/walk_html_publish_candidates_test.go
Normal file
140
shortcuts/apps/walk_html_publish_candidates_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// permissiveFIO is a test-only fileio that delegates to os without
|
||||
// SafeInputPath validation. Unit tests use it so we can drive the walker
|
||||
// and tarball algorithms with absolute t.TempDir paths; production code
|
||||
// goes through LocalFileIO which is cwd-bounded.
|
||||
type permissiveFIO struct{}
|
||||
|
||||
func (permissiveFIO) Open(name string) (fileio.File, error) { return os.Open(name) }
|
||||
func (permissiveFIO) Stat(name string) (fileio.FileInfo, error) { return os.Stat(name) }
|
||||
func (permissiveFIO) ResolvePath(p string) (string, error) { return p, nil }
|
||||
func (permissiveFIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
|
||||
panic("Save not used in apps unit tests")
|
||||
}
|
||||
|
||||
func newTestFIO() fileio.FileIO { return permissiveFIO{} }
|
||||
|
||||
func TestWalkHTMLPublishCandidates_SingleFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "index.html")
|
||||
if err := os.WriteFile(file, []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
got, err := walkHTMLPublishCandidates(newTestFIO(), file)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].RelPath != "index.html" || got[0].Size != 13 {
|
||||
t.Fatalf("got=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkHTMLPublishCandidates_Directory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"index.html": "<html></html>",
|
||||
"css/main.css": "body{}",
|
||||
"assets/logo.svg": "<svg/>",
|
||||
}
|
||||
for rel, content := range files {
|
||||
full := filepath.Join(dir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d candidates, want 3", len(got))
|
||||
}
|
||||
rels := make([]string, 3)
|
||||
for i, c := range got {
|
||||
rels[i] = c.RelPath
|
||||
}
|
||||
sort.Strings(rels)
|
||||
want := []string{"assets/logo.svg", "css/main.css", "index.html"}
|
||||
for i, w := range want {
|
||||
if rels[i] != w {
|
||||
t.Fatalf("rel[%d]=%q want %q", i, rels[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkHTMLPublishCandidates_NotFound(t *testing.T) {
|
||||
if _, err := walkHTMLPublishCandidates(newTestFIO(), "/nonexistent/xyz"); err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUnsafeRelPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
rel string
|
||||
want bool
|
||||
}{
|
||||
{"index.html", false},
|
||||
{"assets/logo.svg", false},
|
||||
{"deep/nested/path/file.html", false},
|
||||
{"archive.tar..bak", false},
|
||||
{"version.1..2.html", false},
|
||||
{"..config", false},
|
||||
{"", false},
|
||||
{"/etc/passwd", true},
|
||||
{"..", true},
|
||||
{"../etc/passwd", true},
|
||||
{"a/../../etc/passwd", true},
|
||||
{"a/..", true},
|
||||
{"evil\x00.html", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isUnsafeRelPath(c.rel); got != c.want {
|
||||
t.Errorf("isUnsafeRelPath(%q) = %v, want %v", c.rel, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkHTMLPublishCandidates_SymlinkSkipped(t *testing.T) {
|
||||
// Walker 只接受 regular file —— symlink 跳过(避免 loop + out-of-root 引用,
|
||||
// 且 fio.Open 对 symlink 行为不一致)。real.html 仍然被收,link.html 不在结果里。
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "real.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(dir, "real.html"), filepath.Join(dir, "link.html")); err != nil {
|
||||
t.Skipf("symlink not supported on this filesystem: %v", err)
|
||||
}
|
||||
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
rels := make(map[string]bool)
|
||||
for _, c := range got {
|
||||
rels[c.RelPath] = true
|
||||
}
|
||||
if !rels["real.html"] {
|
||||
t.Fatalf("expected real.html (regular file) in candidates, got %+v", got)
|
||||
}
|
||||
if rels["link.html"] {
|
||||
t.Fatalf("symlink link.html should NOT appear in candidates, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -3,29 +3,61 @@
|
||||
|
||||
package common
|
||||
|
||||
// FetchDriveMetaTitle looks up the document title via the drive metas batch_query API.
|
||||
func FetchDriveMetaTitle(runtime *RuntimeContext, token, docType string) (string, error) {
|
||||
// DriveMeta is the subset of drive metas/batch_query fields used by shortcuts.
|
||||
type DriveMeta struct {
|
||||
Title string
|
||||
URL string
|
||||
}
|
||||
|
||||
// FetchDriveMeta looks up document metadata via the drive metas batch_query API.
|
||||
func FetchDriveMeta(runtime *RuntimeContext, token, docType string, withURL bool) (DriveMeta, error) {
|
||||
body := map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": token,
|
||||
"doc_type": docType,
|
||||
},
|
||||
},
|
||||
}
|
||||
if withURL {
|
||||
body["with_url"] = true
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": token,
|
||||
"doc_type": docType,
|
||||
},
|
||||
},
|
||||
},
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return DriveMeta{}, err
|
||||
}
|
||||
|
||||
metas := GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return "", nil
|
||||
return DriveMeta{}, nil
|
||||
}
|
||||
meta, _ := metas[0].(map[string]interface{})
|
||||
return GetString(meta, "title"), nil
|
||||
return DriveMeta{
|
||||
Title: GetString(meta, "title"),
|
||||
URL: GetString(meta, "url"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FetchDriveMetaTitle looks up the document title via the drive metas batch_query API.
|
||||
func FetchDriveMetaTitle(runtime *RuntimeContext, token, docType string) (string, error) {
|
||||
meta, err := FetchDriveMeta(runtime, token, docType, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return meta.Title, nil
|
||||
}
|
||||
|
||||
// FetchDriveMetaURL looks up the document access URL via the drive metas batch_query API.
|
||||
func FetchDriveMetaURL(runtime *RuntimeContext, token, docType string) (string, error) {
|
||||
meta, err := FetchDriveMeta(runtime, token, docType, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return meta.URL, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -105,6 +106,44 @@ func TestFetchDriveMetaTitle(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestFetchDriveMetaURL(t *testing.T) {
|
||||
runtime, reg := newDriveMetaTestRuntime(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": "boxcnABC",
|
||||
"doc_type": "file",
|
||||
"title": "report.pdf",
|
||||
"url": "https://tenant.example.com/file/boxcnABC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
got, err := FetchDriveMetaURL(runtime, "boxcnABC", "file")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchDriveMetaURL() error: %v", err)
|
||||
}
|
||||
if got != "https://tenant.example.com/file/boxcnABC" {
|
||||
t.Fatalf("url = %q, want tenant URL", got)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode captured body: %v", err)
|
||||
}
|
||||
if body["with_url"] != true {
|
||||
t.Fatalf("with_url = %#v, want true", body["with_url"])
|
||||
}
|
||||
}
|
||||
|
||||
func newDriveMetaTestRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -34,6 +34,7 @@ func AutoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
|
||||
PermissionGrantSkipped,
|
||||
"",
|
||||
fmt.Sprintf("The operation did not return a permission target (missing token/type), so current user %s was not granted. You can retry later or continue using bot identity.", permissionGrantPermMessage()),
|
||||
"No permission target (missing token or type) returned by the operation.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,11 +44,14 @@ func AutoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
|
||||
func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourceType string) map[string]interface{} {
|
||||
userOpenID := strings.TrimSpace(runtime.UserOpenId())
|
||||
if userOpenID == "" {
|
||||
return buildPermissionGrantResult(
|
||||
result := buildPermissionGrantResult(
|
||||
PermissionGrantSkipped,
|
||||
"",
|
||||
fmt.Sprintf("Resource was created with bot identity, but no current CLI user open_id is configured, so current user %s was not granted. You can retry later or continue using bot identity.", permissionGrantPermMessage()),
|
||||
"No current user identity (not logged in or session expired).",
|
||||
)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Warning: resource was created with bot identity, but no current user open_id is configured, so auto-grant was skipped. Run `lark-cli auth login` and retry, or grant permission manually.\n")
|
||||
return result
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
@@ -70,21 +74,26 @@ func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return buildPermissionGrantResult(
|
||||
errMsg := compactPermissionGrantError(err)
|
||||
result := buildPermissionGrantResult(
|
||||
PermissionGrantFailed,
|
||||
userOpenID,
|
||||
fmt.Sprintf("Resource was created, but granting current user %s failed: %s. You can retry later or continue using bot identity.", permissionGrantPermMessage(), compactPermissionGrantError(err)),
|
||||
fmt.Sprintf("Resource was created, but granting current user %s failed: %s. You can retry later or continue using bot identity.", permissionGrantPermMessage(), errMsg),
|
||||
fmt.Sprintf("Auto-grant failed: %s. The app may lack the required scope or the resource restricts permission changes.", errMsg),
|
||||
)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Warning: resource was created, but auto-grant failed: %s. Retry later or grant permission manually.\n", errMsg)
|
||||
return result
|
||||
}
|
||||
|
||||
return buildPermissionGrantResult(
|
||||
PermissionGrantGranted,
|
||||
userOpenID,
|
||||
fmt.Sprintf("Granted the current CLI user %s on the new %s.", permissionGrantPermMessage(), permissionTargetLabel(resourceType)),
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
func buildPermissionGrantResult(status, userOpenID, message string) map[string]interface{} {
|
||||
func buildPermissionGrantResult(status, userOpenID, message, reason string) map[string]interface{} {
|
||||
result := map[string]interface{}{
|
||||
"status": status,
|
||||
"perm": permissionGrantPerm,
|
||||
@@ -94,6 +103,11 @@ func buildPermissionGrantResult(status, userOpenID, message string) map[string]i
|
||||
result["user_open_id"] = userOpenID
|
||||
result["member_type"] = "openid"
|
||||
}
|
||||
if status == PermissionGrantSkipped {
|
||||
result["hint"] = reason + " Run `lark-cli auth login` and retry, or grant permission manually via the Lark document UI."
|
||||
} else if status == PermissionGrantFailed {
|
||||
result["hint"] = reason + " Retry later or grant permission manually via the Lark document UI."
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
96
shortcuts/common/permission_grant_test.go
Normal file
96
shortcuts/common/permission_grant_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) {
|
||||
config := &core.CliConfig{
|
||||
AppID: "perm-grant-test-skip",
|
||||
AppSecret: "perm-grant-test-secret-skip",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, config)
|
||||
|
||||
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-1")
|
||||
runtime := &RuntimeContext{
|
||||
ctx: ctx,
|
||||
Config: config,
|
||||
Factory: f,
|
||||
resolvedAs: core.AsBot,
|
||||
}
|
||||
|
||||
result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx")
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result for bot mode with empty user open_id")
|
||||
}
|
||||
if result["status"] != PermissionGrantSkipped {
|
||||
t.Fatalf("status = %v, want %q", result["status"], PermissionGrantSkipped)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "auto-grant was skipped") {
|
||||
t.Fatalf("stderr missing auto-grant skipped warning; got:\n%s", stderr.String())
|
||||
}
|
||||
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
|
||||
t.Fatalf("hint = %#v, want string containing 'auth login'", result["hint"])
|
||||
}
|
||||
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "not logged in") {
|
||||
t.Fatalf("hint = %#v, want string containing 'not logged in'", result["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoGrantStderrWarning_GrantFailed(t *testing.T) {
|
||||
config := &core.CliConfig{
|
||||
AppID: "perm-grant-test-fail",
|
||||
AppSecret: "perm-grant-test-secret-fail",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test_user",
|
||||
}
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, config)
|
||||
|
||||
// Register a stub that returns an error code so CallAPI returns an error.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/tkn_doc/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-2")
|
||||
runtime := &RuntimeContext{
|
||||
ctx: ctx,
|
||||
Config: config,
|
||||
Factory: f,
|
||||
resolvedAs: core.AsBot,
|
||||
}
|
||||
|
||||
result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx")
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result for bot mode with grant failure")
|
||||
}
|
||||
if result["status"] != PermissionGrantFailed {
|
||||
t.Fatalf("status = %v, want %q", result["status"], PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "auto-grant failed") {
|
||||
t.Fatalf("stderr missing auto-grant failed warning; got:\n%s", stderr.String())
|
||||
}
|
||||
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
|
||||
t.Fatalf("hint = %#v, want string containing 'Retry later'", result["hint"])
|
||||
}
|
||||
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "scope") {
|
||||
t.Fatalf("hint = %#v, want string containing 'scope'", result["hint"])
|
||||
}
|
||||
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "permission changes") {
|
||||
t.Fatalf("hint = %#v, want string containing 'permission changes'", result["hint"])
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -47,6 +47,34 @@ const defaultLocateDocLimit = 10
|
||||
// with `drive file.comments create_v2` against a fresh docx.
|
||||
const maxCommentTotalRunes = 10000
|
||||
|
||||
// The file comment API treats supported Drive file comments as full-file
|
||||
// comments in the UI, but currently rejects an empty anchor.block_id for file
|
||||
// targets. TODO: remove this placeholder after the API accepts omitting
|
||||
// anchor.block_id for file full comments.
|
||||
const fileFullCommentAnchorBlockID = "test"
|
||||
|
||||
// File comments are enabled only for extensions verified to render correctly in
|
||||
// the Lark file preview comment UI. Keep this list conservative: PDF, docx, and
|
||||
// xlsx currently accept the API request but display poorly in the page.
|
||||
var supportedFileCommentExtensions = []string{
|
||||
".md",
|
||||
".txt",
|
||||
".json",
|
||||
".csv",
|
||||
".go",
|
||||
".js",
|
||||
".py",
|
||||
".pptx",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".zip",
|
||||
".mp3",
|
||||
".mp4",
|
||||
}
|
||||
|
||||
var supportedFileCommentExtensionSet = newSupportedFileCommentExtensionSet(supportedFileCommentExtensions)
|
||||
|
||||
type commentDocRef struct {
|
||||
Kind string
|
||||
Token string
|
||||
@@ -93,17 +121,18 @@ const (
|
||||
var DriveAddComment = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+add-comment",
|
||||
Description: "Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides",
|
||||
Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only",
|
||||
Risk: "write",
|
||||
Scopes: []string{
|
||||
"drive:drive.metadata:readonly",
|
||||
"docx:document:readonly",
|
||||
"docs:document.comment:create",
|
||||
"docs:document.comment:write_only",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/sheet/slides", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "sheet", "slides"}},
|
||||
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true},
|
||||
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
|
||||
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
|
||||
@@ -145,7 +174,6 @@ var DriveAddComment = common.Shortcut{
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if strings.TrimSpace(selection) != "" && blockID != "" {
|
||||
@@ -156,6 +184,9 @@ var DriveAddComment = common.Shortcut{
|
||||
}
|
||||
|
||||
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
|
||||
if docRef.Kind == "file" {
|
||||
return validateFileCommentMode(mode, "")
|
||||
}
|
||||
if mode == commentModeLocal && docRef.Kind == "doc" {
|
||||
return output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
|
||||
}
|
||||
@@ -217,6 +248,33 @@ var DriveAddComment = common.Shortcut{
|
||||
Body(commentBody).
|
||||
Set("file_token", resolvedToken)
|
||||
}
|
||||
if resolvedKind == "file" {
|
||||
commentBody := buildCommentCreateV2Request("file", "", "", replyElements, nil)
|
||||
desc := "2-step orchestration: verify supported file metadata -> create file comment"
|
||||
verifyStep := "[1]"
|
||||
createStep := "[2]"
|
||||
if isWiki {
|
||||
desc = "3-step orchestration: resolve wiki -> verify supported file metadata -> create file comment"
|
||||
verifyStep = "[2]"
|
||||
createStep = "[3]"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc(verifyStep+" Read file metadata and verify the title extension is supported").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": resolvedToken,
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
}).
|
||||
POST("/open-apis/drive/v1/files/:file_token/new_comments").
|
||||
Desc(createStep+" Create file full comment").
|
||||
Body(commentBody).
|
||||
Set("file_token", resolvedToken)
|
||||
}
|
||||
|
||||
// Doc/docx comment dry-run.
|
||||
createPath := "/open-apis/drive/v1/files/:file_token/new_comments"
|
||||
@@ -317,6 +375,9 @@ var DriveAddComment = common.Shortcut{
|
||||
if target.FileType == "slides" {
|
||||
return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken})
|
||||
}
|
||||
if target.FileType == "file" {
|
||||
return executeFileComment(runtime, target)
|
||||
}
|
||||
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
@@ -421,6 +482,9 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
if token, ok := extractURLToken(raw, "/sheets/"); ok {
|
||||
return commentDocRef{Kind: "sheet", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/file/"); ok {
|
||||
return commentDocRef{Kind: "file", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/slides/"); ok {
|
||||
return commentDocRef{Kind: "slides", Token: token}, nil
|
||||
}
|
||||
@@ -431,7 +495,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
return commentDocRef{Kind: "doc", Token: token}, nil
|
||||
}
|
||||
if strings.Contains(raw, "://") {
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/sheet/slides", raw)
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw)
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
|
||||
@@ -440,7 +504,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
// Bare token: --type is required.
|
||||
docType = strings.TrimSpace(docType)
|
||||
if docType == "" {
|
||||
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, sheet, slides)")
|
||||
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)")
|
||||
}
|
||||
return commentDocRef{Kind: docType, Token: raw}, nil
|
||||
}
|
||||
@@ -451,9 +515,16 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
return resolvedCommentTarget{}, err
|
||||
}
|
||||
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
|
||||
if mode == commentModeLocal && docRef.Kind != "docx" && docRef.Kind != "sheet" && docRef.Kind != "slides" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
|
||||
if mode == commentModeLocal {
|
||||
switch docRef.Kind {
|
||||
case "doc":
|
||||
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
|
||||
case "file":
|
||||
if err := validateFileCommentMode(mode, ""); err != nil {
|
||||
return resolvedCommentTarget{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolvedCommentTarget{
|
||||
DocID: docRef.Token,
|
||||
@@ -507,11 +578,24 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
WikiToken: docRef.Token,
|
||||
}, nil
|
||||
}
|
||||
if objType == "file" {
|
||||
if err := validateFileCommentMode(mode, objType); err != nil {
|
||||
return resolvedCommentTarget{}, err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return resolvedCommentTarget{
|
||||
DocID: objToken,
|
||||
FileToken: objToken,
|
||||
FileType: "file",
|
||||
ResolvedBy: "wiki",
|
||||
WikiToken: docRef.Token,
|
||||
}, nil
|
||||
}
|
||||
if mode == commentModeLocal && objType != "docx" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
|
||||
}
|
||||
if mode == commentModeFull && objType != "docx" && objType != "doc" {
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/sheet/slides", objType)
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -718,6 +802,10 @@ func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, reply
|
||||
"sheet_col": sheet.Col,
|
||||
"sheet_row": sheet.Row,
|
||||
}
|
||||
} else if fileType == "file" {
|
||||
body["anchor"] = map[string]interface{}{
|
||||
"block_id": fileFullCommentAnchorBlockID,
|
||||
}
|
||||
} else if strings.TrimSpace(blockID) != "" {
|
||||
body["anchor"] = map[string]interface{}{
|
||||
"block_id": blockID,
|
||||
@@ -809,6 +897,107 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) {
|
||||
return &sheetAnchor{SheetID: sheetID, Col: col, Row: row}, nil
|
||||
}
|
||||
|
||||
func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken string) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": fileToken,
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
metas := common.GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken))
|
||||
}
|
||||
meta, ok := metas[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken))
|
||||
}
|
||||
return common.GetString(meta, "title"), nil
|
||||
}
|
||||
|
||||
func ensureSupportedFileCommentTarget(runtime *common.RuntimeContext, fileToken string) (string, string, error) {
|
||||
title, err := fetchCommentTargetFileTitle(runtime, fileToken)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
extension := fileCommentExtension(title)
|
||||
if isSupportedFileCommentExtension(extension) {
|
||||
return title, extension, nil
|
||||
}
|
||||
if strings.TrimSpace(title) == "" {
|
||||
return "", "", output.ErrWithHint(
|
||||
output.ExitValidation,
|
||||
"unsupported_file_comment_type",
|
||||
"drive +add-comment does not support comments for this Drive file type yet; the file metadata did not return a title",
|
||||
"file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(),
|
||||
)
|
||||
}
|
||||
extensionLabel := extension
|
||||
if extensionLabel == "" {
|
||||
extensionLabel = "no extension"
|
||||
}
|
||||
return "", "", output.ErrWithHint(
|
||||
output.ExitValidation,
|
||||
"unsupported_file_comment_type",
|
||||
fmt.Sprintf("drive +add-comment does not support comments for this Drive file type yet; got %q (%s)", title, extensionLabel),
|
||||
"file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(),
|
||||
)
|
||||
}
|
||||
|
||||
func fileCommentExtension(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
idx := strings.LastIndex(title, ".")
|
||||
if idx == 0 {
|
||||
extension := strings.ToLower(title)
|
||||
if isSupportedFileCommentExtension(extension) {
|
||||
return extension
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if idx < 0 || idx == len(title)-1 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(title[idx:])
|
||||
}
|
||||
|
||||
func isSupportedFileCommentExtension(extension string) bool {
|
||||
_, ok := supportedFileCommentExtensionSet[strings.TrimSpace(extension)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func supportedFileCommentExtensionsText() string {
|
||||
return strings.Join(supportedFileCommentExtensions, ", ")
|
||||
}
|
||||
|
||||
func newSupportedFileCommentExtensionSet(extensions []string) map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(extensions))
|
||||
for _, extension := range extensions {
|
||||
set[extension] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func validateFileCommentMode(mode commentMode, resolvedObjType string) error {
|
||||
if mode != commentModeLocal {
|
||||
return nil
|
||||
}
|
||||
if resolvedObjType != "" {
|
||||
return output.ErrValidation("wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType)
|
||||
}
|
||||
return output.ErrValidation("file comments only support full comments; omit --block-id and --selection-with-ellipsis")
|
||||
}
|
||||
|
||||
func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
@@ -849,6 +1038,48 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
title, extension, err := ensureSupportedFileCommentTarget(runtime, target.FileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
|
||||
requestBody := buildCommentCreateV2Request("file", "", "", replyElements, nil)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating file comment in %s (%s)\n", common.MaskToken(target.FileToken), extension)
|
||||
|
||||
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"comment_id": data["comment_id"],
|
||||
"doc_id": target.DocID,
|
||||
"file_token": target.FileToken,
|
||||
"file_type": "file",
|
||||
"file_name": title,
|
||||
"file_extension": extension,
|
||||
"resolved_by": target.ResolvedBy,
|
||||
"comment_mode": string(commentModeFull),
|
||||
}
|
||||
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
|
||||
out["created_at"] = createdAt
|
||||
}
|
||||
if target.WikiToken != "" {
|
||||
out["wiki_token"] = target.WikiToken
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeSlidesComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
|
||||
@@ -105,6 +105,13 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantKind: "doc",
|
||||
wantToken: "docToken",
|
||||
},
|
||||
{
|
||||
name: "raw token with type file",
|
||||
input: "fileToken",
|
||||
docType: "file",
|
||||
wantKind: "file",
|
||||
wantToken: "fileToken",
|
||||
},
|
||||
{
|
||||
name: "raw token without type",
|
||||
input: "xxxxxx",
|
||||
@@ -122,6 +129,12 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantKind: "slides",
|
||||
wantToken: "pres_123",
|
||||
},
|
||||
{
|
||||
name: "file url",
|
||||
input: "https://example.larksuite.com/file/boxcn123?from=share",
|
||||
wantKind: "file",
|
||||
wantToken: "boxcn123",
|
||||
},
|
||||
{
|
||||
name: "unsupported url",
|
||||
input: "https://example.com/not-a-doc",
|
||||
@@ -545,6 +558,29 @@ func TestBuildCommentCreateV2RequestFull(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommentCreateV2RequestFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
replyElements := []map[string]interface{}{
|
||||
{
|
||||
"type": "text",
|
||||
"text": "README comment",
|
||||
},
|
||||
}
|
||||
got := buildCommentCreateV2Request("file", "", "", replyElements, nil)
|
||||
|
||||
if got["file_type"] != "file" {
|
||||
t.Fatalf("expected file_type file, got %#v", got["file_type"])
|
||||
}
|
||||
anchor, ok := got["anchor"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected anchor map, got %#v", got["anchor"])
|
||||
}
|
||||
if blockID, ok := anchor["block_id"].(string); !ok || blockID != fileFullCommentAnchorBlockID {
|
||||
t.Fatalf("expected file anchor.block_id %q, got %#v", fileFullCommentAnchorBlockID, anchor["block_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -906,6 +942,34 @@ func TestSlidesCommentValidateCompoundBlockID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommentValidateRejectsBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/file/fileToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "blk_123",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "file comments only support full comments") {
|
||||
t.Fatalf("expected file local-comment rejection, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/file/fileToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--selection-with-ellipsis", "something",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "file comments only support full comments") {
|
||||
t.Fatalf("expected file local-comment rejection, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slides comment execute tests ────────────────────────────────────────────
|
||||
|
||||
func TestSlidesCommentExecuteSuccess(t *testing.T) {
|
||||
@@ -1116,6 +1180,146 @@ func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommentExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"metas": []interface{}{
|
||||
map[string]interface{}{"title": "README.txt"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/fileToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "fileComment123", "created_at": 1700000000},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/file/fileToken",
|
||||
"--content", `[{"type":"text","text":"请补充 README 示例"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "fileComment123") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "file" {
|
||||
t.Fatalf("stdout file_type = %q, want file\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, data, "file_name", "data.file_name"); got != "README.txt" {
|
||||
t.Fatalf("stdout file_name = %q, want README.txt\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, data, "file_extension", "data.file_extension"); got != ".txt" {
|
||||
t.Fatalf("stdout file_extension = %q, want .txt\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommentExecuteRejectsUnsupportedFileType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"metas": []interface{}{
|
||||
map[string]interface{}{"title": "notes.pdf"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/file/fileToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not support comments for this Drive file type yet") {
|
||||
t.Fatalf("expected unsupported file comment type error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "notes.pdf") {
|
||||
t.Fatalf("expected error to mention unsupported title, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommentExecuteRejectsUnexpectedMetadataFormat(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"metas": []interface{}{"unexpected"},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/file/fileToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unexpected metadata format") {
|
||||
t.Fatalf("expected unexpected metadata format error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommentSupportedExtensions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
supported := []string{
|
||||
"README.md",
|
||||
"notes.TXT",
|
||||
"data.json",
|
||||
"table.csv",
|
||||
"main.go",
|
||||
"app.js",
|
||||
"script.py",
|
||||
"slides.pptx",
|
||||
"image.png",
|
||||
"photo.jpg",
|
||||
"photo.jpeg",
|
||||
".md",
|
||||
"archive.zip",
|
||||
"audio.mp3",
|
||||
"video.mp4",
|
||||
}
|
||||
for _, title := range supported {
|
||||
extension := fileCommentExtension(title)
|
||||
if !isSupportedFileCommentExtension(extension) {
|
||||
t.Fatalf("%s extension %q should be supported", title, extension)
|
||||
}
|
||||
}
|
||||
|
||||
unsupported := []string{
|
||||
"report.pdf",
|
||||
"word.docx",
|
||||
"sheet.xlsx",
|
||||
"unknown.bin",
|
||||
"no-extension",
|
||||
".gitignore",
|
||||
}
|
||||
for _, title := range unsupported {
|
||||
extension := fileCommentExtension(title)
|
||||
if isSupportedFileCommentExtension(extension) {
|
||||
t.Fatalf("%s extension %q should not be supported", title, extension)
|
||||
}
|
||||
}
|
||||
if extension := fileCommentExtension(".gitignore"); extension != "" {
|
||||
t.Fatalf("dotfile extension = %q, want empty", extension)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DryRun coverage ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestDryRunSheetDirectURL(t *testing.T) {
|
||||
@@ -1346,6 +1550,43 @@ func TestDryRunDocxFullComment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunFileDirectURL(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/file/fileToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "verify supported file metadata") {
|
||||
t.Fatalf("dry-run output missing supported file metadata verification step: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "api")
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("expected 2 dry-run api calls, got %d\nstdout:\n%s", len(api), stdout.String())
|
||||
}
|
||||
verifyCall := mustMapValue(t, api[0], "api[0]")
|
||||
createCall := mustMapValue(t, api[1], "api[1]")
|
||||
verifyBody := mustMapValue(t, verifyCall["body"], "api[0].body")
|
||||
createBody := mustMapValue(t, createCall["body"], "api[1].body")
|
||||
requestDocs := mustSliceValue(t, verifyBody["request_docs"], "api[0].body.request_docs")
|
||||
requestDoc := mustMapValue(t, requestDocs[0], "api[0].body.request_docs[0]")
|
||||
if got := mustStringField(t, requestDoc, "doc_type", "api[0].body.request_docs[0].doc_type"); got != "file" {
|
||||
t.Fatalf("metadata query doc_type = %q, want file\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, createBody, "file_type", "api[1].body.file_type"); got != "file" {
|
||||
t.Fatalf("comment create file_type = %q, want file\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
anchor := mustMapValue(t, createBody["anchor"], "api[1].body.anchor")
|
||||
if got := mustStringField(t, anchor, "block_id", "api[1].body.anchor.block_id"); got != fileFullCommentAnchorBlockID {
|
||||
t.Fatalf("comment create anchor.block_id = %q, want %q\nstdout:\n%s", got, fileFullCommentAnchorBlockID, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── resolveCommentTarget coverage ───────────────────────────────────────────
|
||||
|
||||
func TestResolveWikiToDocxFullComment(t *testing.T) {
|
||||
@@ -1397,7 +1638,7 @@ func TestResolveWikiToUnsupportedType(t *testing.T) {
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/sheet/slides") {
|
||||
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/file/sheet/slides") {
|
||||
t.Fatalf("expected unsupported type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"},
|
||||
|
||||
@@ -131,15 +131,15 @@ func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
}
|
||||
|
||||
switch spec.DocType {
|
||||
case "doc", "docx", "sheet", "bitable":
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
default:
|
||||
return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable", spec.DocType)
|
||||
return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType)
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base":
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base", spec.FileExtension)
|
||||
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension)
|
||||
}
|
||||
|
||||
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
|
||||
@@ -150,6 +150,14 @@ func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
return output.ErrValidation("--file-extension base only supports --doc-type bitable")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
|
||||
return output.ErrValidation("--file-extension pptx only supports --doc-type slides")
|
||||
}
|
||||
|
||||
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
|
||||
return output.ErrValidation("--doc-type slides only supports --file-extension pptx or pdf")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
|
||||
return output.ErrValidation("--sub-id is only used when exporting sheet/bitable as csv")
|
||||
@@ -345,6 +353,8 @@ func exportFileSuffix(fileExtension string) string {
|
||||
return ".csv"
|
||||
case "base":
|
||||
return ".base"
|
||||
case "pptx":
|
||||
return ".pptx"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -70,4 +70,10 @@ func TestSanitizeExportFileNameAndEnsureExtension(t *testing.T) {
|
||||
if got := exportFileSuffix("base"); got != ".base" {
|
||||
t.Fatalf("exportFileSuffix(base) = %q, want %q", got, ".base")
|
||||
}
|
||||
if got := ensureExportFileExtension("report", "pptx"); got != "report.pptx" {
|
||||
t.Fatalf("ensureExportFileExtension() = %q, want %q", got, "report.pptx")
|
||||
}
|
||||
if got := ensureExportFileExtension("report.pptx", "pptx"); got != "report.pptx" {
|
||||
t.Fatalf("ensureExportFileExtension() should preserve suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +50,34 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
name: "base bitable ok",
|
||||
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "base"},
|
||||
},
|
||||
{
|
||||
name: "slides pptx ok",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "pptx"},
|
||||
},
|
||||
{
|
||||
name: "slides pdf ok",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "base non bitable rejected",
|
||||
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "base"},
|
||||
wantErr: "only supports --doc-type bitable",
|
||||
},
|
||||
{
|
||||
name: "pptx non slides rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pptx"},
|
||||
wantErr: "only supports --doc-type slides",
|
||||
},
|
||||
{
|
||||
name: "slides csv rejected",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "csv"},
|
||||
wantErr: "slides only supports",
|
||||
},
|
||||
{
|
||||
name: "unknown doc type rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "unknown", FileExtension: "pdf"},
|
||||
wantErr: "invalid --doc-type",
|
||||
},
|
||||
{
|
||||
name: "unknown file extension rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "rtf"},
|
||||
|
||||
@@ -604,9 +604,9 @@ func TestDriveUploadSmallFileToWiki(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
|
||||
func TestDriveUploadUsesMetaURLForExplorerParent(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-explorer-fallback-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
AppID: "drive-upload-explorer-meta-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
|
||||
@@ -615,12 +615,21 @@ func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
// upload_all only ever returns file_token; url is never present —
|
||||
// this exercises the fallback path unconditionally for explorer
|
||||
// parents.
|
||||
"data": map[string]interface{}{"file_token": "file_explorer_small"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "file_explorer_small", "doc_type": "file", "url": "https://tenant.example.com/file/file_explorer_small"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
origDir, _ := os.Getwd()
|
||||
@@ -641,14 +650,14 @@ func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got, want := data["url"], "https://www.feishu.cn/file/file_explorer_small"; got != want {
|
||||
t.Fatalf("data.url = %#v, want %q (brand-standard fallback)", got, want)
|
||||
if got, want := data["url"], "https://tenant.example.com/file/file_explorer_small"; got != want {
|
||||
t.Fatalf("data.url = %#v, want %q (metadata URL)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
|
||||
func TestDriveUploadUsesMetaURLForWikiParent(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-wiki-no-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
AppID: "drive-upload-wiki-meta-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
|
||||
@@ -660,6 +669,18 @@ func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
|
||||
"data": map[string]interface{}{"file_token": "file_wiki_small"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "file_wiki_small", "doc_type": "file", "url": "https://tenant.example.com/file/file_wiki_small"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
@@ -677,8 +698,8 @@ func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if _, ok := data["url"]; ok {
|
||||
t.Fatalf("data.url should be omitted for wiki-hosted files (no standalone URL); got %#v", data["url"])
|
||||
if got, want := data["url"], "https://tenant.example.com/file/file_wiki_small"; got != want {
|
||||
t.Fatalf("data.url = %#v, want %q (metadata URL)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1078,14 +1099,15 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Body["parent_type"] != driveUploadParentTypeWiki {
|
||||
t.Fatalf("parent_type = %#v, want %q", got.API[0].Body["parent_type"], driveUploadParentTypeWiki)
|
||||
@@ -1093,6 +1115,12 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
|
||||
if got.API[0].Body["parent_node"] != "wikcn_dryrun_upload_target" {
|
||||
t.Fatalf("parent_node = %#v, want %q", got.API[0].Body["parent_node"], "wikcn_dryrun_upload_target")
|
||||
}
|
||||
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[1].URL)
|
||||
}
|
||||
if got.API[1].Body["with_url"] != true {
|
||||
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
|
||||
@@ -1168,18 +1196,25 @@ func TestDriveUploadDryRunIncludesFileToken(t *testing.T) {
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
|
||||
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
|
||||
}
|
||||
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[1].URL)
|
||||
}
|
||||
if got.API[1].Body["with_url"] != true {
|
||||
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadDryRunBotOverwriteSkipsPermissionGrantHint(t *testing.T) {
|
||||
@@ -1222,8 +1257,8 @@ func TestDriveUploadDryRunBotOverwriteSkipsPermissionGrantHint(t *testing.T) {
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
|
||||
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
|
||||
|
||||
@@ -284,3 +284,94 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestDriveUploadBotAutoGrantSkippedNoUser(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, ""))
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "file_skipped",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("report.pdf", []byte("pdf"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpload, []string{
|
||||
"+upload",
|
||||
"--file", "report.pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
|
||||
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadBotAutoGrantFailed(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "file_grant_fail",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/file_grant_fail/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("report.pdf", []byte("pdf"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpload, []string{
|
||||
"+upload",
|
||||
"--file", "report.pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
|
||||
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ var DriveUpload = common.Shortcut{
|
||||
Command: "+upload",
|
||||
Description: "Upload a local file to Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:file:upload"},
|
||||
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
@@ -124,11 +124,22 @@ var DriveUpload = common.Shortcut{
|
||||
body["file_token"] = spec.FileToken
|
||||
}
|
||||
d := common.NewDryRunAPI().
|
||||
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload)").
|
||||
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload), then fetch the real Drive URL via metadata").
|
||||
POST("/open-apis/drive/v1/files/upload_all").
|
||||
Body(body)
|
||||
d.POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("Fetch the uploaded file's real access URL").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": "<file_token from upload response>",
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
"with_url": true,
|
||||
})
|
||||
if runtime.IsBot() && !isOverwrite {
|
||||
d.Desc("After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
|
||||
d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
@@ -165,13 +176,10 @@ var DriveUpload = common.Shortcut{
|
||||
if uploadResult.Version != "" {
|
||||
out["version"] = uploadResult.Version
|
||||
}
|
||||
// wiki-hosted files have no standalone /file/<token> URL — only the
|
||||
// wiki node URL, which the upload response doesn't carry. Skip the
|
||||
// fallback for parent_type=wiki rather than emit a link that 404s.
|
||||
if target.ParentType == driveUploadParentTypeExplorer {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "file", uploadResult.FileToken); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
if u, metaErr := common.FetchDriveMetaURL(runtime, uploadResult.FileToken, "file"); metaErr == nil && strings.TrimSpace(u) != "" {
|
||||
out["url"] = u
|
||||
} else if metaErr != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: uploaded file URL lookup failed: %v\n", metaErr)
|
||||
}
|
||||
if !isOverwrite {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, uploadResult.FileToken, "file"); grant != nil {
|
||||
|
||||
@@ -911,12 +911,16 @@ func marshalMarkdownPostContent(content [][]map[string]interface{}) string {
|
||||
"content": content,
|
||||
},
|
||||
}
|
||||
return marshalJSONNoEscape(payload)
|
||||
data, _ := json.Marshal(payload)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func buildSingleMDPost(markdown string) string {
|
||||
return marshalMarkdownPostContent([][]map[string]interface{}{
|
||||
buildPostElementNodes(optimizeMarkdownStyle(markdown)),
|
||||
{{
|
||||
"tag": "md",
|
||||
"text": optimizeMarkdownStyle(markdown),
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -940,7 +944,10 @@ func buildSegmentedPost(markdown string) string {
|
||||
if optimized == "" {
|
||||
continue
|
||||
}
|
||||
content = append(content, buildPostElementNodes(optimized))
|
||||
content = append(content, []map[string]interface{}{{
|
||||
"tag": "md",
|
||||
"text": optimized,
|
||||
}})
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return buildSingleMDPost(markdown)
|
||||
@@ -955,186 +962,8 @@ func buildMarkdownPostContent(markdown string) string {
|
||||
return buildSingleMDPost(markdown)
|
||||
}
|
||||
|
||||
// buildPostElementNodes splits optimized markdown text into Feishu post inline
|
||||
// elements. It tokenizes markdown links/images and bare http(s) URLs:
|
||||
// - markdown links are kept verbatim inside a {"tag":"md"} segment
|
||||
// - bare URLs become {"tag":"a"} elements rendered natively by Feishu,
|
||||
// avoiding the md renderer misinterpreting underscores as italic markers
|
||||
//
|
||||
// Fenced code blocks are protected before tokenization so their content remains
|
||||
// a single md segment, and bare URLs support balanced parentheses in the path.
|
||||
func buildPostElementNodes(text string) []map[string]interface{} {
|
||||
protected, codeBlocks := protectMarkdownCodeBlocks(text)
|
||||
if protected == "" {
|
||||
return []map[string]interface{}{{
|
||||
"tag": "md",
|
||||
"text": text,
|
||||
}}
|
||||
}
|
||||
elems := make([]map[string]interface{}, 0, 4)
|
||||
prev := 0
|
||||
for i := 0; i < len(protected); {
|
||||
end, kind, ok := scanPostToken(protected, i)
|
||||
if !ok {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if i > prev {
|
||||
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(protected[prev:i], codeBlocks))
|
||||
}
|
||||
|
||||
token := protected[i:end]
|
||||
if kind == postTokenMarkdown {
|
||||
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(token, codeBlocks))
|
||||
} else {
|
||||
url := trimBareURLToken(token)
|
||||
if url == "" {
|
||||
url = token
|
||||
}
|
||||
elems = append(elems, map[string]interface{}{
|
||||
"tag": "a",
|
||||
"text": url,
|
||||
"href": url,
|
||||
})
|
||||
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(token[len(url):], codeBlocks))
|
||||
}
|
||||
prev = end
|
||||
i = end
|
||||
}
|
||||
if prev < len(protected) {
|
||||
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(protected[prev:], codeBlocks))
|
||||
}
|
||||
if len(elems) == 0 {
|
||||
return []map[string]interface{}{{
|
||||
"tag": "md",
|
||||
"text": text,
|
||||
}}
|
||||
}
|
||||
return elems
|
||||
}
|
||||
|
||||
func trimBareURLToken(token string) string {
|
||||
trimmed := strings.TrimRight(token, ".,;:!?")
|
||||
for strings.HasSuffix(trimmed, ")") && strings.Count(trimmed, "(") < strings.Count(trimmed, ")") {
|
||||
trimmed = strings.TrimSuffix(trimmed, ")")
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
type postTokenKind int
|
||||
|
||||
const (
|
||||
postTokenMarkdown postTokenKind = iota
|
||||
postTokenURL
|
||||
)
|
||||
|
||||
func appendMDPostNode(elems []map[string]interface{}, text string) []map[string]interface{} {
|
||||
if text == "" {
|
||||
return elems
|
||||
}
|
||||
return append(elems, map[string]interface{}{
|
||||
"tag": "md",
|
||||
"text": text,
|
||||
})
|
||||
}
|
||||
|
||||
func scanPostToken(text string, start int) (end int, kind postTokenKind, ok bool) {
|
||||
if end, ok = scanMarkdownLinkToken(text, start); ok {
|
||||
return end, postTokenMarkdown, true
|
||||
}
|
||||
if end, ok = scanBareURLToken(text, start); ok {
|
||||
return end, postTokenURL, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func scanMarkdownLinkToken(text string, start int) (int, bool) {
|
||||
openBracket := start
|
||||
if text[start] == '!' {
|
||||
if start+1 >= len(text) || text[start+1] != '[' {
|
||||
return 0, false
|
||||
}
|
||||
openBracket = start + 1
|
||||
} else if text[start] != '[' {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
closeBracket := strings.IndexByte(text[openBracket+1:], ']')
|
||||
if closeBracket < 0 {
|
||||
return 0, false
|
||||
}
|
||||
closeBracket += openBracket + 1
|
||||
if closeBracket+1 >= len(text) || text[closeBracket+1] != '(' {
|
||||
return 0, false
|
||||
}
|
||||
return scanBalancedParenToken(text, closeBracket+1)
|
||||
}
|
||||
|
||||
func scanBareURLToken(text string, start int) (int, bool) {
|
||||
if !strings.HasPrefix(text[start:], "http://") && !strings.HasPrefix(text[start:], "https://") {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
depth := 0
|
||||
for i := start; i < len(text); i++ {
|
||||
switch text[i] {
|
||||
case ' ', '\t', '\n', '\r', '<', '>', '"', '[', ']':
|
||||
return i, i > start
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
if depth == 0 {
|
||||
return i, i > start
|
||||
}
|
||||
depth--
|
||||
}
|
||||
}
|
||||
return len(text), true
|
||||
}
|
||||
|
||||
func scanBalancedParenToken(text string, openParen int) (int, bool) {
|
||||
if openParen >= len(text) || text[openParen] != '(' {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
depth := 0
|
||||
for i := openParen; i < len(text); i++ {
|
||||
switch text[i] {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i + 1, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func buildPostElements(text string) string {
|
||||
return marshalJSONNoEscape(buildPostElementNodes(text))
|
||||
}
|
||||
|
||||
func marshalJSONNoEscape(v interface{}) string {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(v)
|
||||
return strings.TrimSuffix(buf.String(), "\n")
|
||||
}
|
||||
|
||||
// marshalStringNoEscape serializes a string to JSON without HTML-escaping
|
||||
// special characters like &, <, >. Go's json.Marshal escapes them to \u0026
|
||||
// etc. by default, which breaks URLs containing & in Feishu's md renderer.
|
||||
func marshalStringNoEscape(s string) string {
|
||||
return marshalJSONNoEscape(s)
|
||||
}
|
||||
|
||||
// wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network).
|
||||
// Used by DryRun. Output may include md/text paragraphs when blank-line separators are present.
|
||||
// Bare URLs are emitted as {"tag":"a"} elements to avoid Feishu's md renderer
|
||||
// misinterpreting underscores in URLs as italic markers.
|
||||
func wrapMarkdownAsPost(markdown string) string {
|
||||
return buildMarkdownPostContent(markdown)
|
||||
}
|
||||
|
||||
@@ -373,171 +373,19 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalStringNoEscape(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "ampersand not escaped", input: "a=1&b=2", want: `"a=1&b=2"`},
|
||||
{name: "angle brackets not escaped", input: "<tag>", want: `"<tag>"`},
|
||||
{name: "regular string", input: "hello world", want: `"hello world"`},
|
||||
{name: "url with ampersand", input: "https://example.com?a=1&b=2", want: `"https://example.com?a=1&b=2"`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := marshalStringNoEscape(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("marshalStringNoEscape(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostElements(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantSubs []string // substrings that must appear
|
||||
wantNsubs []string // substrings that must NOT appear
|
||||
}{
|
||||
{
|
||||
name: "plain text no URL",
|
||||
input: "hello **world**",
|
||||
wantSubs: []string{`"tag":"md"`, `hello **world**`},
|
||||
},
|
||||
{
|
||||
name: "bare URL only",
|
||||
input: "https://example.com/path",
|
||||
wantSubs: []string{`"tag":"a"`, `"text":"https://example.com/path"`, `"href":"https://example.com/path"`},
|
||||
},
|
||||
{
|
||||
name: "bare URL with underscores",
|
||||
input: "https://example.com/flow_id=abc_def",
|
||||
wantSubs: []string{`"tag":"a"`, `flow_id=abc_def`},
|
||||
},
|
||||
{
|
||||
name: "bare URL with ampersand not escaped",
|
||||
input: "https://example.com?a=1&b=2",
|
||||
wantSubs: []string{`"tag":"a"`, `a=1&b=2`},
|
||||
},
|
||||
{
|
||||
name: "text before and after URL",
|
||||
input: "click here: https://example.com/path ok?",
|
||||
wantSubs: []string{`"tag":"md"`, `click here: `, `"tag":"a"`, `https://example.com/path`, ` ok?`},
|
||||
},
|
||||
{
|
||||
name: "markdown link kept in md segment",
|
||||
input: "[click here](https://example.com/path_with_underscore)",
|
||||
wantSubs: []string{`"tag":"md"`, `[click here](https://example.com/path_with_underscore)`},
|
||||
},
|
||||
{
|
||||
name: "markdown link not promoted to a tag",
|
||||
input: "[text](https://example.com)",
|
||||
wantSubs: []string{`"tag":"md"`},
|
||||
wantNsubs: []string{`"tag":"a"`},
|
||||
},
|
||||
{
|
||||
name: "multiple bare URLs",
|
||||
input: "https://a.com/x_y and https://b.com/p_q",
|
||||
wantSubs: []string{
|
||||
`"tag":"a"`, `https://a.com/x_y`,
|
||||
`https://b.com/p_q`,
|
||||
`"tag":"md"`, ` and `,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed markdown and bare URL",
|
||||
input: "**bold** https://example.com/foo_bar [link](https://example.com) end",
|
||||
wantSubs: []string{`"tag":"md"`, `**bold**`, `"tag":"a"`, `foo_bar`, `[link](https://example.com)`},
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
wantSubs: []string{`"tag":"md"`, `"text":""`},
|
||||
},
|
||||
{
|
||||
name: "URL followed by comma",
|
||||
input: "visit https://example.com/path, then click",
|
||||
wantSubs: []string{`"tag":"a"`, `"href":"https://example.com/path"`},
|
||||
wantNsubs: []string{`https://example.com/path,`},
|
||||
},
|
||||
{
|
||||
name: "URL followed by period",
|
||||
input: "see https://example.com/foo.",
|
||||
wantSubs: []string{`"tag":"a"`, `https://example.com/foo`},
|
||||
wantNsubs: []string{`https://example.com/foo."`},
|
||||
},
|
||||
{
|
||||
name: "URL with no trailing punctuation unchanged",
|
||||
input: "https://example.com/foo_bar",
|
||||
wantSubs: []string{`"href":"https://example.com/foo_bar"`},
|
||||
},
|
||||
{
|
||||
name: "URL with balanced parentheses preserved",
|
||||
input: "https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
wantSubs: []string{`"href":"https://en.wikipedia.org/wiki/Foo_(bar)"`},
|
||||
wantNsubs: []string{`"href":"https://en.wikipedia.org/wiki/Foo_"`},
|
||||
},
|
||||
{
|
||||
name: "code block URL stays markdown",
|
||||
input: "```bash\ncurl https://example.com/foo_bar\n```",
|
||||
wantSubs: []string{`"tag":"md"`, "```bash\\ncurl https://example.com/foo_bar\\n```"},
|
||||
wantNsubs: []string{`"tag":"a"`},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildPostElements(tt.input)
|
||||
for _, sub := range tt.wantSubs {
|
||||
if !strings.Contains(got, sub) {
|
||||
t.Errorf("buildPostElements(%q)\n got: %s\n missing: %q", tt.input, got, sub)
|
||||
}
|
||||
}
|
||||
for _, sub := range tt.wantNsubs {
|
||||
if strings.Contains(got, sub) {
|
||||
t.Errorf("buildPostElements(%q)\n got: %s\n should not contain: %q", tt.input, got, sub)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPost(t *testing.T) {
|
||||
t.Run("plain markdown", func(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("hello **world**")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
|
||||
}
|
||||
node := decodePostParagraphForTest(t, got, 0)
|
||||
if node["tag"] != "md" {
|
||||
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
|
||||
}
|
||||
if node["text"] != "hello **world**" {
|
||||
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare URL becomes a tag", func(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("see https://example.com/flow_id=abc_def done")
|
||||
if !strings.Contains(got, `"tag":"a"`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() bare URL should produce a tag: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `flow_id=abc_def`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() URL content missing: %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("code block URL stays md", func(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("```bash\ncurl https://example.com/foo_bar\n```")
|
||||
if strings.Contains(got, `"tag":"a"`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() code block URL should stay markdown: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "```bash\\ncurl https://example.com/foo_bar\\n```") {
|
||||
t.Fatalf("wrapMarkdownAsPost() code block content missing: %s", got)
|
||||
}
|
||||
})
|
||||
got := wrapMarkdownAsPost("hello **world**")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
|
||||
}
|
||||
node := decodePostParagraphForTest(t, got, 0)
|
||||
if node["tag"] != "md" {
|
||||
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
|
||||
}
|
||||
if node["text"] != "hello **world**" {
|
||||
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldUseSegmentedPost(t *testing.T) {
|
||||
|
||||
@@ -29,11 +29,11 @@ var ImMessagesReply = common.Shortcut{
|
||||
{Name: "content", Desc: "(one of --content/--text/--markdown/--image/--file/--video/--audio required) message content JSON"},
|
||||
{Name: "text", Desc: "plain text message (auto-wrapped as JSON)"},
|
||||
{Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"},
|
||||
{Name: "image", Desc: "image_key, local file path"},
|
||||
{Name: "file", Desc: "file_key, local file path"},
|
||||
{Name: "video", Desc: "video file_key, local file path; must be used together with --video-cover"},
|
||||
{Name: "video-cover", Desc: "video cover image_key, local file path; required when using --video"},
|
||||
{Name: "audio", Desc: "audio file_key, local file path"},
|
||||
{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
|
||||
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
|
||||
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
|
||||
},
|
||||
|
||||
@@ -33,11 +33,11 @@ var ImMessagesSend = common.Shortcut{
|
||||
{Name: "text", Desc: "plain text message (auto-wrapped as JSON)"},
|
||||
{Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
|
||||
{Name: "image", Desc: "image_key, local file path"},
|
||||
{Name: "file", Desc: "file_key, local file path"},
|
||||
{Name: "video", Desc: "video file_key, local file path; must be used together with --video-cover"},
|
||||
{Name: "video-cover", Desc: "video cover image_key, local file path; required when using --video"},
|
||||
{Name: "audio", Desc: "audio file_key, local file path"},
|
||||
{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
|
||||
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
|
||||
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
chatFlag := runtime.Str("chat-id")
|
||||
|
||||
@@ -6,9 +6,11 @@ package im
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestValidateMediaFlagPath(t *testing.T) {
|
||||
@@ -49,3 +51,37 @@ func TestValidateMediaFlagPath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMMediaFlagDescriptionsDocumentPathRestrictions(t *testing.T) {
|
||||
shortcuts := []struct {
|
||||
name string
|
||||
flags []common.Flag
|
||||
}{
|
||||
{name: "messages-send", flags: ImMessagesSend.Flags},
|
||||
{name: "messages-reply", flags: ImMessagesReply.Flags},
|
||||
}
|
||||
mediaFlags := []string{"image", "file", "video", "video-cover", "audio"}
|
||||
for _, sc := range shortcuts {
|
||||
for _, flagName := range mediaFlags {
|
||||
t.Run(sc.name+"/"+flagName, func(t *testing.T) {
|
||||
desc := findFlagDesc(t, sc.flags, flagName)
|
||||
for _, want := range []string{"URL", "cwd-relative local path", "absolute paths", ".. are rejected"} {
|
||||
if !strings.Contains(desc, want) {
|
||||
t.Fatalf("%s --%s description = %q, want it to mention %q", sc.name, flagName, desc, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findFlagDesc(t *testing.T, flags []common.Flag, name string) string {
|
||||
t.Helper()
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name {
|
||||
return flag.Desc
|
||||
}
|
||||
}
|
||||
t.Fatalf("flag %q not found", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
@@ -16,7 +17,7 @@ var MarkdownCreate = common.Shortcut{
|
||||
Command: "+create",
|
||||
Description: "Create a Markdown file in Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:file:upload"},
|
||||
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
@@ -55,7 +56,19 @@ var MarkdownCreate = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return markdownUploadDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
|
||||
dry := markdownUploadDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
|
||||
dry.POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("Fetch the created Markdown file's real access URL").
|
||||
Body(map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": "<file_token from upload response>",
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
"with_url": true,
|
||||
})
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := markdownUploadSpec{
|
||||
@@ -87,10 +100,10 @@ var MarkdownCreate = common.Shortcut{
|
||||
"file_name": finalMarkdownFileName(spec),
|
||||
"size_bytes": fileSize,
|
||||
}
|
||||
if target := spec.Target(); target.ParentType == markdownUploadParentTypeExplorer {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "file", result.FileToken); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
if u, metaErr := common.FetchDriveMetaURL(runtime, result.FileToken, "file"); metaErr == nil && strings.TrimSpace(u) != "" {
|
||||
out["url"] = u
|
||||
} else if metaErr != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: created Markdown file URL lookup failed: %v\n", metaErr)
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, result.FileToken, "file"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
|
||||
@@ -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"}
|
||||
@@ -403,6 +410,9 @@ func TestMarkdownCreateDryRunWithInlineContent(t *testing.T) {
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/files/upload_all") {
|
||||
t.Fatalf("dry-run missing upload_all: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/metas/batch_query") || !strings.Contains(out, `"with_url": true`) {
|
||||
t.Fatalf("dry-run missing metadata URL lookup: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "markdown content") {
|
||||
t.Fatalf("dry-run missing content marker: %s", out)
|
||||
}
|
||||
@@ -429,6 +439,9 @@ func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
|
||||
if !strings.Contains(out, `"parent_node": "wikcn_markdown_dryrun_target"`) {
|
||||
t.Fatalf("dry-run missing wiki parent_node: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/metas/batch_query") || !strings.Contains(out, `"with_url": true`) {
|
||||
t.Fatalf("dry-run missing metadata URL lookup: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
|
||||
@@ -470,6 +483,9 @@ func TestMarkdownCreateDryRunWithFileUsesStatOnly(t *testing.T) {
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/files/upload_prepare") {
|
||||
t.Fatalf("dry-run missing multipart prepare step: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/metas/batch_query") || !strings.Contains(out, `"with_url": true`) {
|
||||
t.Fatalf("dry-run missing metadata URL lookup: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "open should not be called in dry-run") {
|
||||
t.Fatalf("dry-run unexpectedly tried to open the source file: %s", out)
|
||||
}
|
||||
@@ -489,6 +505,18 @@ func TestMarkdownCreateSuccessUploadAll(t *testing.T) {
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_create", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_create"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
@@ -521,12 +549,12 @@ func TestMarkdownCreateSuccessUploadAll(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), `"file_name": "README.md"`) {
|
||||
t.Fatalf("stdout missing file_name: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"url": "https://www.feishu.cn/file/box_md_create"`) {
|
||||
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_create"`) {
|
||||
t.Fatalf("stdout missing url: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateSuccessUploadAllToWikiOmitsURL(t *testing.T) {
|
||||
func TestMarkdownCreateSuccessUploadAllToWikiReturnsMetaURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -540,6 +568,18 @@ func TestMarkdownCreateSuccessUploadAllToWikiOmitsURL(t *testing.T) {
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_create_wiki", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_create_wiki"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
@@ -558,8 +598,8 @@ func TestMarkdownCreateSuccessUploadAllToWikiOmitsURL(t *testing.T) {
|
||||
if got := body.Fields["parent_node"]; got != "wikcn_markdown_create_target" {
|
||||
t.Fatalf("parent_node = %q, want %q", got, "wikcn_markdown_create_target")
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"url":`) {
|
||||
t.Fatalf("stdout should omit url for wiki-hosted markdown files: %s", stdout.String())
|
||||
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_create_wiki"`) {
|
||||
t.Fatalf("stdout missing metadata url for wiki-hosted markdown file: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,6 +615,18 @@ func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_create_pretty", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_create_pretty"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
@@ -591,7 +643,7 @@ func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
|
||||
if !strings.Contains(out, "file_token: box_md_create_pretty") {
|
||||
t.Fatalf("pretty output missing file_token: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "url: https://www.feishu.cn/file/box_md_create_pretty") {
|
||||
if !strings.Contains(out, "url: https://tenant.example.com/file/box_md_create_pretty") {
|
||||
t.Fatalf("pretty output missing url: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "permission_grant.status: skipped") {
|
||||
@@ -602,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())
|
||||
|
||||
@@ -649,6 +809,18 @@ func TestMarkdownCreateMultipartUploadSuccess(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_multipart", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_multipart"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
@@ -677,6 +849,9 @@ func TestMarkdownCreateMultipartUploadSuccess(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), `"file_token": "box_md_multipart"`) {
|
||||
t.Fatalf("stdout missing multipart file_token: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_multipart"`) {
|
||||
t.Fatalf("stdout missing multipart metadata url: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateMultipartUploadToWikiUsesWikiParent(t *testing.T) {
|
||||
@@ -715,6 +890,18 @@ func TestMarkdownCreateMultipartUploadToWikiUsesWikiParent(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_multipart_wiki", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_multipart_wiki"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
@@ -749,8 +936,8 @@ func TestMarkdownCreateMultipartUploadToWikiUsesWikiParent(t *testing.T) {
|
||||
if got := body["parent_node"]; got != "wikcn_markdown_multipart_target" {
|
||||
t.Fatalf("parent_node = %#v, want %q", got, "wikcn_markdown_multipart_target")
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"url":`) {
|
||||
t.Fatalf("stdout should omit url for wiki-hosted multipart markdown files: %s", stdout.String())
|
||||
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_multipart_wiki"`) {
|
||||
t.Fatalf("stdout missing metadata url for wiki-hosted multipart markdown file: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,18 @@ package shortcuts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/okr"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/apps"
|
||||
"github.com/larksuite/cli/shortcuts/base"
|
||||
"github.com/larksuite/cli/shortcuts/calendar"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -31,10 +36,28 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/wiki"
|
||||
)
|
||||
|
||||
// Empty brand (no config loaded) is treated as no-restriction so bootstrap
|
||||
// paths and tests without config still see the full service list.
|
||||
var brandRestrictedServices = map[string][]core.LarkBrand{
|
||||
"apps": {core.BrandFeishu},
|
||||
}
|
||||
|
||||
func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool {
|
||||
allowed, ok := brandRestrictedServices[service]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if brand == "" {
|
||||
return true
|
||||
}
|
||||
return slices.Contains(allowed, brand)
|
||||
}
|
||||
|
||||
// allShortcuts aggregates shortcuts from all domain packages.
|
||||
var allShortcuts []common.Shortcut
|
||||
|
||||
func init() {
|
||||
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
|
||||
@@ -67,6 +90,14 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) {
|
||||
}
|
||||
|
||||
func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) {
|
||||
// Factory.Config may be nil in tests that pass a zero-value factory.
|
||||
var brand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
brand = cfg.Brand
|
||||
}
|
||||
}
|
||||
|
||||
// Group by service
|
||||
byService := make(map[string][]common.Shortcut)
|
||||
for _, s := range allShortcuts {
|
||||
@@ -115,5 +146,46 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
if service == "mail" {
|
||||
mail.InstallOnMail(svc)
|
||||
}
|
||||
|
||||
if !IsShortcutServiceAvailable(service, brand) {
|
||||
installBrandRestrictionGuard(svc, service, brand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors internal/cmdpolicy/apply.go::installDenyStub: DisableFlagParsing +
|
||||
// ArbitraryArgs keep cobra from short-circuiting with "missing required flag"
|
||||
// before our RunE runs; leaf-level PersistentPreRunE defeats cobra's "first
|
||||
// PreRunE wins" walk-up that would otherwise shadow the stub.
|
||||
func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core.LarkBrand) {
|
||||
stub := func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return output.ErrValidation(
|
||||
"the %q feature is not yet supported on the %s brand",
|
||||
service, brand,
|
||||
)
|
||||
}
|
||||
noopPreRun := func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
}
|
||||
var walk func(c *cobra.Command)
|
||||
walk = func(c *cobra.Command) {
|
||||
c.Hidden = true
|
||||
c.DisableFlagParsing = true
|
||||
c.Args = cobra.ArbitraryArgs
|
||||
c.PreRunE = nil
|
||||
c.PreRun = nil
|
||||
c.PersistentPreRunE = noopPreRun
|
||||
c.PersistentPreRun = nil
|
||||
c.RunE = stub
|
||||
c.Run = nil
|
||||
for _, child := range c.Commands() {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(svc)
|
||||
|
||||
// --help bypasses RunE, so surface the restriction in Long too.
|
||||
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
|
||||
}
|
||||
|
||||
122
shortcuts/register_brand_guard_test.go
Normal file
122
shortcuts/register_brand_guard_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newFactoryWithBrand(brand core.LarkBrand) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) {
|
||||
return &core.CliConfig{Brand: brand}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func findChild(root *cobra.Command, name string) *cobra.Command {
|
||||
for _, c := range root.Commands() {
|
||||
if c.Name() == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBrandGuard_AppsStaysRegisteredOnLark(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark))
|
||||
|
||||
apps := findChild(program, "apps")
|
||||
if apps == nil {
|
||||
t.Fatal("apps service command should be registered on Lark brand (so users see a clear brand error, not 'unknown command')")
|
||||
}
|
||||
if !apps.Hidden {
|
||||
t.Error("apps service command should be Hidden on Lark brand")
|
||||
}
|
||||
if len(apps.Commands()) == 0 {
|
||||
t.Error("apps subcommands should still be mounted (so children also hit the brand-restriction stub)")
|
||||
}
|
||||
for _, child := range apps.Commands() {
|
||||
if !child.Hidden {
|
||||
t.Errorf("apps child %q should be Hidden on Lark brand", child.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrandGuard_AppsExecuteReturnsBrandError(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark))
|
||||
|
||||
apps := findChild(program, "apps")
|
||||
if apps == nil {
|
||||
t.Fatal("apps should be registered")
|
||||
}
|
||||
create := findChild(apps, "+create")
|
||||
if create == nil {
|
||||
t.Fatal("apps +create should be registered")
|
||||
}
|
||||
|
||||
err := create.RunE(create, []string{"--name", "x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected brand-restriction error, got nil")
|
||||
}
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("expected ExitValidation (%d), got %d", output.ExitValidation, exitErr.Code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Error(), "apps") || !strings.Contains(exitErr.Error(), "lark") {
|
||||
t.Errorf("expected error to mention apps + lark, got: %s", exitErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrandGuard_AppsExecutableOnFeishu(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newFactoryWithBrand(core.BrandFeishu))
|
||||
|
||||
apps := findChild(program, "apps")
|
||||
if apps == nil {
|
||||
t.Fatal("apps should be registered on Feishu brand")
|
||||
}
|
||||
if apps.Hidden {
|
||||
t.Error("apps should NOT be Hidden on Feishu brand")
|
||||
}
|
||||
create := findChild(apps, "+create")
|
||||
if create == nil {
|
||||
t.Fatal("apps +create should be registered on Feishu brand")
|
||||
}
|
||||
if create.DisableFlagParsing {
|
||||
t.Error("apps +create should not have DisableFlagParsing on Feishu (the guard must not have run)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrandGuard_DispatchHitsStubViaCobra(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark))
|
||||
|
||||
program.SetArgs([]string{"apps", "+create", "--name", "x", "--app-type", "HTML"})
|
||||
program.SetContext(context.Background())
|
||||
err := program.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error from dispatching apps +create on Lark brand")
|
||||
}
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *output.ExitError from cobra dispatch, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Error(), "lark") {
|
||||
t.Errorf("dispatched error should mention lark brand, got: %s", exitErr.Error())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -304,3 +304,88 @@ func decodeSheetCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]in
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestSheetCreateBotAutoGrantSkippedNoUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheet": map[string]interface{}{
|
||||
"spreadsheet_token": "shtcn_skipped",
|
||||
"url": "https://example.feishu.cn/sheets/shtcn_skipped",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSheetCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "No User Sheet",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSheetCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
|
||||
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreateBotAutoGrantFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "ou_current_user"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheet": map[string]interface{}{
|
||||
"spreadsheet_token": "shtcn_grant_fail",
|
||||
"url": "https://example.feishu.cn/sheets/shtcn_grant_fail",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/shtcn_grant_fail/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := runSheetCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "Grant Fail Sheet",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSheetCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
|
||||
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -29,6 +30,15 @@ func TestSheetCreateSheetValidateMissingToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetInfoRequiresSpreadsheetMetaAndReadScopes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := []string{"sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"}
|
||||
if !reflect.DeepEqual(SheetInfo.Scopes, want) {
|
||||
t.Fatalf("SheetInfo scopes = %v, want %v", SheetInfo.Scopes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetManageValidateRejectsURLAndTokenTogether(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -7,12 +7,15 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -38,6 +41,56 @@ func mountAndRunSheets(t *testing.T, s common.Shortcut, args []string, f *cmduti
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
type sheetWriteImageStaticFileIOProvider struct {
|
||||
fio fileio.FileIO
|
||||
}
|
||||
|
||||
func (p *sheetWriteImageStaticFileIOProvider) Name() string { return "sheet-write-image-static" }
|
||||
|
||||
func (p *sheetWriteImageStaticFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO {
|
||||
return p.fio
|
||||
}
|
||||
|
||||
type sheetWriteImageMemoryFileIO struct {
|
||||
files map[string][]byte
|
||||
}
|
||||
|
||||
func (f *sheetWriteImageMemoryFileIO) Open(name string) (fileio.File, error) {
|
||||
data, ok := f.files[name]
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return sheetWriteImageMemoryFile{Reader: bytes.NewReader(data)}, nil
|
||||
}
|
||||
|
||||
func (f *sheetWriteImageMemoryFileIO) Stat(name string) (fileio.FileInfo, error) {
|
||||
data, ok := f.files[name]
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return sheetWriteImageFileInfo{size: int64(len(data))}, nil
|
||||
}
|
||||
|
||||
func (f *sheetWriteImageMemoryFileIO) ResolvePath(path string) (string, error) { return path, nil }
|
||||
|
||||
func (f *sheetWriteImageMemoryFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type sheetWriteImageMemoryFile struct {
|
||||
*bytes.Reader
|
||||
}
|
||||
|
||||
func (sheetWriteImageMemoryFile) Close() error { return nil }
|
||||
|
||||
type sheetWriteImageFileInfo struct {
|
||||
size int64
|
||||
}
|
||||
|
||||
func (i sheetWriteImageFileInfo) Size() int64 { return i.size }
|
||||
func (i sheetWriteImageFileInfo) IsDir() bool { return false }
|
||||
func (i sheetWriteImageFileInfo) Mode() fs.FileMode { return 0 }
|
||||
|
||||
const existingWriteImageTestFile = "./lark_sheets_cell_images.go"
|
||||
|
||||
// ── Validate ─────────────────────────────────────────────────────────────────
|
||||
@@ -221,80 +274,20 @@ func TestSheetWriteImageDryRunWithSheetID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageDryRunRejectsMissingFile(t *testing.T) {
|
||||
func TestSheetWriteImageDryRunDoesNotValidateImageFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetWriteImage, []string{
|
||||
"+write-image",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:A1",
|
||||
"--image", "./missing.png",
|
||||
"--image", "/__bridge_url__/qKrk1wSAtS",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "image file not found") {
|
||||
t.Fatalf("expected file-not-found error before dry-run planning, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageDryRunRejectsDirectory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cmdutil.TestChdir(t, tmpDir)
|
||||
if err := os.Mkdir("imgdir", 0o755); err != nil {
|
||||
t.Fatalf("Mkdir() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetWriteImage, []string{
|
||||
"+write-image",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:A1",
|
||||
"--image", "./imgdir",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "regular file") {
|
||||
t.Fatalf("expected regular-file error before dry-run planning, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageDryRunRejectsAbsolutePath(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetWriteImage, []string{
|
||||
"+write-image",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:A1",
|
||||
"--image", "/etc/passwd",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe image path") {
|
||||
t.Fatalf("expected unsafe-path error before dry-run planning, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageDryRunRejectsOversizedFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cmdutil.TestChdir(t, tmpDir)
|
||||
|
||||
fh, err := os.Create("huge.png")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
t.Fatalf("dry-run should not stat or open image files, got: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(20*1024*1024 + 1); err != nil {
|
||||
fh.Close()
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err = mountAndRunSheets(t, SheetWriteImage, []string{
|
||||
"+write-image",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:A1",
|
||||
"--image", "./huge.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds 20MB limit") {
|
||||
t.Fatalf("expected size error before dry-run planning, got: %v", err)
|
||||
if !strings.Contains(stdout.String(), "/__bridge_url__/qKrk1wSAtS") {
|
||||
t.Fatalf("dry-run output should preserve image path: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +361,55 @@ func TestSheetWriteImageExecuteSendsJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageExecuteUsesFileIOForBridgeSentinelPath(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
imagePath := "/__bridge_url__/qKrk1wSAtS"
|
||||
imageData := []byte{0x89, 0x50, 0x4E, 0x47}
|
||||
f.FileIOProvider = &sheetWriteImageStaticFileIOProvider{
|
||||
fio: &sheetWriteImageMemoryFileIO{
|
||||
files: map[string][]byte{imagePath: imageData},
|
||||
},
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheetToken": "shtTOKEN",
|
||||
"revision": float64(5),
|
||||
"updateRange": "sheet1!A1:A1",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetWriteImage, []string{
|
||||
"+write-image",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:A1",
|
||||
"--image", imagePath,
|
||||
"--name", "bridge.png",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("request body is not valid JSON: %v", err)
|
||||
}
|
||||
if body["name"] != "bridge.png" {
|
||||
t.Fatalf("body name = %v, want bridge.png", body["name"])
|
||||
}
|
||||
if body["image"] == nil {
|
||||
t.Fatal("body image field is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageExecuteRejectsNonexistentFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ import (
|
||||
var SheetInfo = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+info",
|
||||
Description: "View spreadsheet and sheet information",
|
||||
Description: "View spreadsheet metadata and sheet information",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
Scopes: []string{"sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
|
||||
@@ -147,6 +147,55 @@ func TestSlidesCreateBotSkippedWithoutCurrentUser(t *testing.T) {
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
|
||||
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateBotAutoGrantFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "ou_current_user"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_grant_fail",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_grant_fail", "https://example.feishu.cn/slides/pres_grant_fail")
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/pres_grant_fail/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "Grant Fail PPT",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
|
||||
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateDryRunDefaultTitle verifies that dry-run also normalizes an empty title to "Untitled".
|
||||
|
||||
@@ -17,5 +17,8 @@ func Shortcuts() []common.Shortcut {
|
||||
WikiNodeCopy,
|
||||
WikiNodeGet,
|
||||
WikiNodeDelete,
|
||||
WikiMemberAdd,
|
||||
WikiMemberRemove,
|
||||
WikiMemberList,
|
||||
}
|
||||
}
|
||||
|
||||
176
shortcuts/wiki/wiki_member_add.go
Normal file
176
shortcuts/wiki/wiki_member_add.go
Normal 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
|
||||
}
|
||||
70
shortcuts/wiki/wiki_member_helpers.go
Normal file
70
shortcuts/wiki/wiki_member_helpers.go
Normal 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
|
||||
}
|
||||
183
shortcuts/wiki/wiki_member_list.go
Normal file
183
shortcuts/wiki/wiki_member_list.go
Normal 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)
|
||||
}
|
||||
}
|
||||
153
shortcuts/wiki/wiki_member_remove.go
Normal file
153
shortcuts/wiki/wiki_member_remove.go
Normal 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())
|
||||
}
|
||||
734
shortcuts/wiki/wiki_member_test.go
Normal file
734
shortcuts/wiki/wiki_member_test.go
Normal 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
|
||||
}
|
||||
@@ -577,6 +577,105 @@ func TestWikiNodeCreateBotAutoGrantSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeCreateBotAutoGrantSkippedNoUser(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiPermissionTestConfig(""))
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_skipped",
|
||||
"obj_token": "docx_skipped",
|
||||
"obj_type": "docx",
|
||||
"node_type": "origin",
|
||||
"title": "Wiki Skipped",
|
||||
"has_child": false,
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeCreate, []string{
|
||||
"+node-create",
|
||||
"--space-id", "space_123",
|
||||
"--title", "Wiki Skipped",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
|
||||
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeCreateBotAutoGrantFailed(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiPermissionTestConfig("ou_current_user"))
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_grant_fail",
|
||||
"obj_token": "docx_grant_fail",
|
||||
"obj_type": "docx",
|
||||
"node_type": "origin",
|
||||
"title": "Wiki Fail",
|
||||
"has_child": false,
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/wik_grant_fail/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeCreate, []string{
|
||||
"+node-create",
|
||||
"--space-id", "space_123",
|
||||
"--title", "Wiki Fail",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
|
||||
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
281
sidecar/server-multi-tenant-demo/README.md
Normal file
281
sidecar/server-multi-tenant-demo/README.md
Normal 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
|
||||
44
sidecar/server-multi-tenant-demo/allowlist.go
Normal file
44
sidecar/server-multi-tenant-demo/allowlist.go
Normal 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
|
||||
}
|
||||
51
sidecar/server-multi-tenant-demo/audit.go
Normal file
51
sidecar/server-multi-tenant-demo/audit.go
Normal 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
|
||||
}
|
||||
530
sidecar/server-multi-tenant-demo/auth_bridge.go
Normal file
530
sidecar/server-multi-tenant-demo/auth_bridge.go
Normal 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 ""
|
||||
}
|
||||
51
sidecar/server-multi-tenant-demo/forward.go
Normal file
51
sidecar/server-multi-tenant-demo/forward.go
Normal 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
|
||||
}
|
||||
372
sidecar/server-multi-tenant-demo/handler.go
Normal file
372
sidecar/server-multi-tenant-demo/handler.go
Normal 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
|
||||
}
|
||||
878
sidecar/server-multi-tenant-demo/handler_test.go
Normal file
878
sidecar/server-multi-tenant-demo/handler_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
195
sidecar/server-multi-tenant-demo/main.go
Normal file
195
sidecar/server-multi-tenant-demo/main.go
Normal 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
|
||||
}
|
||||
6
skill-template/domains/apps.md
Normal file
6
skill-template/domains/apps.md
Normal file
@@ -0,0 +1,6 @@
|
||||
## 妙搭应用(apps)域介绍
|
||||
|
||||
妙搭是飞书的低代码 / 无代码应用平台。本域命令围绕"妙搭应用"展开:
|
||||
|
||||
- **App(应用)**:用户创建的妙搭应用对象,含 `app_id`、`name`、`description`、`icon_url`;通过 `+html-publish` 发布 HTML 内容
|
||||
- **Access Scope(可用范围)**:`specific`(指定可见)/ `public`(互联网公开)/ `tenant`(企业全员)三选一
|
||||
@@ -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 时,如果用户已经明确要求删除且目标明确,可以直接执行;只有目标不明确时才先追问
|
||||
|
||||
@@ -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
99
skills/lark-apps/SKILL.md
Normal 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**(用户明示部署 / 分享时直接调;仅说"可演示"时先问用户是否要部署再调) |
|
||||
104
skills/lark-apps/references/lark-apps-access-scope-get.md
Normal file
104
skills/lark-apps/references/lark-apps-access-scope-get.md
Normal 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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user