mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
4 Commits
v1.0.37
...
feat/sec_p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
054ff9339b | ||
|
|
bdb0cd14d1 | ||
|
|
6c41d12792 | ||
|
|
2286937366 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -42,5 +42,3 @@ app.log
|
||||
/server-demo
|
||||
.tmp/
|
||||
cover*.out
|
||||
|
||||
lark-env.sh
|
||||
|
||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -2,49 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
@@ -817,9 +774,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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 26 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 24 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** — 18 business domains, 200+ curated commands, 26 AI Agent [Skills](./skills/)
|
||||
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 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,7 +41,6 @@ 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+ 命令及 26 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — 26 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 18 大业务域、200+ 精选命令、26 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -41,7 +41,6 @@
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
|
||||
@@ -47,7 +47,8 @@ func authCheckRun(opts *CheckOptions) error {
|
||||
|
||||
required := strings.Fields(opts.Scope)
|
||||
if len(required) == 0 {
|
||||
return output.ErrValidation("--scope cannot be empty")
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": true, "granted": []string{}, "missing": []string{}})
|
||||
return nil
|
||||
}
|
||||
|
||||
config, err := f.Config()
|
||||
|
||||
@@ -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", "apps"}
|
||||
return []string{"base", "contact", "docs", "markdown"}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
cmdevent "github.com/larksuite/cli/cmd/event"
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/sec"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
@@ -133,6 +134,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
rootCmd.AddCommand(sec.NewCmdSec(f))
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
|
||||
251
cmd/sec/config_init.go
Normal file
251
cmd/sec/config_init.go
Normal file
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdSecConfig is the parent for `lark-cli sec config <verb>`. Currently
|
||||
// it only carries `init`; future verbs (e.g. `show`, `reset`) plug in here.
|
||||
func NewCmdSecConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage lark-sec-cli daemon configuration",
|
||||
}
|
||||
cmd.AddCommand(NewCmdSecConfigInit(f, nil))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ConfigInitOptions holds inputs for `lark-cli sec config init`.
|
||||
type ConfigInitOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
AppID string
|
||||
AppSecret string
|
||||
Brand string
|
||||
Yes bool // skip the interactive form when all required values are provided
|
||||
}
|
||||
|
||||
// NewCmdSecConfigInit collects App ID / App Secret / Brand from the user and
|
||||
// registers them with the running lark-sec-cli daemon's admin endpoint. The
|
||||
// daemon stashes the secret in the OS keychain and switches into sidecar mode
|
||||
// for SEC_AUTH credential isolation.
|
||||
func NewCmdSecConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
|
||||
opts := &ConfigInitOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Register a Lark App with the running lark-sec-cli daemon",
|
||||
Long: `Register an App ID / App Secret with the lark-sec-cli daemon.
|
||||
|
||||
The daemon must already be running (start it with "lark-cli sec run"). The
|
||||
registration POSTs to /_sec/api/v1/register-app on the local proxy port,
|
||||
HMAC-signed with the daemon's proxy.key.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runConfigInit(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (skips the prompt when set)")
|
||||
cmd.Flags().StringVar(&opts.AppSecret, "app-secret", "", "App Secret (skips the prompt when set)")
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark")
|
||||
cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "skip the interactive form when all required values are provided")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// secBridge mirrors what the daemon writes to ~/.lark-cli/sec_config.json.
|
||||
// It's the single contract between lark-cli and lark-sec-cli at runtime —
|
||||
// we don't reach into lark-sec-cli internals, only what it chooses to publish.
|
||||
type secBridge struct {
|
||||
Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"`
|
||||
Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"`
|
||||
CA string `json:"LARKSUITE_CLI_SEC_CA"`
|
||||
Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"`
|
||||
}
|
||||
|
||||
func runConfigInit(cmd *cobra.Command, opts *ConfigInitOptions) error {
|
||||
errOut := opts.Factory.IOStreams.ErrOut
|
||||
trace := verboseOut(cmd, errOut)
|
||||
|
||||
tracef(trace, "sec config init", "loading daemon bridge from %s/sec_config.json", core.GetConfigDir())
|
||||
bridge, err := loadBridge()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "sec_bridge_missing",
|
||||
fmt.Sprintf("daemon bridge file unreadable: %v", err),
|
||||
"Start the daemon first: `lark-cli sec run`.")
|
||||
}
|
||||
tracef(trace, "sec config init", "bridge: enable=%t proxy=%s ca=%s auth=%t", bridge.Enable, bridge.Proxy, bridge.CA, bridge.Auth)
|
||||
if !bridge.Enable || bridge.Proxy == "" {
|
||||
return output.ErrWithHint(output.ExitValidation, "sec_not_running",
|
||||
"lark-sec-cli is not advertising an active proxy",
|
||||
"Run `lark-cli sec run` to start it.")
|
||||
}
|
||||
|
||||
// The HMAC key sits next to the CA in the daemon's config dir. Deriving
|
||||
// from the bridge's SEC_CA path keeps lark-cli decoupled from the daemon's
|
||||
// install location — if the daemon ever moves, the bridge follows and we
|
||||
// follow with it.
|
||||
tracef(trace, "sec config init", "reading daemon HMAC key beside %s", bridge.CA)
|
||||
hmacKey, err := readHMACKey(bridge.CA)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "sec_hmac_key", "read daemon HMAC key: %v", err)
|
||||
}
|
||||
|
||||
if err := promptForMissing(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracef(trace, "sec config init", "POST %s/_sec/api/v1/register-app app_id=%s brand=%s", bridge.Proxy, opts.AppID, opts.Brand)
|
||||
if err := registerApp(cmd.Context(), bridge.Proxy, hmacKey, opts.AppID, opts.AppSecret, opts.Brand); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "sec_register_app", "register-app: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(errOut,
|
||||
fmt.Sprintf("registered app %s with lark-sec-cli (%s)", opts.AppID, opts.Brand))
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadBridge reads the daemon-written sec_config.json from lark-cli's config dir.
|
||||
func loadBridge() (*secBridge, error) {
|
||||
path := filepath.Join(core.GetConfigDir(), "sec_config.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var b secBridge
|
||||
if err := json.Unmarshal(data, &b); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// readHMACKey returns the daemon's proxy.key bytes. The daemon writes the key
|
||||
// hex-encoded (64 ASCII chars); we hex-decode here. If the file is a raw
|
||||
// 32-byte blob (older daemon variants), we use it as-is.
|
||||
func readHMACKey(caPath string) ([]byte, error) {
|
||||
if caPath == "" {
|
||||
return nil, errors.New("sec_config.json has no LARKSUITE_CLI_SEC_CA — can't locate proxy.key")
|
||||
}
|
||||
keyPath := filepath.Join(filepath.Dir(caPath), "proxy.key")
|
||||
raw, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 64 {
|
||||
if decoded, err := hex.DecodeString(string(raw)); err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// promptForMissing fills in any of AppID / AppSecret / Brand the user didn't
|
||||
// provide via flags. --yes refuses to prompt; that's caller error if any are
|
||||
// still missing at that point.
|
||||
func promptForMissing(opts *ConfigInitOptions) error {
|
||||
if opts.AppID != "" && opts.AppSecret != "" && opts.Brand != "" {
|
||||
return nil
|
||||
}
|
||||
if opts.Yes {
|
||||
return output.ErrValidation("--yes set but missing one of --app-id / --app-secret / --brand")
|
||||
}
|
||||
|
||||
groups := []*huh.Group{}
|
||||
if opts.AppID == "" {
|
||||
groups = append(groups, huh.NewGroup(
|
||||
huh.NewInput().Title("App ID").Placeholder("cli_xxxx").Value(&opts.AppID),
|
||||
))
|
||||
}
|
||||
if opts.AppSecret == "" {
|
||||
groups = append(groups, huh.NewGroup(
|
||||
huh.NewInput().Title("App Secret").EchoMode(huh.EchoModePassword).Value(&opts.AppSecret),
|
||||
))
|
||||
}
|
||||
if opts.Brand == "" {
|
||||
opts.Brand = "feishu"
|
||||
groups = append(groups, huh.NewGroup(
|
||||
huh.NewSelect[string]().Title("Brand").Options(
|
||||
huh.NewOption("Feishu (cn)", "feishu"),
|
||||
huh.NewOption("Lark (intl)", "lark"),
|
||||
).Value(&opts.Brand),
|
||||
))
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
form := huh.NewForm(groups...).WithTheme(cmdutil.ThemeFeishu())
|
||||
if err := form.Run(); err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerApp POSTs to /_sec/api/v1/register-app with the daemon's HMAC scheme.
|
||||
// Canonical signing input is "method\npath\nsha256hex(body)\ntimestamp", per
|
||||
// lark-sec-cli/internal/proxy/admin_handler.go's verifyHMAC.
|
||||
func registerApp(ctx context.Context, proxyURL string, hmacKey []byte, appID, appSecret, brand string) error {
|
||||
const path = "/_sec/api/v1/register-app"
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": appID,
|
||||
"app_secret": appSecret,
|
||||
"brand": brand,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
bodyHash := sha256.Sum256(body)
|
||||
canonical := http.MethodPost + "\n" + path + "\n" + hex.EncodeToString(bodyHash[:]) + "\n" + ts
|
||||
mac := hmac.New(sha256.New, hmacKey)
|
||||
mac.Write([]byte(canonical))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, proxyURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Lark-Admin-Signature", sig)
|
||||
req.Header.Set("X-Lark-Admin-Timestamp", ts)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
33
cmd/sec/factory.go
Normal file
33
cmd/sec/factory.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
intsec "github.com/larksuite/cli/internal/sec"
|
||||
)
|
||||
|
||||
// installer wires up an internal/sec.Installer using the Factory's HTTP client,
|
||||
// the default platform paths, and a lazy OAPI-client provider used to fetch
|
||||
// the install manifest. APIClientFunc is a method value, not an eager call —
|
||||
// commands that short-circuit (or that never install, like sec status / sec
|
||||
// stop) avoid decrypting credentials from the keychain. Every cmd/sec
|
||||
// subcommand starts here.
|
||||
func installer(f *cmdutil.Factory) (*intsec.Installer, *intsec.Paths, error) {
|
||||
paths, err := intsec.DefaultPaths()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve sec paths: %w", err)
|
||||
}
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve http client: %w", err)
|
||||
}
|
||||
return &intsec.Installer{
|
||||
Paths: paths,
|
||||
HTTPClient: httpClient,
|
||||
APIClientFunc: f.NewAPIClient,
|
||||
}, paths, nil
|
||||
}
|
||||
127
cmd/sec/run.go
Normal file
127
cmd/sec/run.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
intsec "github.com/larksuite/cli/internal/sec"
|
||||
)
|
||||
|
||||
// RunOptions holds inputs for `lark-cli sec run`.
|
||||
type RunOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
ProxyPort int
|
||||
// AutoInstall runs `sec install` first when no binary is recorded.
|
||||
AutoInstall bool
|
||||
}
|
||||
|
||||
// NewCmdSecRun starts lark-sec-cli as a user-level system service so it
|
||||
// persists across logins and gets restarted by the OS supervisor if it
|
||||
// crashes. Under the hood it shells out to `lark-sec-cli service enable`,
|
||||
// which is the recommended startup path per the lark-sec-cli manual:
|
||||
//
|
||||
// - macOS → user-level launchd plist with KeepAlive=true
|
||||
// - Linux → user systemd unit with Restart=always
|
||||
// - Windows → registry autostart + a VBS watchdog loop
|
||||
//
|
||||
// Switching to this from a detached `exec.Command(... Setsid:true)` spawn
|
||||
// fixes two latent issues at once: (1) daemon logs survive past lark-cli
|
||||
// exit because the service supervisor — not our terminated pipes — owns
|
||||
// the daemon's stdout, and (2) the daemon's own self-upgrade module can
|
||||
// now fire (it gates on running-under-supervisor).
|
||||
func NewCmdSecRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command {
|
||||
opts := &RunOptions{Factory: f, AutoInstall: true}
|
||||
cmd := &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Enable lark-sec-cli as a user system service (the daemon runs in the background)",
|
||||
Long: `Install lark-sec-cli as a user-level system service so the proxy
|
||||
daemon runs automatically, persists across logins, and is restarted by the
|
||||
OS if it exits. The daemon writes its own log file (default: under
|
||||
~/.lark-sec-cli/logs/daemon.log) so logs persist independently of this
|
||||
command.
|
||||
|
||||
After enabling, the daemon writes ~/.lark-cli/sec_config.json itself with
|
||||
the proxy port and CA path, so subsequent lark-cli runs route through the
|
||||
sidecar without any further action.
|
||||
|
||||
To stop and remove the service: lark-cli sec stop.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runRun(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "force lark-sec-cli to bind this port (default: dynamic)")
|
||||
cmd.Flags().BoolVar(&opts.AutoInstall, "auto-install", true, "bootstrap-install lark-sec-cli first when no binary is recorded")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runRun(cmd *cobra.Command, opts *RunOptions) error {
|
||||
ctx := cmd.Context()
|
||||
errOut := opts.Factory.IOStreams.ErrOut
|
||||
trace := verboseOut(cmd, errOut)
|
||||
|
||||
tracef(trace, "sec run", "constructing installer (lazy credentials)")
|
||||
inst, paths, err := installer(opts.Factory)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
|
||||
// Make sure we have a binary on disk before asking it to install itself
|
||||
// as a service.
|
||||
tracef(trace, "sec run", "loading state from %s", paths.StateFile())
|
||||
state, err := intsec.LoadState(paths.StateFile())
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
|
||||
}
|
||||
if state == nil {
|
||||
tracef(trace, "sec run", "no install on disk (auto-install=%t)", opts.AutoInstall)
|
||||
if !opts.AutoInstall {
|
||||
return output.ErrWithHint(output.ExitValidation, "sec_not_installed",
|
||||
"lark-sec-cli is not installed",
|
||||
"Re-run `lark-cli sec run` with --auto-install (default on), or remove --auto-install=false.")
|
||||
}
|
||||
state, err = inst.Install(ctx, intsec.InstallOptions{Verbose: trace})
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitNetwork, "sec_install", "auto-install lark-sec-cli: %v", err)
|
||||
}
|
||||
} else {
|
||||
tracef(trace, "sec run", "existing install: version=%s binary=%s", state.Version, state.BinaryPath)
|
||||
}
|
||||
|
||||
args := []string{"service", "enable"}
|
||||
if opts.ProxyPort > 0 {
|
||||
args = append(args, fmt.Sprintf("--proxy-port=%d", opts.ProxyPort))
|
||||
}
|
||||
|
||||
fmt.Fprintf(errOut, "Running: %s %v\n", state.BinaryPath, args)
|
||||
tracef(trace, "sec run", "shelling out to %s %v", state.BinaryPath, args)
|
||||
|
||||
c := exec.CommandContext(ctx, state.BinaryPath, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
if err := c.Run(); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "sec_service_enable",
|
||||
"`lark-sec-cli service enable` failed: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
tracef(trace, "sec run", "service enable returned ok (%d bytes stdout)", stdout.Len())
|
||||
|
||||
// Forward the installer's stdout to the user — it contains the launchd /
|
||||
// systemd unit name, the registered executable path, and a confirmation
|
||||
// that the supervisor will respawn the daemon on exit. Useful diagnostic
|
||||
// output that's better seen than swallowed.
|
||||
fmt.Fprint(errOut, stdout.String())
|
||||
output.PrintSuccess(errOut,
|
||||
"lark-sec-cli enabled as a user system service. Run `lark-cli sec status` to verify, `lark-cli sec stop` to disable.")
|
||||
return nil
|
||||
}
|
||||
49
cmd/sec/sec.go
Normal file
49
cmd/sec/sec.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package sec exposes the `lark-cli sec` command tree that bootstraps the
|
||||
// lark-sec-cli sidecar daemon: install, run, stop, status, and `config init`.
|
||||
// The internal/sec package owns the implementation; this package is a thin
|
||||
// Cobra wrapper that mirrors the conventions in cmd/auth.
|
||||
//
|
||||
// After bootstrap install, lark-sec-cli handles its own upgrade lifecycle —
|
||||
// lark-cli is not in the update path, which is why there's no `sec update`
|
||||
// subcommand here.
|
||||
package sec
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmdSec builds the parent `sec` command and registers all subcommands.
|
||||
//
|
||||
// The persistent --verbose / -v flag is inherited by every subcommand:
|
||||
// `sec run -v`, `sec status -v`, etc. all emit step-by-step trace output to
|
||||
// stderr.
|
||||
//
|
||||
// There is no `sec install` subcommand — `sec run` auto-installs lark-sec-cli
|
||||
// if no binary is on disk, so a separate install verb was redundant.
|
||||
func NewCmdSec(f *cmdutil.Factory) *cobra.Command {
|
||||
var verbose bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "sec",
|
||||
Short: "Manage the lark-sec-cli security sidecar (run, status, stop, config)",
|
||||
Long: `Manage the lark-sec-cli security sidecar.
|
||||
|
||||
lark-sec-cli is a local HTTPS proxy daemon that intercepts lark-cli's traffic,
|
||||
injects BDMS risk-control signatures, and manages credentials via the OS
|
||||
keychain. These subcommands handle the runtime lifecycle from lark-cli's side:
|
||||
start the daemon (auto-installing on first run), inspect its state, register
|
||||
an app with it, and stop it. Updates after the first install are managed by
|
||||
lark-sec-cli itself.`,
|
||||
}
|
||||
cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
|
||||
"print step-by-step pipeline output to stderr")
|
||||
cmd.AddCommand(NewCmdSecRun(f, nil))
|
||||
cmd.AddCommand(NewCmdSecStop(f, nil))
|
||||
cmd.AddCommand(NewCmdSecStatus(f, nil))
|
||||
cmd.AddCommand(NewCmdSecConfig(f))
|
||||
return cmd
|
||||
}
|
||||
38
cmd/sec/sec_test.go
Normal file
38
cmd/sec/sec_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// TestNewCmdSec_HasAllSubcommands locks in the public command surface so a
|
||||
// future refactor doesn't silently drop run/status/etc. The `update` verb
|
||||
// was intentionally removed when lark-sec-cli took over its own upgrade
|
||||
// lifecycle; if it ever needs to come back, add it here too. `install` was
|
||||
// removed because `sec run --auto-install` (default on) makes a standalone
|
||||
// install verb redundant.
|
||||
func TestNewCmdSec_HasAllSubcommands(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := NewCmdSec(f)
|
||||
|
||||
var got []string
|
||||
for _, c := range cmd.Commands() {
|
||||
got = append(got, c.Name())
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := []string{"config", "run", "status", "stop"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("subcommands = %v, want %v", got, want)
|
||||
}
|
||||
for i, name := range want {
|
||||
if got[i] != name {
|
||||
t.Errorf("subcommands[%d] = %q, want %q", i, got[i], name)
|
||||
}
|
||||
}
|
||||
}
|
||||
115
cmd/sec/status.go
Normal file
115
cmd/sec/status.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
intsec "github.com/larksuite/cli/internal/sec"
|
||||
)
|
||||
|
||||
// StatusOptions holds inputs for `lark-cli sec status`.
|
||||
type StatusOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
}
|
||||
|
||||
// NewCmdSecStatus shows install + runtime state. Implementation strategy:
|
||||
//
|
||||
// 1. Read lark-cli's local install record (state.json) — works even when the
|
||||
// daemon's not installed, and gives the user a version/buildId/path
|
||||
// fingerprint regardless of whether the service is up.
|
||||
// 2. If the install exists, shell out to `lark-sec-cli status` for the
|
||||
// live daemon view (service registration, pid liveness, proxy probe,
|
||||
// sec_config.json contents). The daemon's own status command does a
|
||||
// thorough check; we just pass it through.
|
||||
func NewCmdSecStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
|
||||
opts := &StatusOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show lark-sec-cli install and runtime state",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runStatus(cmd, opts)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runStatus(cmd *cobra.Command, opts *StatusOptions) error {
|
||||
errOut := opts.Factory.IOStreams.ErrOut
|
||||
trace := verboseOut(cmd, errOut)
|
||||
|
||||
tracef(trace, "sec status", "constructing installer (lazy credentials)")
|
||||
_, paths, err := installer(opts.Factory)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
out := opts.Factory.IOStreams.Out
|
||||
tracef(trace, "sec status", "loading state from %s", paths.StateFile())
|
||||
state, err := intsec.LoadState(paths.StateFile())
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
|
||||
}
|
||||
if state == nil {
|
||||
fmt.Fprintln(out, "lark-sec-cli: not installed")
|
||||
fmt.Fprintln(out, " run: lark-cli sec run")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(out, "lark-sec-cli %s\n", state.Version)
|
||||
fmt.Fprintf(out, " binary: %s\n", state.BinaryPath)
|
||||
|
||||
// Daemon-side detail via `lark-sec-cli status`. The daemon's status
|
||||
// command already covers service registration + pid + proxy reachability
|
||||
// + bridge file — better than re-implementing those here.
|
||||
tracef(trace, "sec status", "shelling out to %s status", state.BinaryPath)
|
||||
c := exec.CommandContext(cmd.Context(), state.BinaryPath, "status")
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
runErr := c.Run()
|
||||
tracef(trace, "sec status", "daemon status exit=%v stdout=%d bytes stderr=%d bytes", runErr, stdout.Len(), stderr.Len())
|
||||
fmt.Fprintln(out, " --- lark-sec-cli status ---")
|
||||
if stdout.Len() > 0 {
|
||||
fmt.Fprint(out, indent(stdout.String(), " "))
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
fmt.Fprint(out, indent(stderr.String(), " "))
|
||||
}
|
||||
// `lark-sec-cli status` exits 1 when not running — that's diagnostic
|
||||
// data, not a failure of OUR command. Surface it for the user but don't
|
||||
// propagate the non-zero exit upward.
|
||||
_ = runErr
|
||||
return nil
|
||||
}
|
||||
|
||||
// indent prefixes every line of s with prefix. Cheap pass-through formatter
|
||||
// used to make the embedded `lark-sec-cli status` output read as a sub-block
|
||||
// under our own header.
|
||||
func indent(s, prefix string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
buf.WriteString(prefix)
|
||||
buf.WriteString(s[start : i+1])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
buf.WriteString(prefix)
|
||||
buf.WriteString(s[start:])
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
82
cmd/sec/stop.go
Normal file
82
cmd/sec/stop.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
intsec "github.com/larksuite/cli/internal/sec"
|
||||
)
|
||||
|
||||
// StopOptions holds inputs for `lark-cli sec stop`.
|
||||
type StopOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
}
|
||||
|
||||
// NewCmdSecStop disables and removes the lark-sec-cli user system service.
|
||||
// Counterpart to `sec run` — internally invokes `lark-sec-cli service disable`,
|
||||
// which uninstalls the launchd / systemd / VBS-watchdog registration.
|
||||
//
|
||||
// The daemon itself wipes ~/.lark-cli/sec_config.json on shutdown (see its
|
||||
// --disable-on-exit flag, default true), so subsequent lark-cli runs route
|
||||
// directly to the upstream API instead of dangling through a dead local proxy.
|
||||
func NewCmdSecStop(f *cmdutil.Factory, runF func(*StopOptions) error) *cobra.Command {
|
||||
opts := &StopOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Disable and remove the lark-sec-cli user system service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runStop(cmd, opts)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runStop(cmd *cobra.Command, opts *StopOptions) error {
|
||||
out := opts.Factory.IOStreams.ErrOut
|
||||
trace := verboseOut(cmd, out)
|
||||
|
||||
tracef(trace, "sec stop", "constructing installer (lazy credentials)")
|
||||
_, paths, err := installer(opts.Factory)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
tracef(trace, "sec stop", "loading state from %s", paths.StateFile())
|
||||
state, err := intsec.LoadState(paths.StateFile())
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
|
||||
}
|
||||
if state == nil {
|
||||
// Nothing on disk to stop — no-op.
|
||||
tracef(trace, "sec stop", "no install on disk; nothing to stop")
|
||||
output.PrintSuccess(out, "lark-sec-cli not installed; nothing to stop")
|
||||
return nil
|
||||
}
|
||||
|
||||
args := []string{"service", "disable"}
|
||||
fmt.Fprintf(out, "Running: %s %v\n", state.BinaryPath, args)
|
||||
tracef(trace, "sec stop", "shelling out to %s %v", state.BinaryPath, args)
|
||||
|
||||
c := exec.CommandContext(cmd.Context(), state.BinaryPath, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
if err := c.Run(); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "sec_service_disable",
|
||||
"`lark-sec-cli service disable` failed: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
tracef(trace, "sec stop", "service disable returned ok (%d bytes stdout)", stdout.Len())
|
||||
fmt.Fprint(out, stdout.String())
|
||||
output.PrintSuccess(out, "lark-sec-cli service disabled")
|
||||
return nil
|
||||
}
|
||||
32
cmd/sec/verbose.go
Normal file
32
cmd/sec/verbose.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// verboseOut returns the trace destination for a sec subcommand: the given
|
||||
// stderr writer when the inherited --verbose / -v flag is set, otherwise nil.
|
||||
// Pair with tracef — a nil destination silently drops traces, so callers can
|
||||
// emit unconditionally.
|
||||
func verboseOut(cmd *cobra.Command, errOut io.Writer) io.Writer {
|
||||
if v, _ := cmd.Flags().GetBool("verbose"); v {
|
||||
return errOut
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tracef writes one trace line to w when w is non-nil. The prefix names the
|
||||
// emitting subcommand (e.g. "sec run") so layered output from the install
|
||||
// pipeline + the command itself stays distinguishable.
|
||||
func tracef(w io.Writer, prefix, format string, args ...any) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "[%s] "+format+"\n", append([]any{prefix}, args...)...)
|
||||
}
|
||||
227
extension/credential/secplugin/provider.go
Normal file
227
extension/credential/secplugin/provider.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package secplugin provides a placeholder credential provider for SEC_AUTH mode.
|
||||
//
|
||||
// When ~/.lark-cli/sec_config.json has:
|
||||
//
|
||||
// LARKSUITE_CLI_SEC_ENABLE=true
|
||||
// LARKSUITE_CLI_SEC_AUTH=true
|
||||
//
|
||||
// this provider returns a minimal Account and placeholder tokens. The proxy
|
||||
// is expected to replace the placeholder tokens with real ones.
|
||||
package secplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
internalsec "github.com/larksuite/cli/internal/secplugin"
|
||||
)
|
||||
|
||||
// Provider supplies placeholder credentials when SEC_AUTH mode is enabled.
|
||||
type Provider struct{}
|
||||
|
||||
// Name returns the registered credential provider name.
|
||||
func (p *Provider) Name() string { return "secplugin" }
|
||||
|
||||
// Priority is higher than env (default 10) but lower than sidecar (0),
|
||||
// so authsidecar builds keep sidecar semantics when both are present.
|
||||
func (p *Provider) Priority() int { return 1 }
|
||||
|
||||
// loadSecConfig is replaceable in tests so provider behavior can be isolated
|
||||
// from on-disk SEC configuration state.
|
||||
var loadSecConfig = internalsec.Load
|
||||
|
||||
func validateDefaultAs(value string) error {
|
||||
switch id := credential.Identity(strings.TrimSpace(value)); id {
|
||||
case "", credential.IdentityAuto, credential.IdentityUser, credential.IdentityBot:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id)
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveAccount builds an account that advertises SEC_AUTH placeholder support.
|
||||
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
cfg, err := loadSecConfig()
|
||||
if err != nil {
|
||||
return nil, &credential.BlockError{Provider: p.Name(), Reason: err.Error()}
|
||||
}
|
||||
if cfg == nil || !cfg.AuthEnabled() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
appID := strings.TrimSpace(os.Getenv(envvars.CliAppID))
|
||||
brand := credential.Brand(strings.TrimSpace(os.Getenv(envvars.CliBrand)))
|
||||
var defaultAs credential.Identity
|
||||
|
||||
// Prefer explicit env; if missing, allow sec_config.json to provide defaults.
|
||||
if appID == "" && strings.TrimSpace(cfg.AppID) != "" {
|
||||
appID = strings.TrimSpace(cfg.AppID)
|
||||
}
|
||||
if brand == "" && strings.TrimSpace(cfg.Brand) != "" {
|
||||
brand = credential.Brand(strings.TrimSpace(cfg.Brand))
|
||||
}
|
||||
if defaultAs == "" && strings.TrimSpace(cfg.DefaultAs) != "" {
|
||||
defaultAs = credential.Identity(strings.TrimSpace(cfg.DefaultAs))
|
||||
if err := validateDefaultAs(string(defaultAs)); err != nil {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: p.Name(),
|
||||
Reason: err.Error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer explicit env for sandbox use; otherwise fall back to on-disk config
|
||||
// without resolving any secrets.
|
||||
if appID == "" || brand == "" {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil || multi == nil {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: p.Name(),
|
||||
Reason: "SEC_AUTH is enabled but no app config is available; run `lark-cli config init --new` (trusted env), or set " + envvars.CliAppID + " and " + envvars.CliBrand,
|
||||
}
|
||||
}
|
||||
app := multi.CurrentAppConfig("") // profile override not available in provider API
|
||||
if app == nil {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: p.Name(),
|
||||
Reason: "SEC_AUTH is enabled but no active profile is available in config.json",
|
||||
}
|
||||
}
|
||||
if appID == "" {
|
||||
appID = app.AppId
|
||||
}
|
||||
if brand == "" {
|
||||
brand = credential.Brand(app.Brand)
|
||||
}
|
||||
if defaultAs == "" {
|
||||
defaultAs = credential.Identity(app.DefaultAs)
|
||||
}
|
||||
|
||||
// Map strict mode to supported identities (0 = allow all).
|
||||
mode := multi.StrictMode
|
||||
if app.StrictMode != nil {
|
||||
mode = *app.StrictMode
|
||||
}
|
||||
switch mode {
|
||||
case core.StrictModeBot:
|
||||
// Keep sandbox locked down to bot.
|
||||
return &credential.Account{
|
||||
AppID: appID,
|
||||
AppSecret: credential.NoAppSecret,
|
||||
Brand: brand,
|
||||
DefaultAs: defaultAs,
|
||||
SupportedIdentities: credential.SupportsBot,
|
||||
}, nil
|
||||
case core.StrictModeUser:
|
||||
return &credential.Account{
|
||||
AppID: appID,
|
||||
AppSecret: credential.NoAppSecret,
|
||||
Brand: brand,
|
||||
DefaultAs: defaultAs,
|
||||
SupportedIdentities: credential.SupportsUser,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: p.Name(),
|
||||
Reason: "SEC_AUTH is enabled but " + envvars.CliAppID + " is missing",
|
||||
}
|
||||
}
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
if brand != credential.BrandFeishu && brand != credential.BrandLark {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: p.Name(),
|
||||
Reason: fmt.Sprintf("invalid %s %q (want feishu or lark)", envvars.CliBrand, brand),
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultAs comes from env if present (optional).
|
||||
envDefaultAs := strings.TrimSpace(os.Getenv(envvars.CliDefaultAs))
|
||||
if err := validateDefaultAs(envDefaultAs); err != nil {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: p.Name(),
|
||||
Reason: err.Error(),
|
||||
}
|
||||
}
|
||||
switch id := credential.Identity(envDefaultAs); id {
|
||||
case "", credential.IdentityAuto:
|
||||
// keep defaultAs from config/env; empty is allowed
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
defaultAs = id
|
||||
}
|
||||
|
||||
// If STRICT_MODE env is not set, allow sec_config.json to provide a default.
|
||||
strictModeRaw := strings.TrimSpace(os.Getenv(envvars.CliStrictMode))
|
||||
if strictModeRaw == "" && strings.TrimSpace(cfg.StrictMode) != "" {
|
||||
strictModeRaw = strings.TrimSpace(cfg.StrictMode)
|
||||
}
|
||||
|
||||
// SupportedIdentities from STRICT_MODE (optional). Default: allow both.
|
||||
support := credential.SupportsAll
|
||||
switch strictMode := strictModeRaw; strictMode {
|
||||
case "bot":
|
||||
support = credential.SupportsBot
|
||||
case "user":
|
||||
support = credential.SupportsUser
|
||||
case "off", "":
|
||||
// Keep the default: allow both identities.
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: p.Name(),
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
}
|
||||
}
|
||||
|
||||
return &credential.Account{
|
||||
AppID: appID,
|
||||
AppSecret: credential.NoAppSecret,
|
||||
Brand: brand,
|
||||
DefaultAs: defaultAs,
|
||||
SupportedIdentities: support,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveToken returns placeholder tokens that a trusted proxy must replace.
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
cfg, err := internalsec.Load()
|
||||
if err != nil {
|
||||
return nil, &credential.BlockError{Provider: p.Name(), Reason: err.Error()}
|
||||
}
|
||||
if cfg == nil || !cfg.AuthEnabled() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch req.Type {
|
||||
case credential.TokenTypeUAT:
|
||||
return &credential.Token{
|
||||
Value: internalsec.SentinelUAT,
|
||||
Scopes: "", // empty => skip scope pre-check
|
||||
Source: "secplugin",
|
||||
}, nil
|
||||
case credential.TokenTypeTAT:
|
||||
return &credential.Token{
|
||||
Value: internalsec.SentinelTAT,
|
||||
Scopes: "",
|
||||
Source: "secplugin",
|
||||
}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// init registers the SEC_AUTH placeholder credential provider.
|
||||
func init() {
|
||||
credential.Register(&Provider{})
|
||||
}
|
||||
486
extension/credential/secplugin/provider_test.go
Normal file
486
extension/credential/secplugin/provider_test.go
Normal file
@@ -0,0 +1,486 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package secplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
internalsec "github.com/larksuite/cli/internal/secplugin"
|
||||
)
|
||||
|
||||
// TestProvider_Metadata verifies the registered provider metadata.
|
||||
func TestProvider_Metadata(t *testing.T) {
|
||||
p := &Provider{}
|
||||
if p.Name() != "secplugin" {
|
||||
t.Fatalf("Name() = %q, want secplugin", p.Name())
|
||||
}
|
||||
if p.Priority() != 1 {
|
||||
t.Fatalf("Priority() = %d, want 1", p.Priority())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_UsesSecConfigDefaults verifies that SEC config defaults populate
|
||||
// the placeholder account when env vars are absent.
|
||||
func TestProvider_UsesSecConfigDefaults(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
loadSecConfig = func() (*internalsec.Config, error) {
|
||||
return &internalsec.Config{
|
||||
Enable: true,
|
||||
Auth: true,
|
||||
AppID: "cli_test_app",
|
||||
Brand: "lark",
|
||||
DefaultAs: "bot",
|
||||
StrictMode: "bot",
|
||||
}, nil
|
||||
}
|
||||
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliBrand, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliStrictMode, "")
|
||||
|
||||
p := &Provider{}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("ResolveAccount() = nil, want account")
|
||||
}
|
||||
if acct.AppID != "cli_test_app" {
|
||||
t.Fatalf("acct.AppID = %q, want %q", acct.AppID, "cli_test_app")
|
||||
}
|
||||
if string(acct.Brand) != "lark" {
|
||||
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, "lark")
|
||||
}
|
||||
if string(acct.DefaultAs) != "bot" {
|
||||
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, "bot")
|
||||
}
|
||||
// StrictMode=bot => SupportsBot only.
|
||||
if acct.SupportedIdentities != 2 {
|
||||
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsBot)", acct.SupportedIdentities, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_EnvOverridesSecConfigDefaults verifies that explicit environment
|
||||
// variables override SEC config defaults.
|
||||
func TestProvider_EnvOverridesSecConfigDefaults(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
loadSecConfig = func() (*internalsec.Config, error) {
|
||||
return &internalsec.Config{
|
||||
Enable: true,
|
||||
Auth: true,
|
||||
AppID: "cli_test_app",
|
||||
Brand: "feishu",
|
||||
DefaultAs: "bot",
|
||||
StrictMode: "bot",
|
||||
}, nil
|
||||
}
|
||||
|
||||
t.Setenv(envvars.CliAppID, "cli_env_app")
|
||||
t.Setenv(envvars.CliBrand, "lark")
|
||||
t.Setenv(envvars.CliDefaultAs, "user")
|
||||
t.Setenv(envvars.CliStrictMode, "user")
|
||||
|
||||
p := &Provider{}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("ResolveAccount() = nil, want account")
|
||||
}
|
||||
if acct.AppID != "cli_env_app" {
|
||||
t.Fatalf("acct.AppID = %q, want %q", acct.AppID, "cli_env_app")
|
||||
}
|
||||
if string(acct.Brand) != "lark" {
|
||||
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, "lark")
|
||||
}
|
||||
if string(acct.DefaultAs) != "user" {
|
||||
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, "user")
|
||||
}
|
||||
// StrictMode=user => SupportsUser only (bit 1).
|
||||
if acct.SupportedIdentities != 1 {
|
||||
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsUser)", acct.SupportedIdentities, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_ResolveAccount_ReturnsNilWhenDisabled verifies early nil returns
|
||||
// when SEC_AUTH mode is unavailable.
|
||||
func TestProvider_ResolveAccount_ReturnsNilWhenDisabled(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg *internalsec.Config
|
||||
}{
|
||||
{name: "nil config", cfg: nil},
|
||||
{name: "auth disabled", cfg: &internalsec.Config{Enable: true, Auth: false}},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
loadSecConfig = func() (*internalsec.Config, error) { return tt.cfg, nil }
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_ResolveAccount_LoadErrorBlocks verifies that SEC config load failures
|
||||
// stop provider resolution.
|
||||
func TestProvider_ResolveAccount_LoadErrorBlocks(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
loadSecConfig = func() (*internalsec.Config, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ResolveAccount() error = nil, want block error")
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
|
||||
}
|
||||
blockErr, ok := err.(*credential.BlockError)
|
||||
if !ok {
|
||||
t.Fatalf("ResolveAccount() error = %T, want *credential.BlockError", err)
|
||||
}
|
||||
if blockErr.Provider != "secplugin" {
|
||||
t.Fatalf("blockErr.Provider = %q, want secplugin", blockErr.Provider)
|
||||
}
|
||||
if !strings.Contains(blockErr.Reason, context.DeadlineExceeded.Error()) {
|
||||
t.Fatalf("blockErr.Reason = %q, want load error text", blockErr.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_ResolveAccount_DefaultsBrandAndSupport verifies fallback defaults
|
||||
// for brand and supported identities.
|
||||
func TestProvider_ResolveAccount_DefaultsBrandAndSupport(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
loadSecConfig = func() (*internalsec.Config, error) {
|
||||
return &internalsec.Config{
|
||||
Enable: true,
|
||||
Auth: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliBrand, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliStrictMode, "")
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app_from_disk",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
DefaultAs: core.AsBot,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("ResolveAccount() = nil, want account")
|
||||
}
|
||||
if acct.Brand != credential.BrandFeishu {
|
||||
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, credential.BrandFeishu)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Fatalf("acct.SupportedIdentities = %d, want %d", acct.SupportedIdentities, credential.SupportsAll)
|
||||
}
|
||||
if acct.DefaultAs != credential.Identity("bot") {
|
||||
t.Fatalf("acct.DefaultAs = %q, want bot", acct.DefaultAs)
|
||||
}
|
||||
if acct.AppID != "app_from_disk" {
|
||||
t.Fatalf("acct.AppID = %q, want app_from_disk", acct.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_ResolveAccount_InvalidValuesBlock verifies validation failures for
|
||||
// brand and identity-related settings.
|
||||
func TestProvider_ResolveAccount_InvalidValuesBlock(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg *internalsec.Config
|
||||
envKey string
|
||||
envValue string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "invalid brand from config",
|
||||
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "bad-brand"},
|
||||
want: "invalid " + envvars.CliBrand,
|
||||
},
|
||||
{
|
||||
name: "invalid default as from config",
|
||||
cfg: &internalsec.Config{
|
||||
Enable: true,
|
||||
Auth: true,
|
||||
AppID: "cli_test_app",
|
||||
Brand: "lark",
|
||||
DefaultAs: "bad",
|
||||
},
|
||||
want: "invalid " + envvars.CliDefaultAs,
|
||||
},
|
||||
{
|
||||
name: "invalid default as from env",
|
||||
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "lark"},
|
||||
envKey: envvars.CliDefaultAs,
|
||||
envValue: "bad",
|
||||
want: "invalid " + envvars.CliDefaultAs,
|
||||
},
|
||||
{
|
||||
name: "invalid strict mode from env",
|
||||
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "lark"},
|
||||
envKey: envvars.CliStrictMode,
|
||||
envValue: "bad",
|
||||
want: "invalid " + envvars.CliStrictMode,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
loadSecConfig = func() (*internalsec.Config, error) { return tt.cfg, nil }
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliBrand, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliStrictMode, "")
|
||||
if tt.envKey != "" {
|
||||
t.Setenv(tt.envKey, tt.envValue)
|
||||
}
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ResolveAccount() error = nil, want block error")
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
|
||||
}
|
||||
blockErr, ok := err.(*credential.BlockError)
|
||||
if !ok {
|
||||
t.Fatalf("ResolveAccount() error = %T, want *credential.BlockError", err)
|
||||
}
|
||||
if !strings.Contains(blockErr.Reason, tt.want) {
|
||||
t.Fatalf("blockErr.Reason = %q, want substring %q", blockErr.Reason, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_ResolveAccount_FallbackToDiskConfig verifies fallback behavior
|
||||
// when SEC config omits app identity fields.
|
||||
func TestProvider_ResolveAccount_FallbackToDiskConfig(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
loadSecConfig = func() (*internalsec.Config, error) {
|
||||
return &internalsec.Config{Enable: true, Auth: true}, nil
|
||||
}
|
||||
|
||||
t.Run("missing config blocks", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliBrand, "")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ResolveAccount() error = nil, want block error")
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
|
||||
}
|
||||
blockErr := err.(*credential.BlockError)
|
||||
if !strings.Contains(blockErr.Reason, "no app config is available") {
|
||||
t.Fatalf("blockErr.Reason = %q, want missing app config message", blockErr.Reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing active profile blocks", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app_from_disk",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.LarkBrand("lark"),
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ResolveAccount() error = nil, want block error")
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
|
||||
}
|
||||
blockErr := err.(*credential.BlockError)
|
||||
if !strings.Contains(blockErr.Reason, "no active profile") {
|
||||
t.Fatalf("blockErr.Reason = %q, want no active profile message", blockErr.Reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("strict mode from disk", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mode core.StrictMode
|
||||
wantIDs credential.IdentitySupport
|
||||
}{
|
||||
{name: "bot", mode: core.StrictModeBot, wantIDs: credential.SupportsBot},
|
||||
{name: "user", mode: core.StrictModeUser, wantIDs: credential.SupportsUser},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
mode := tt.mode
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app_from_disk",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.LarkBrand("lark"),
|
||||
DefaultAs: core.AsBot,
|
||||
StrictMode: &mode,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("ResolveAccount() = nil, want account")
|
||||
}
|
||||
if acct.AppID != "app_from_disk" {
|
||||
t.Fatalf("acct.AppID = %q, want app_from_disk", acct.AppID)
|
||||
}
|
||||
if acct.Brand != credential.Brand("lark") {
|
||||
t.Fatalf("acct.Brand = %q, want lark", acct.Brand)
|
||||
}
|
||||
if acct.DefaultAs != credential.Identity("bot") {
|
||||
t.Fatalf("acct.DefaultAs = %q, want bot", acct.DefaultAs)
|
||||
}
|
||||
if acct.SupportedIdentities != tt.wantIDs {
|
||||
t.Fatalf("acct.SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.wantIDs)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestProvider_ResolveAccount_StrictModePreservesConfiguredDefaultAs verifies
|
||||
// cfg.DefaultAs is not overwritten by disk profile default in strict-mode path.
|
||||
func TestProvider_ResolveAccount_StrictModePreservesConfiguredDefaultAs(t *testing.T) {
|
||||
prev := loadSecConfig
|
||||
t.Cleanup(func() { loadSecConfig = prev })
|
||||
|
||||
loadSecConfig = func() (*internalsec.Config, error) {
|
||||
return &internalsec.Config{
|
||||
Enable: true,
|
||||
Auth: true,
|
||||
Brand: "lark",
|
||||
DefaultAs: "user",
|
||||
StrictMode: "bot",
|
||||
}, nil
|
||||
}
|
||||
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliBrand, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliStrictMode, "")
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app_from_disk",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.LarkBrand("lark"),
|
||||
DefaultAs: core.AsBot,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("ResolveAccount() = nil, want account")
|
||||
}
|
||||
if acct.DefaultAs != credential.IdentityUser {
|
||||
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, credential.IdentityUser)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsBot {
|
||||
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsBot)", acct.SupportedIdentities, credential.SupportsBot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_ResolveToken_ReturnsSentinels verifies placeholder token behavior
|
||||
// for SEC_AUTH mode.
|
||||
func TestProvider_ResolveToken_ReturnsSentinels(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envvars.CliSecEnable, "true")
|
||||
t.Setenv(envvars.CliSecAuth, "true")
|
||||
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:3128")
|
||||
t.Setenv(envvars.CliSecCA, "")
|
||||
|
||||
p := &Provider{}
|
||||
|
||||
uat, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken(UAT) error = %v", err)
|
||||
}
|
||||
if uat == nil || uat.Value != internalsec.SentinelUAT || uat.Source != "secplugin" {
|
||||
t.Fatalf("ResolveToken(UAT) = %#v, want sentinel UAT token", uat)
|
||||
}
|
||||
|
||||
tat, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken(TAT) error = %v", err)
|
||||
}
|
||||
if tat == nil || tat.Value != internalsec.SentinelTAT || tat.Source != "secplugin" {
|
||||
t.Fatalf("ResolveToken(TAT) = %#v, want sentinel TAT token", tat)
|
||||
}
|
||||
|
||||
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenType("other")})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken(other) error = %v", err)
|
||||
}
|
||||
if tok != nil {
|
||||
t.Fatalf("ResolveToken(other) = %#v, want nil", tok)
|
||||
}
|
||||
}
|
||||
3
go.mod
3
go.mod
@@ -11,7 +11,6 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
@@ -20,7 +19,6 @@ require (
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sync v0.15.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/term v0.27.0
|
||||
golang.org/x/text v0.23.0
|
||||
@@ -63,4 +61,5 @@ require (
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
)
|
||||
|
||||
15
go.sum
15
go.sum
@@ -45,7 +45,6 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -74,11 +73,6 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
@@ -103,8 +97,6 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
|
||||
@@ -115,10 +107,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
@@ -173,10 +163,7 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -4,18 +4,45 @@
|
||||
package envvars
|
||||
|
||||
const (
|
||||
CliAppID = "LARKSUITE_CLI_APP_ID"
|
||||
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
|
||||
CliBrand = "LARKSUITE_CLI_BRAND"
|
||||
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
|
||||
// CliAppID is the app ID environment variable consumed by the CLI.
|
||||
CliAppID = "LARKSUITE_CLI_APP_ID"
|
||||
|
||||
// CliAppSecret is the app secret environment variable consumed by the CLI.
|
||||
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
|
||||
|
||||
// CliBrand selects the tenant brand environment variable consumed by the CLI.
|
||||
CliBrand = "LARKSUITE_CLI_BRAND"
|
||||
|
||||
// CliUserAccessToken is the user access token override environment variable.
|
||||
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
|
||||
|
||||
// CliTenantAccessToken is the tenant access token override environment variable.
|
||||
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
|
||||
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
|
||||
// Sidecar proxy (auth proxy mode)
|
||||
// CliDefaultAs selects the default identity environment variable.
|
||||
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
|
||||
|
||||
// CliStrictMode selects the strict identity mode environment variable.
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
|
||||
// CliAuthProxy is the auth sidecar HTTP address environment variable.
|
||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
|
||||
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
||||
|
||||
// Content safety scanning mode
|
||||
// CliProxyKey is the shared HMAC signing key environment variable for the sidecar.
|
||||
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
||||
|
||||
// CliSecEnable enables sec plugin mode from the environment.
|
||||
CliSecEnable = "LARKSUITE_CLI_SEC_ENABLE"
|
||||
|
||||
// CliSecProxy sets the fixed sec plugin HTTP proxy address.
|
||||
CliSecProxy = "LARKSUITE_CLI_SEC_PROXY"
|
||||
|
||||
// CliSecCA points to an extra PEM bundle trusted by sec plugin mode.
|
||||
CliSecCA = "LARKSUITE_CLI_SEC_CA"
|
||||
|
||||
// CliSecAuth enables placeholder-token auth mode for sec plugin flows.
|
||||
CliSecAuth = "LARKSUITE_CLI_SEC_AUTH"
|
||||
|
||||
// CliContentSafetyMode selects the content safety scanning mode.
|
||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||
)
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
"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": "数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限管理" }
|
||||
|
||||
138
internal/sec/archive.go
Normal file
138
internal/sec/archive.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxArchiveBytes is a sanity ceiling for total uncompressed size to prevent
|
||||
// a malicious or corrupt zip from filling the disk. The lark-sec-cli zip is a
|
||||
// single binary plus one shared library; 1 GiB is several orders of magnitude
|
||||
// over the real size and well under most users' free disk.
|
||||
const maxArchiveBytes = 1 << 30
|
||||
|
||||
// ExtractZip unpacks src into dst, refusing entries whose target paths would
|
||||
// escape dst (zip slip). Existing files inside dst are overwritten; dst must
|
||||
// already exist.
|
||||
//
|
||||
// Executable permission is preserved when the zip stores POSIX mode bits;
|
||||
// otherwise we apply 0o755 to suspected binaries (matching BinaryName() /
|
||||
// legacy names or anything *.dylib/*.so/*.dll) and 0o644 to everything else.
|
||||
func ExtractZip(src, dst string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open zip: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
dstAbs, err := filepath.Abs(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var totalSize uint64
|
||||
for _, f := range r.File {
|
||||
totalSize += f.UncompressedSize64
|
||||
if totalSize > maxArchiveBytes {
|
||||
return fmt.Errorf("zip exceeds %d bytes; refusing", maxArchiveBytes)
|
||||
}
|
||||
if err := extractZipEntry(f, dstAbs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractZipEntry(f *zip.File, dstAbs string) error {
|
||||
// Reject absolute paths and any traversal segments. filepath.Clean
|
||||
// collapses redundant separators but does NOT resolve symlinks or strip
|
||||
// leading slashes — we have to do both explicitly.
|
||||
name := f.Name
|
||||
if strings.ContainsRune(name, 0) {
|
||||
return fmt.Errorf("zip entry name contains NUL: %q", name)
|
||||
}
|
||||
cleaned := filepath.Clean(name)
|
||||
if filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "..") ||
|
||||
strings.Contains(cleaned, string(filepath.Separator)+".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("zip entry escapes destination: %q", name)
|
||||
}
|
||||
|
||||
target := filepath.Join(dstAbs, cleaned)
|
||||
// Defense in depth: even if the checks above missed something, this rel
|
||||
// check guarantees target is under dstAbs.
|
||||
rel, err := filepath.Rel(dstAbs, target)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return fmt.Errorf("zip entry escapes destination: %q", name)
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Symlink support: zip entries can be symlinks (mode bit set). The
|
||||
// lark-sec-cli artifact doesn't currently use them, but if it grows to
|
||||
// (e.g. for shared library version aliases) we want graceful handling.
|
||||
if f.Mode()&os.ModeSymlink != 0 {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
linkBytes, readErr := io.ReadAll(io.LimitReader(rc, 1024))
|
||||
rc.Close()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
os.Remove(target) // os.Symlink fails if target exists
|
||||
return os.Symlink(string(linkBytes), target)
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
mode := f.Mode().Perm()
|
||||
if mode == 0 {
|
||||
mode = guessMode(cleaned)
|
||||
}
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, rc); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// guessMode supplies executable bits for entries the zip writer didn't tag
|
||||
// with POSIX mode info — typically the case for archives built on Windows.
|
||||
func guessMode(name string) os.FileMode {
|
||||
base := filepath.Base(name)
|
||||
if base == BinaryName() {
|
||||
return 0o755
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(base))
|
||||
switch ext {
|
||||
case ".dylib", ".so", ".dll":
|
||||
return 0o755
|
||||
}
|
||||
if runtime.GOOS != "windows" && !strings.ContainsRune(base, '.') {
|
||||
// Plausibly an extra unix binary shipped alongside the sec-cli binary.
|
||||
return 0o755
|
||||
}
|
||||
return 0o644
|
||||
}
|
||||
121
internal/sec/archive_test.go
Normal file
121
internal/sec/archive_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeZip builds an in-memory zip with the given entries, writes it to path,
|
||||
// and returns nothing — convenience for table-driven tests.
|
||||
type zipEntry struct {
|
||||
name string
|
||||
body string
|
||||
mode os.FileMode
|
||||
symlink string // when set, entry is a symlink with this target
|
||||
}
|
||||
|
||||
func makeZip(t *testing.T, path string, entries []zipEntry) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for _, e := range entries {
|
||||
hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate}
|
||||
if e.mode != 0 {
|
||||
hdr.SetMode(e.mode)
|
||||
}
|
||||
if e.symlink != "" {
|
||||
hdr.SetMode(os.ModeSymlink | 0o777)
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
t.Fatalf("zip header %q: %v", e.name, err)
|
||||
}
|
||||
body := e.body
|
||||
if e.symlink != "" {
|
||||
body = e.symlink
|
||||
}
|
||||
if _, err := io.WriteString(w, body); err != nil {
|
||||
t.Fatalf("zip write %q: %v", e.name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil {
|
||||
t.Fatalf("write zip: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractZip_HappyPath(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
zipPath := filepath.Join(tmp, "src.zip")
|
||||
makeZip(t, zipPath, []zipEntry{
|
||||
{name: "lark-sec-cli", body: "binary", mode: 0o755},
|
||||
{name: "ca.crt", body: "cert"},
|
||||
{name: "lib/libMetaSecML.dylib", body: "dylib", mode: 0o755},
|
||||
})
|
||||
dst := filepath.Join(tmp, "out")
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ExtractZip(zipPath, dst); err != nil {
|
||||
t.Fatalf("ExtractZip: %v", err)
|
||||
}
|
||||
|
||||
for name, want := range map[string]string{
|
||||
"lark-sec-cli": "binary",
|
||||
"ca.crt": "cert",
|
||||
"lib/libMetaSecML.dylib": "dylib",
|
||||
} {
|
||||
got, err := os.ReadFile(filepath.Join(dst, name))
|
||||
if err != nil {
|
||||
t.Errorf("read %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("%s body = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(dst, "lark-sec-cli")); err == nil {
|
||||
if info.Mode().Perm()&0o100 == 0 {
|
||||
t.Errorf("lark-sec-cli not executable: mode=%v", info.Mode())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractZip_RejectsTraversal(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
zipPath := filepath.Join(tmp, "evil.zip")
|
||||
makeZip(t, zipPath, []zipEntry{
|
||||
{name: "../../../etc/passwd", body: "pwned"},
|
||||
})
|
||||
dst := filepath.Join(tmp, "out")
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ExtractZip(zipPath, dst); err == nil {
|
||||
t.Fatal("ExtractZip accepted zip-slip entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractZip_RejectsAbsolutePath(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
zipPath := filepath.Join(tmp, "abs.zip")
|
||||
makeZip(t, zipPath, []zipEntry{
|
||||
{name: "/etc/passwd", body: "pwned"},
|
||||
})
|
||||
dst := filepath.Join(tmp, "out")
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ExtractZip(zipPath, dst); err == nil {
|
||||
t.Fatal("ExtractZip accepted absolute-path entry")
|
||||
}
|
||||
}
|
||||
33
internal/sec/bootstrap.go
Normal file
33
internal/sec/bootstrap.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// bootstrapManifestJSON is the lark-sec-cli release manifest shipped with this
|
||||
// lark-cli build. It points directly at TOS so a fresh install does not depend
|
||||
// on any external release-tracking service — first install is fully self-contained.
|
||||
//
|
||||
// Updating this file pins a new default version of lark-sec-cli for users who
|
||||
// install via lark-cli. After install, lark-sec-cli is in charge of finding and
|
||||
// applying its own updates; lark-cli does not consult any release server.
|
||||
//
|
||||
//go:embed bootstrap.json
|
||||
var bootstrapManifestJSON []byte
|
||||
|
||||
// LoadBootstrap parses the embedded bootstrap manifest into a Manifest value.
|
||||
func LoadBootstrap() (*Manifest, error) {
|
||||
var entries []Entry
|
||||
if err := json.Unmarshal(bootstrapManifestJSON, &entries); err != nil {
|
||||
return nil, fmt.Errorf("decode embedded bootstrap manifest: %w", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("embedded bootstrap manifest is empty")
|
||||
}
|
||||
return &Manifest{Entries: entries}, nil
|
||||
}
|
||||
59
internal/sec/bootstrap.json
Normal file
59
internal/sec/bootstrap.json
Normal file
@@ -0,0 +1,59 @@
|
||||
[
|
||||
{
|
||||
"key": 0,
|
||||
"buildPlatform": "linux",
|
||||
"urls": [
|
||||
{
|
||||
"urls": {
|
||||
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/linux-amd64/linux-amd64-1.0.1-alpha.23.zip",
|
||||
"arm64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/linux-arm64/linux-arm64-1.0.1-alpha.23.zip"
|
||||
},
|
||||
"region": "cn"
|
||||
}
|
||||
],
|
||||
"branch": "dev",
|
||||
"version": "1.0.1-alpha.23",
|
||||
"extra": {
|
||||
"pipeline_id": "367354993",
|
||||
"upload_date": 1778487420795
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": 1,
|
||||
"buildPlatform": "win32",
|
||||
"urls": [
|
||||
{
|
||||
"urls": {
|
||||
"x86": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/windows-386/windows-386-1.0.1-alpha.23.zip",
|
||||
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/windows-amd64/windows-amd64-1.0.1-alpha.23.zip"
|
||||
},
|
||||
"region": "cn"
|
||||
}
|
||||
],
|
||||
"branch": "dev",
|
||||
"version": "1.0.1-alpha.23",
|
||||
"extra": {
|
||||
"pipeline_id": "367354993",
|
||||
"upload_date": 1778487437393
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": 2,
|
||||
"buildPlatform": "darwin",
|
||||
"urls": [
|
||||
{
|
||||
"urls": {
|
||||
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/darwin-amd64/darwin-amd64-1.0.1-alpha.23.zip",
|
||||
"arm64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/darwin-arm64/darwin-arm64-1.0.1-alpha.23.zip"
|
||||
},
|
||||
"region": "cn"
|
||||
}
|
||||
],
|
||||
"branch": "dev",
|
||||
"version": "1.0.1-alpha.23",
|
||||
"extra": {
|
||||
"pipeline_id": "367354993",
|
||||
"upload_date": 1778487395152
|
||||
}
|
||||
}
|
||||
]
|
||||
58
internal/sec/bootstrap_test.go
Normal file
58
internal/sec/bootstrap_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLoadBootstrap_DecodesAllPlatforms guards against the embedded
|
||||
// manifest becoming malformed or losing an OS — both would break first
|
||||
// install on whatever GOOS lost its entry.
|
||||
func TestLoadBootstrap_DecodesAllPlatforms(t *testing.T) {
|
||||
manifest, err := LoadBootstrap()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBootstrap: %v", err)
|
||||
}
|
||||
platforms := map[string]bool{}
|
||||
for _, e := range manifest.Entries {
|
||||
platforms[e.BuildPlatform] = true
|
||||
if e.Version == "" {
|
||||
t.Errorf("entry %s missing version", e.BuildPlatform)
|
||||
}
|
||||
if e.Extra.PipelineID == "" {
|
||||
t.Errorf("entry %s missing extra.pipeline_id", e.BuildPlatform)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"darwin", "linux", "win32"} {
|
||||
if !platforms[want] {
|
||||
t.Errorf("bootstrap missing platform %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadBootstrap_PickArtifactForCurrentHost ensures the embedded manifest
|
||||
// resolves to a real URL for whatever platform the test runner is on, so a
|
||||
// developer fixing this code locally can still smoke-test their changes.
|
||||
func TestLoadBootstrap_PickArtifactForCurrentHost(t *testing.T) {
|
||||
manifest, err := LoadBootstrap()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBootstrap: %v", err)
|
||||
}
|
||||
art, err := manifest.PickArtifact(runtime.GOOS, runtime.GOARCH, "cn")
|
||||
if err != nil {
|
||||
t.Fatalf("PickArtifact for %s/%s: %v", runtime.GOOS, runtime.GOARCH, err)
|
||||
}
|
||||
if !strings.HasPrefix(art.URL, "https://") {
|
||||
t.Errorf("URL is not https: %q", art.URL)
|
||||
}
|
||||
if !strings.HasSuffix(art.URL, ".zip") {
|
||||
t.Errorf("URL is not a .zip: %q", art.URL)
|
||||
}
|
||||
if art.BuildID == "" {
|
||||
t.Error("BuildID is empty")
|
||||
}
|
||||
}
|
||||
156
internal/sec/download.go
Normal file
156
internal/sec/download.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// downloadMaxBytes caps the artifact size we'll accept. Comfortably over the
|
||||
// real lark-sec-cli zip (~tens of MB) and well under what a malicious mirror
|
||||
// could use to exhaust local disk before we noticed.
|
||||
const downloadMaxBytes = 512 * 1024 * 1024
|
||||
|
||||
// DownloadOptions controls Download.
|
||||
type DownloadOptions struct {
|
||||
URL string
|
||||
Destination string // full path to the .zip we'll create
|
||||
HTTPClient *http.Client
|
||||
|
||||
// ExpectedSHA256, if non-empty, is the hex SHA256 the artifact MUST
|
||||
// match — verified after the full body has been streamed. Use this when
|
||||
// the manifest publishes a hash for the artifact (e.g. bootstrap.json's
|
||||
// `extra.sha256`). Any mismatch fails the download with the .part file
|
||||
// removed.
|
||||
//
|
||||
// When empty (the manifest doesn't carry a hash), the only integrity
|
||||
// check left is the CDN's own `Content-MD5` response header, applied
|
||||
// opportunistically below.
|
||||
ExpectedSHA256 string
|
||||
}
|
||||
|
||||
// Download streams URL to Destination. Writes to a sibling .part file and
|
||||
// renames into place on success so a crashed or aborted run leaves no
|
||||
// half-written zip the next run might mistake for valid.
|
||||
//
|
||||
// Two layers of integrity check, both opt-in:
|
||||
//
|
||||
// 1. ExpectedSHA256 (strong, manifest-provided): cryptographic, fails the
|
||||
// download on mismatch. Use whenever the release manifest carries a hash.
|
||||
// 2. CDN `Content-MD5` header (opportunistic): non-cryptographic, catches
|
||||
// edge replacement or transit corruption when the upstream CDN populates
|
||||
// the header. Runs unconditionally — if the header is present we honour it.
|
||||
//
|
||||
// Neither check defends against a malicious upstream that controls both the
|
||||
// artifact AND the manifest. That class of risk has to be handled by signing
|
||||
// the release pipeline, which is out of scope for the client.
|
||||
func Download(ctx context.Context, opts DownloadOptions) error {
|
||||
if opts.HTTPClient == nil {
|
||||
return fmt.Errorf("Download: HTTPClient is required")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := opts.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download %s: %w", opts.URL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: status %d", opts.URL, resp.StatusCode)
|
||||
}
|
||||
|
||||
tmpPath := opts.Destination + ".part"
|
||||
out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup := func() { out.Close(); os.Remove(tmpPath) }
|
||||
|
||||
// Hash both ways during the single read pass. Both hashers are cheap and
|
||||
// we don't know yet which check (or both) we'll actually need.
|
||||
sha := sha256.New()
|
||||
md := md5.New()
|
||||
writer := io.MultiWriter(out, sha, md)
|
||||
|
||||
n, err := io.Copy(writer, io.LimitReader(resp.Body, downloadMaxBytes+1))
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("download %s: %w", opts.URL, err)
|
||||
}
|
||||
if n > downloadMaxBytes {
|
||||
cleanup()
|
||||
return fmt.Errorf("download %s: exceeds %d bytes", opts.URL, downloadMaxBytes)
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := verifyChecksums(resp, opts.ExpectedSHA256, sha, md); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("download %s: %w", opts.URL, err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, opts.Destination); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyChecksums applies the two-layer integrity check after the body has
|
||||
// been fully streamed. Returns nil when both layers (whichever apply) agree.
|
||||
func verifyChecksums(resp *http.Response, expectedSHA256 string, sha, md hash.Hash) error {
|
||||
if expectedSHA256 != "" {
|
||||
got := hex.EncodeToString(sha.Sum(nil))
|
||||
if !equalFoldHex(got, expectedSHA256) {
|
||||
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, got)
|
||||
}
|
||||
}
|
||||
|
||||
if cdnMD5 := resp.Header.Get("Content-MD5"); cdnMD5 != "" {
|
||||
got := base64.StdEncoding.EncodeToString(md.Sum(nil))
|
||||
if got != cdnMD5 {
|
||||
return fmt.Errorf("content-md5 mismatch: cdn=%s, computed=%s", cdnMD5, got)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// equalFoldHex is a non-allocating ASCII case-insensitive compare for hex
|
||||
// strings. SHA256 manifests sometimes ship uppercase, sometimes lowercase.
|
||||
func equalFoldHex(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(a); i++ {
|
||||
ca, cb := a[i], b[i]
|
||||
if 'A' <= ca && ca <= 'Z' {
|
||||
ca += 'a' - 'A'
|
||||
}
|
||||
if 'A' <= cb && cb <= 'Z' {
|
||||
cb += 'a' - 'A'
|
||||
}
|
||||
if ca != cb {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
184
internal/sec/download_test.go
Normal file
184
internal/sec/download_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const bodyContent = "lark-sec-cli pretend zip bytes"
|
||||
|
||||
// fixtureSHA256 / fixtureMD5 are the hashes of bodyContent.
|
||||
var fixtureSHA256 string
|
||||
var fixtureMD5b64 string
|
||||
|
||||
func init() {
|
||||
sum := sha256.Sum256([]byte(bodyContent))
|
||||
fixtureSHA256 = hex.EncodeToString(sum[:])
|
||||
m := md5.Sum([]byte(bodyContent))
|
||||
fixtureMD5b64 = base64.StdEncoding.EncodeToString(m[:])
|
||||
}
|
||||
|
||||
func newFixtureServer(t *testing.T, setContentMD5 bool) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if setContentMD5 {
|
||||
w.Header().Set("Content-MD5", fixtureMD5b64)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(bodyContent))
|
||||
}))
|
||||
}
|
||||
|
||||
// TestDownload_HappyPath_NoChecksum confirms that a download with no manifest
|
||||
// SHA and no CDN MD5 succeeds — the integrity hooks are opt-in, not required.
|
||||
func TestDownload_HappyPath_NoChecksum(t *testing.T) {
|
||||
srv := newFixtureServer(t, false)
|
||||
defer srv.Close()
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "out.zip")
|
||||
err := Download(context.Background(), DownloadOptions{
|
||||
URL: srv.URL,
|
||||
Destination: dst,
|
||||
HTTPClient: srv.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Download: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != bodyContent {
|
||||
t.Errorf("body roundtrip mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_SHA256_Match confirms the manifest-provided SHA256 path
|
||||
// passes for a correct hash. Tests both cases (with and without CDN MD5)
|
||||
// so the second layer doesn't interfere.
|
||||
func TestDownload_SHA256_Match(t *testing.T) {
|
||||
for _, withMD5 := range []bool{false, true} {
|
||||
name := "noMD5"
|
||||
if withMD5 {
|
||||
name = "withCDNMd5"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
srv := newFixtureServer(t, withMD5)
|
||||
defer srv.Close()
|
||||
dst := filepath.Join(t.TempDir(), "out.zip")
|
||||
err := Download(context.Background(), DownloadOptions{
|
||||
URL: srv.URL,
|
||||
Destination: dst,
|
||||
HTTPClient: srv.Client(),
|
||||
ExpectedSHA256: fixtureSHA256,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Download: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_SHA256_Mismatch is the safety property: a wrong manifest hash
|
||||
// rejects the download AND removes the .part file so the next run doesn't
|
||||
// pick up a poisoned zip.
|
||||
func TestDownload_SHA256_Mismatch(t *testing.T) {
|
||||
srv := newFixtureServer(t, false)
|
||||
defer srv.Close()
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "out.zip")
|
||||
err := Download(context.Background(), DownloadOptions{
|
||||
URL: srv.URL,
|
||||
Destination: dst,
|
||||
HTTPClient: srv.Client(),
|
||||
ExpectedSHA256: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected sha256 mismatch error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "sha256 mismatch") {
|
||||
t.Errorf("error should mention sha256 mismatch: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(dst); statErr == nil {
|
||||
t.Errorf("dst should not exist after mismatch")
|
||||
}
|
||||
if _, statErr := os.Stat(dst + ".part"); statErr == nil {
|
||||
t.Errorf(".part should not exist after mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_ContentMD5_Mismatch confirms the opportunistic check fires
|
||||
// even when no manifest SHA was provided. Catches a CDN edge that returned
|
||||
// content but a stale/wrong Content-MD5 header (or a poisoned proxy).
|
||||
func TestDownload_ContentMD5_Mismatch(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-MD5", "Z3JhZmFuYTpyZWFsbHk/Pz8/Pz8/PzA9PT0=") // arbitrary
|
||||
_, _ = w.Write([]byte(bodyContent))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "out.zip")
|
||||
err := Download(context.Background(), DownloadOptions{
|
||||
URL: srv.URL,
|
||||
Destination: dst,
|
||||
HTTPClient: srv.Client(),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected content-md5 mismatch error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "content-md5 mismatch") {
|
||||
t.Errorf("error should mention content-md5 mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_SHA256_CaseInsensitive guards the hex compare against case
|
||||
// drift in the manifest (some publishers upper-case).
|
||||
func TestDownload_SHA256_CaseInsensitive(t *testing.T) {
|
||||
srv := newFixtureServer(t, false)
|
||||
defer srv.Close()
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "out.zip")
|
||||
err := Download(context.Background(), DownloadOptions{
|
||||
URL: srv.URL,
|
||||
Destination: dst,
|
||||
HTTPClient: srv.Client(),
|
||||
ExpectedSHA256: strings.ToUpper(fixtureSHA256),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Download (uppercase sha): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownload_404_NoPartFile confirms that a non-200 response leaves no
|
||||
// .part file behind to confuse the next attempt.
|
||||
func TestDownload_404_NoPartFile(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "out.zip")
|
||||
err := Download(context.Background(), DownloadOptions{
|
||||
URL: srv.URL,
|
||||
Destination: dst,
|
||||
HTTPClient: srv.Client(),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404")
|
||||
}
|
||||
if _, statErr := os.Stat(dst + ".part"); statErr == nil {
|
||||
t.Errorf(".part should not exist after 404")
|
||||
}
|
||||
}
|
||||
297
internal/sec/install.go
Normal file
297
internal/sec/install.go
Normal file
@@ -0,0 +1,297 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
)
|
||||
|
||||
// Installer orchestrates first-time install of lark-sec-cli:
|
||||
// fetch remote manifest via OAPI → download zip → extract into
|
||||
// versions/<version>/ → swap "current" → write state.json.
|
||||
//
|
||||
// After this first install, lark-sec-cli takes over its own updates and
|
||||
// lark-cli is no longer in the update path. The installer therefore only
|
||||
// knows about the bootstrap path — no Tron, no other release sources.
|
||||
type Installer struct {
|
||||
Paths *Paths
|
||||
HTTPClient *http.Client
|
||||
// APIClientFunc resolves the OAPI client lazily. It is invoked only when
|
||||
// the install pipeline actually needs to fetch the remote manifest —
|
||||
// short-circuits (and other callers of installer() that don't install,
|
||||
// like sec status / sec stop) avoid keychain decryption entirely.
|
||||
APIClientFunc func() (*client.APIClient, error)
|
||||
}
|
||||
|
||||
// InstallOptions tunes a single Install call.
|
||||
type InstallOptions struct {
|
||||
// Force re-runs the pipeline even when an install already exists. Used by
|
||||
// `sec install --force` for repair / re-pinning to the bundled bootstrap.
|
||||
Force bool
|
||||
// Region selects which region's URLs to pick from the manifest. Defaults to
|
||||
// DefaultRegion ("cn"). Reserved for future brand split.
|
||||
Region string
|
||||
// Verbose, when non-nil, is the destination for step-by-step trace output.
|
||||
// nil = silent (production default); typically set to stderr by `sec install -v`.
|
||||
Verbose io.Writer
|
||||
}
|
||||
|
||||
// tracef writes one trace line to w if w is non-nil.
|
||||
func tracef(w io.Writer, format string, args ...any) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "[sec install] "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// Install runs the bootstrap pipeline and returns the new State on success.
|
||||
// If a usable install already exists on disk and Force is false, returns the
|
||||
// existing state unchanged (no network call).
|
||||
func (i *Installer) Install(ctx context.Context, opts InstallOptions) (*State, error) {
|
||||
v := opts.Verbose
|
||||
tracef(v, "ensuring sec paths under %s", i.Paths.InstallDir())
|
||||
if err := i.Paths.Ensure(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tracef(v, "loading existing state from %s", i.Paths.StateFile())
|
||||
existing, err := LoadState(i.Paths.StateFile())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load sec state: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
tracef(v, "existing state: version=%s binary=%s", existing.Version, existing.BinaryPath)
|
||||
} else {
|
||||
tracef(v, "no existing state on disk")
|
||||
}
|
||||
|
||||
// Idempotent short-circuit: nothing to do if an install is already on disk.
|
||||
// Self-upgrades after bootstrap are lark-sec-cli's job, not ours — see the
|
||||
// upgrade subsystem in lark-sec-cli/internal/upgrade/.
|
||||
if !opts.Force && existing != nil && binaryReady(existing.BinaryPath) {
|
||||
tracef(v, "binary exists at %s — short-circuiting (no network)", existing.BinaryPath)
|
||||
return existing, nil
|
||||
}
|
||||
if opts.Force {
|
||||
tracef(v, "--force set; running full install pipeline")
|
||||
} else {
|
||||
tracef(v, "no usable install on disk; running full install pipeline")
|
||||
}
|
||||
|
||||
region := opts.Region
|
||||
if region == "" {
|
||||
region = DefaultRegion
|
||||
}
|
||||
tracef(v, "region=%s", region)
|
||||
|
||||
if i.APIClientFunc == nil {
|
||||
return nil, errors.New("sec installer: APIClientFunc is required to fetch remote manifest")
|
||||
}
|
||||
tracef(v, "resolving OAPI client (will decrypt credentials)")
|
||||
apiClient, err := i.APIClientFunc()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve api client: %w", err)
|
||||
}
|
||||
platform, arch, err := CurrentPlatformArch()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracef(v, "detected platform=%s arch=%s", platform, arch)
|
||||
|
||||
tracef(v, "fetching remote manifest from %s", secCliManifestPath)
|
||||
rm, err := FetchRemoteManifest(ctx, apiClient, region, platform, arch, v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracef(v, "manifest returned %d url(s): %v", len(rm.URLs), rm.URLs)
|
||||
downloadURL := rm.URLs[0]
|
||||
tracef(v, "picked downloadURL=%s", downloadURL)
|
||||
version, err := versionFromURL(downloadURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracef(v, "parsed version=%s", version)
|
||||
|
||||
versionDir := i.Paths.VersionDir(version)
|
||||
tracef(v, "creating versionDir=%s", versionDir)
|
||||
if err := os.MkdirAll(versionDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
zipPath := filepath.Join(i.Paths.VersionsDir(), version+".zip")
|
||||
|
||||
tracef(v, "downloading %s -> %s", downloadURL, zipPath)
|
||||
if err := Download(ctx, DownloadOptions{
|
||||
URL: downloadURL,
|
||||
Destination: zipPath,
|
||||
HTTPClient: i.HTTPClient,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info, statErr := os.Stat(zipPath); statErr == nil {
|
||||
tracef(v, "downloaded %d bytes", info.Size())
|
||||
}
|
||||
defer os.Remove(zipPath) // free disk; we keep the unpacked version dir
|
||||
|
||||
tracef(v, "extracting %s -> %s", zipPath, versionDir)
|
||||
if err := ExtractZip(zipPath, versionDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
binaryPath, err := locateBinary(versionDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracef(v, "located binary at %s", binaryPath)
|
||||
// Ensure executable bit on POSIX — some zips lose it.
|
||||
if runtime.GOOS != "windows" {
|
||||
if info, err := os.Stat(binaryPath); err == nil {
|
||||
_ = os.Chmod(binaryPath, info.Mode()|0o100|0o010|0o001)
|
||||
}
|
||||
}
|
||||
|
||||
tracef(v, "swapping %s -> %s", i.Paths.CurrentLink(), versionDir)
|
||||
if err := swapCurrent(i.Paths.CurrentLink(), versionDir); err != nil {
|
||||
return nil, fmt.Errorf("swap current: %w", err)
|
||||
}
|
||||
|
||||
tracef(v, "writing state.json to %s", i.Paths.StateFile())
|
||||
state := &State{
|
||||
Version: version,
|
||||
InstalledAt: time.Now().UTC(),
|
||||
BinaryPath: i.Paths.BinaryPath(),
|
||||
}
|
||||
if err := SaveState(i.Paths.StateFile(), state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// locateBinary handles two artifact layouts: flat (zip root has the binary)
|
||||
// and nested (zip root is a single dir containing the binary). The bootstrap
|
||||
// manifest's example payload uses nested ("linux-amd64-1.0.1-alpha.23/...");
|
||||
// we accommodate either since the wrapping dir name could change per build.
|
||||
func locateBinary(versionDir string) (string, error) {
|
||||
name := BinaryName()
|
||||
|
||||
flat := filepath.Join(versionDir, name)
|
||||
if _, err := os.Stat(flat); err == nil {
|
||||
return flat, nil
|
||||
}
|
||||
|
||||
var found string
|
||||
walkErr := filepath.WalkDir(versionDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && d.Name() == name {
|
||||
found = path
|
||||
return fs.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
return "", walkErr
|
||||
}
|
||||
if found == "" {
|
||||
return "", fmt.Errorf("binary %q not found under %s", name, versionDir)
|
||||
}
|
||||
|
||||
// Promote the binary's parent to be versionDir so "current" → versionDir
|
||||
// produces a predictable layout. Move the *contents* up rather than the
|
||||
// binary alone, because shared libs may sit beside it.
|
||||
parent := filepath.Dir(found)
|
||||
if parent != versionDir {
|
||||
entries, err := os.ReadDir(parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if err := os.Rename(filepath.Join(parent, e.Name()), filepath.Join(versionDir, e.Name())); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
_ = os.Remove(parent)
|
||||
}
|
||||
return filepath.Join(versionDir, name), nil
|
||||
}
|
||||
|
||||
// swapCurrent atomically points <install>/current at versionDir. On POSIX
|
||||
// we use a symlink with the standard rename-into-place trick; on Windows we
|
||||
// fall back to removing the directory and copying, since junctions need
|
||||
// admin / developer-mode privileges we may not have.
|
||||
func swapCurrent(link, versionDir string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Remove any existing target then copy. This is non-atomic, but
|
||||
// concurrent installs on the same Windows host are not a use case
|
||||
// we support — `sec install` runs interactively.
|
||||
if err := os.RemoveAll(link); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return copyDir(versionDir, link)
|
||||
}
|
||||
|
||||
tmp := link + ".new"
|
||||
_ = os.Remove(tmp)
|
||||
if err := os.Symlink(versionDir, tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, link)
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(out, in)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
return closeErr
|
||||
})
|
||||
}
|
||||
|
||||
func binaryReady(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
139
internal/sec/manifest.go
Normal file
139
internal/sec/manifest.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Manifest describes a lark-sec-cli release set: one Entry per build platform,
|
||||
// each carrying one or more region-scoped URL maps keyed by arch. It's what we
|
||||
// embed at build time as the bootstrap manifest. After bootstrap, lark-sec-cli
|
||||
// queries its own release source for updates — lark-cli is uninvolved.
|
||||
type Manifest struct {
|
||||
Entries []Entry
|
||||
}
|
||||
|
||||
// Entry is one row of the bootstrap manifest, one per published platform.
|
||||
type Entry struct {
|
||||
Key int `json:"key"`
|
||||
BuildPlatform string `json:"buildPlatform"` // "darwin" | "linux" | "win32"
|
||||
URLs []RegionURLs `json:"urls"`
|
||||
Branch string `json:"branch"`
|
||||
Version string `json:"version"`
|
||||
Extra EntryExtra `json:"extra"`
|
||||
}
|
||||
|
||||
// RegionURLs maps an arch ("amd64", "arm64", "x86") to its download URL,
|
||||
// scoped to a region ("cn" today; reserved for future brand split).
|
||||
type RegionURLs struct {
|
||||
URLs map[string]string `json:"urls"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
// EntryExtra is metadata the release pipeline emits alongside each artifact.
|
||||
// PipelineID is the build identifier lark-sec-cli will later forward to its
|
||||
// own update server when checking for new versions. SHA256 (when present) is
|
||||
// the hex-encoded hash of the zip artifact; the installer fails the download
|
||||
// on mismatch. Manifests built before the release pipeline added the field
|
||||
// leave it empty, in which case integrity falls back to the CDN's own
|
||||
// Content-MD5 header.
|
||||
type EntryExtra struct {
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
UploadDate int64 `json:"upload_date"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
}
|
||||
|
||||
// Artifact is the resolved download target after platform/arch/region selection.
|
||||
type Artifact struct {
|
||||
URL string
|
||||
Version string
|
||||
BuildID string // pipeline_id — recorded in state.json so lark-sec-cli knows what it was installed at
|
||||
SHA256 string // hex-encoded; empty when the manifest doesn't carry one
|
||||
}
|
||||
|
||||
// PickArtifact selects the right Entry for the current GOOS/GOARCH and the
|
||||
// requested region. Returns a clear error explaining which combination was
|
||||
// missing so users can tell whether the build was never published or just not
|
||||
// for their platform.
|
||||
func (m *Manifest) PickArtifact(goos, goarch, region string) (*Artifact, error) {
|
||||
platform, err := platformKey(goos)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arch, err := archKey(goos, goarch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, e := range m.Entries {
|
||||
if e.BuildPlatform != platform {
|
||||
continue
|
||||
}
|
||||
for _, ru := range e.URLs {
|
||||
if ru.Region != region {
|
||||
continue
|
||||
}
|
||||
url, ok := ru.URLs[arch]
|
||||
if !ok || url == "" {
|
||||
continue
|
||||
}
|
||||
return &Artifact{
|
||||
URL: url,
|
||||
Version: e.Version,
|
||||
BuildID: e.Extra.PipelineID,
|
||||
SHA256: e.Extra.SHA256,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no artifact for platform=%s arch=%s region=%s", platform, arch, region)
|
||||
}
|
||||
|
||||
// platformKey maps Go's GOOS to the manifest's buildPlatform enum.
|
||||
func platformKey(goos string) (string, error) {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
return "darwin", nil
|
||||
case "linux":
|
||||
return "linux", nil
|
||||
case "windows":
|
||||
return "win32", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported GOOS: %s", goos)
|
||||
}
|
||||
}
|
||||
|
||||
// archKey maps Go's GOARCH to the arch key the manifest uses inside RegionURLs.URLs.
|
||||
// Windows 32-bit ships under "x86" while POSIX 32-bit (e.g. 386 on linux) is not
|
||||
// currently published — surface that as an error rather than silently falling back.
|
||||
func archKey(goos, goarch string) (string, error) {
|
||||
switch goarch {
|
||||
case "amd64":
|
||||
return "amd64", nil
|
||||
case "arm64":
|
||||
return "arm64", nil
|
||||
case "386":
|
||||
if goos == "windows" {
|
||||
return "x86", nil
|
||||
}
|
||||
return "", fmt.Errorf("32-bit %s is not published", goos)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported GOARCH: %s", goarch)
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentPlatformArch is a convenience for the install flow.
|
||||
func CurrentPlatformArch() (platform, arch string, err error) {
|
||||
platform, err = platformKey(runtime.GOOS)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
arch, err = archKey(runtime.GOOS, runtime.GOARCH)
|
||||
return platform, arch, err
|
||||
}
|
||||
|
||||
// DefaultRegion is the only region published today for bootstrap installs.
|
||||
// Kept here for callers that still want a single source of truth.
|
||||
const DefaultRegion = "cn"
|
||||
108
internal/sec/manifest_test.go
Normal file
108
internal/sec/manifest_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// sampleManifest is the manifest example baked into bootstrap.json, trimmed to
|
||||
// the three published platforms. PickArtifact must select the right URL for
|
||||
// each GOOS/GOARCH combination.
|
||||
func sampleManifest() *Manifest {
|
||||
return &Manifest{Entries: []Entry{
|
||||
{
|
||||
Key: 0,
|
||||
BuildPlatform: "linux",
|
||||
Branch: "dev",
|
||||
Version: "1.0.1-alpha.23",
|
||||
Extra: EntryExtra{PipelineID: "367354993"},
|
||||
URLs: []RegionURLs{{
|
||||
Region: "cn",
|
||||
URLs: map[string]string{
|
||||
"amd64": "https://cdn/linux-amd64.zip",
|
||||
"arm64": "https://cdn/linux-arm64.zip",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Key: 1,
|
||||
BuildPlatform: "win32",
|
||||
Branch: "dev",
|
||||
Version: "1.0.1-alpha.23",
|
||||
Extra: EntryExtra{PipelineID: "367354993"},
|
||||
URLs: []RegionURLs{{
|
||||
Region: "cn",
|
||||
URLs: map[string]string{
|
||||
"x86": "https://cdn/win-386.zip",
|
||||
"amd64": "https://cdn/win-amd64.zip",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Key: 2,
|
||||
BuildPlatform: "darwin",
|
||||
Branch: "dev",
|
||||
Version: "1.0.1-alpha.23",
|
||||
Extra: EntryExtra{PipelineID: "367354993"},
|
||||
URLs: []RegionURLs{{
|
||||
Region: "cn",
|
||||
URLs: map[string]string{
|
||||
"amd64": "https://cdn/darwin-amd64.zip",
|
||||
"arm64": "https://cdn/darwin-arm64.zip",
|
||||
},
|
||||
}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestPickArtifact_HappyPath(t *testing.T) {
|
||||
m := sampleManifest()
|
||||
cases := []struct {
|
||||
goos, goarch string
|
||||
wantURL string
|
||||
}{
|
||||
{"darwin", "arm64", "https://cdn/darwin-arm64.zip"},
|
||||
{"darwin", "amd64", "https://cdn/darwin-amd64.zip"},
|
||||
{"linux", "amd64", "https://cdn/linux-amd64.zip"},
|
||||
{"linux", "arm64", "https://cdn/linux-arm64.zip"},
|
||||
{"windows", "amd64", "https://cdn/win-amd64.zip"},
|
||||
{"windows", "386", "https://cdn/win-386.zip"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.goos+"/"+c.goarch, func(t *testing.T) {
|
||||
art, err := m.PickArtifact(c.goos, c.goarch, "cn")
|
||||
if err != nil {
|
||||
t.Fatalf("PickArtifact: %v", err)
|
||||
}
|
||||
if art.URL != c.wantURL {
|
||||
t.Errorf("URL = %q, want %q", art.URL, c.wantURL)
|
||||
}
|
||||
if art.Version != "1.0.1-alpha.23" {
|
||||
t.Errorf("Version = %q", art.Version)
|
||||
}
|
||||
if art.BuildID != "367354993" {
|
||||
t.Errorf("BuildID = %q", art.BuildID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickArtifact_Linux386Rejected(t *testing.T) {
|
||||
if _, err := sampleManifest().PickArtifact("linux", "386", "cn"); err == nil {
|
||||
t.Fatal("expected error for linux/386 (not published)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickArtifact_UnknownRegion(t *testing.T) {
|
||||
if _, err := sampleManifest().PickArtifact("darwin", "arm64", "sg"); err == nil {
|
||||
t.Fatal("expected error for region=sg (not present in fixture)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickArtifact_UnsupportedOS(t *testing.T) {
|
||||
if _, err := sampleManifest().PickArtifact("plan9", "amd64", "cn"); err == nil {
|
||||
t.Fatal("expected error for plan9")
|
||||
}
|
||||
}
|
||||
146
internal/sec/paths.go
Normal file
146
internal/sec/paths.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package sec manages the first-time bootstrap install of the lark-sec-cli
|
||||
// sidecar from lark-cli's side: download the artifact, lay it out on disk,
|
||||
// record what version landed. Runtime lifecycle (start / stop / status) is
|
||||
// handled by shelling out to lark-sec-cli's own `service enable / disable /
|
||||
// status` commands, so we don't need pid files / env capture / log tees here.
|
||||
// Updates after install are lark-sec-cli's responsibility, not lark-cli's.
|
||||
package sec
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
// envInstallDirOverride lets tests and power users redirect the entire sec
|
||||
// tree (install + data) to a single root. When set, install_dir is <root>
|
||||
// and data_dir is <root>/data — no platform-conventional lookup happens.
|
||||
envInstallDirOverride = "LARKSUITE_CLI_SEC_DIR"
|
||||
)
|
||||
|
||||
// BinaryName returns the executable basename inside the sec-cli artifact for
|
||||
// the current platform:
|
||||
//
|
||||
// darwin → libLarkEntCli.dylib
|
||||
// linux → liblarkentcli.so
|
||||
// windows → lark_enterprise_cli.exe
|
||||
//
|
||||
// The .dylib/.so extensions on POSIX are convention only — those files are
|
||||
// normal Mach-O / ELF executables, not loadable libraries.
|
||||
func BinaryName() string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return "libLarkEntCli.dylib"
|
||||
case "windows":
|
||||
return "lark_enterprise_cli.exe"
|
||||
default:
|
||||
return "liblarkentcli.so"
|
||||
}
|
||||
}
|
||||
|
||||
// Paths exposes the filesystem layout for the sec sidecar. All methods return
|
||||
// absolute paths; nothing on disk is created — callers must call Ensure().
|
||||
type Paths struct {
|
||||
install string
|
||||
data string
|
||||
}
|
||||
|
||||
// DefaultPaths returns Paths rooted at the platform-conventional user data dir,
|
||||
// or at $LARKSUITE_CLI_SEC_DIR when set.
|
||||
func DefaultPaths() (*Paths, error) {
|
||||
if root := os.Getenv(envInstallDirOverride); root != "" {
|
||||
return &Paths{install: root, data: filepath.Join(root, "data")}, nil
|
||||
}
|
||||
install, data, err := platformDirs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Paths{install: install, data: data}, nil
|
||||
}
|
||||
|
||||
// platformDirs returns (install_dir, data_dir) for the current OS, applying
|
||||
// per-platform conventions:
|
||||
//
|
||||
// macOS install = data = ~/Library/Application Support/lark-cli/sec
|
||||
// Linux install = $XDG_DATA_HOME/lark-cli/sec (fallback ~/.local/share/...)
|
||||
// data = $XDG_STATE_HOME/lark-cli/sec (fallback ~/.local/state/...)
|
||||
// Windows install = data = %LOCALAPPDATA%\lark-cli\sec
|
||||
//
|
||||
// Linux splits install/data along XDG lines; macOS and Windows colocate them
|
||||
// because their conventions don't distinguish "share" from "state" at the
|
||||
// per-user level.
|
||||
func platformDirs() (install, data string, err error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
base := filepath.Join(home, "Library", "Application Support", "lark-cli", "sec")
|
||||
return base, filepath.Join(base, "data"), nil
|
||||
case "windows":
|
||||
appData := os.Getenv("LOCALAPPDATA")
|
||||
if appData == "" {
|
||||
return "", "", errors.New("LOCALAPPDATA is not set")
|
||||
}
|
||||
base := filepath.Join(appData, "lark-cli", "sec")
|
||||
return base, filepath.Join(base, "data"), nil
|
||||
case "linux":
|
||||
dataHome := os.Getenv("XDG_DATA_HOME")
|
||||
if dataHome == "" {
|
||||
dataHome = filepath.Join(home, ".local", "share")
|
||||
}
|
||||
stateHome := os.Getenv("XDG_STATE_HOME")
|
||||
if stateHome == "" {
|
||||
stateHome = filepath.Join(home, ".local", "state")
|
||||
}
|
||||
return filepath.Join(dataHome, "lark-cli", "sec"),
|
||||
filepath.Join(stateHome, "lark-cli", "sec"),
|
||||
nil
|
||||
default:
|
||||
base := filepath.Join(home, ".lark-cli", "sec")
|
||||
return base, filepath.Join(base, "data"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure creates the directories the installer writes into.
|
||||
func (p *Paths) Ensure() error {
|
||||
for _, d := range []string{p.install, p.data, p.VersionsDir()} {
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallDir is the root for binaries and version trees.
|
||||
func (p *Paths) InstallDir() string { return p.install }
|
||||
|
||||
// DataDir is the root for state.json (and anything else lark-cli persists
|
||||
// about the install — currently just state.json).
|
||||
func (p *Paths) DataDir() string { return p.data }
|
||||
|
||||
// VersionsDir stores each unpacked release: versions/<version>/<files>.
|
||||
func (p *Paths) VersionsDir() string { return filepath.Join(p.install, "versions") }
|
||||
|
||||
// VersionDir is the unpack target for a specific version string.
|
||||
func (p *Paths) VersionDir(version string) string {
|
||||
return filepath.Join(p.VersionsDir(), version)
|
||||
}
|
||||
|
||||
// CurrentLink points to the active version (symlink on POSIX, plain copy on Windows).
|
||||
func (p *Paths) CurrentLink() string { return filepath.Join(p.install, "current") }
|
||||
|
||||
// BinaryPath is the active sec-cli executable, addressed through the
|
||||
// `current` symlink so it stays valid across version swaps.
|
||||
func (p *Paths) BinaryPath() string {
|
||||
return filepath.Join(p.CurrentLink(), BinaryName())
|
||||
}
|
||||
|
||||
// StateFile records what version is installed and where its binary lives.
|
||||
func (p *Paths) StateFile() string { return filepath.Join(p.data, "state.json") }
|
||||
51
internal/sec/paths_test.go
Normal file
51
internal/sec/paths_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultPaths_OverrideViaEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(envInstallDirOverride, dir)
|
||||
p, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultPaths: %v", err)
|
||||
}
|
||||
if p.InstallDir() != dir {
|
||||
t.Errorf("InstallDir = %q, want %q", p.InstallDir(), dir)
|
||||
}
|
||||
if p.DataDir() != filepath.Join(dir, "data") {
|
||||
t.Errorf("DataDir = %q, want %s/data", p.DataDir(), dir)
|
||||
}
|
||||
if !strings.HasPrefix(p.StateFile(), dir) {
|
||||
t.Errorf("StateFile not under override root: %q", p.StateFile())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaths_Ensure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(envInstallDirOverride, dir)
|
||||
p, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultPaths: %v", err)
|
||||
}
|
||||
if err := p.Ensure(); err != nil {
|
||||
t.Fatalf("Ensure: %v", err)
|
||||
}
|
||||
for _, d := range []string{p.InstallDir(), p.DataDir(), p.VersionsDir()} {
|
||||
info, err := os.Stat(d)
|
||||
if err != nil {
|
||||
t.Errorf("missing %s: %v", d, err)
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Errorf("%s is not a directory", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
113
internal/sec/remote_manifest.go
Normal file
113
internal/sec/remote_manifest.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// secCliManifestPath is the OAPI endpoint that returns the per-platform
|
||||
// download URLs for lark-sec-cli, gated by tenant_access_token.
|
||||
const secCliManifestPath = "/open-apis/security_plugin/v1/sec_cli/manifest"
|
||||
|
||||
// xTTEnvEnv, when set, injects an x-tt-env header on the manifest request.
|
||||
// Used for BOE / sub-environment routing (e.g. value "boe_tns_api"). Unset
|
||||
// in prod — the gateway treats absence as "no override". This is the only
|
||||
// debug-routing knob in this file; brand/domain switching itself is handled
|
||||
// at the network layer via the lark-env.sh Whistle pattern in the
|
||||
// lark-cli maintainer doc.
|
||||
const xTTEnvEnv = "LARKSUITE_CLI_X_TT_ENV"
|
||||
|
||||
// RemoteManifest is the payload returned by GET /open-apis/security_plugin/v1/sec_cli/manifest
|
||||
// for a single (region, platform, arch) combination. The server returns only
|
||||
// the download URLs; version metadata is parsed from the URL itself (see
|
||||
// versionFromURL).
|
||||
type RemoteManifest struct {
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
|
||||
// FetchRemoteManifest calls the OAPI manifest endpoint with TAT (bot) auth
|
||||
// and returns the typed payload for the given region/platform/arch. When the
|
||||
// LARKSUITE_CLI_X_TT_ENV env var is set, its value is sent as an x-tt-env
|
||||
// request header for sub-environment routing.
|
||||
//
|
||||
// Errors are returned as-is — there is no fallback to the embedded
|
||||
// bootstrap manifest. Callers that need offline behavior must handle that
|
||||
// explicitly.
|
||||
func FetchRemoteManifest(
|
||||
ctx context.Context,
|
||||
ac *client.APIClient,
|
||||
region, platform, arch string,
|
||||
verbose io.Writer,
|
||||
) (*RemoteManifest, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: "GET",
|
||||
ApiPath: secCliManifestPath,
|
||||
QueryParams: larkcore.QueryParams{
|
||||
"region": []string{region},
|
||||
"platform": []string{platform},
|
||||
"arch": []string{arch},
|
||||
},
|
||||
}
|
||||
tracef(verbose, "GET %s?region=%s&platform=%s&arch=%s as=bot", secCliManifestPath, region, platform, arch)
|
||||
|
||||
var extraOpts []larkcore.RequestOptionFunc
|
||||
if v := os.Getenv(xTTEnvEnv); v != "" {
|
||||
h := http.Header{}
|
||||
h.Set("x-tt-env", v)
|
||||
extraOpts = append(extraOpts, larkcore.WithHeaders(h))
|
||||
tracef(verbose, "injecting header x-tt-env=%s (from %s)", v, xTTEnvEnv)
|
||||
}
|
||||
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsBot, extraOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sec_cli manifest request: %w", err)
|
||||
}
|
||||
tracef(verbose, "response status=%d body-len=%d body=%q", resp.StatusCode, len(resp.RawBody), string(resp.RawBody))
|
||||
|
||||
var env struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data *RemoteManifest `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.RawBody, &env); err != nil {
|
||||
// Print body unconditionally on decode failure — a malformed response is
|
||||
// the most common case where the caller needs to see exactly what arrived.
|
||||
fmt.Fprintf(os.Stderr, "[sec_cli manifest] decode failed; status=%d len=%d body=%q\n", resp.StatusCode, len(resp.RawBody), string(resp.RawBody))
|
||||
return nil, fmt.Errorf("sec_cli manifest decode: %w", err)
|
||||
}
|
||||
if env.Code != 0 {
|
||||
return nil, fmt.Errorf("sec_cli manifest error %d: %s", env.Code, env.Msg)
|
||||
}
|
||||
if env.Data == nil || len(env.Data.URLs) == 0 {
|
||||
return nil, fmt.Errorf("sec_cli manifest: no urls for region=%s platform=%s arch=%s", region, platform, arch)
|
||||
}
|
||||
return env.Data, nil
|
||||
}
|
||||
|
||||
// versionFromURL extracts the release version from a download URL of the form
|
||||
// .../releases/<version>/<pipeline-id>/<platform-arch>/<archive>.zip
|
||||
// The server-side manifest does not return version as a discrete field;
|
||||
// state.json's Version needs *something* to disambiguate concurrent installs
|
||||
// in versions/<version>/, so we parse it out here.
|
||||
var releaseVersionRE = regexp.MustCompile(`/releases/([^/]+)/`)
|
||||
|
||||
func versionFromURL(u string) (string, error) {
|
||||
m := releaseVersionRE.FindStringSubmatch(u)
|
||||
if len(m) < 2 || m[1] == "" {
|
||||
return "", fmt.Errorf("could not parse release version from URL %q", u)
|
||||
}
|
||||
return m[1], nil
|
||||
}
|
||||
79
internal/sec/state.go
Normal file
79
internal/sec/state.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// State is the JSON document at <data>/state.json describing the currently
|
||||
// installed lark-sec-cli artifact. It is the source of truth for what binary
|
||||
// to launch. After bootstrap install lark-sec-cli may upgrade itself in
|
||||
// place — when that happens this state file is informational only; the
|
||||
// daemon owns its own canonical version state.
|
||||
type State struct {
|
||||
Version string `json:"version"`
|
||||
BuildID string `json:"build_id"`
|
||||
InstalledAt time.Time `json:"installed_at"`
|
||||
BinaryPath string `json:"binary_path"`
|
||||
}
|
||||
|
||||
// LoadState reads state.json. Returns (nil, nil) when the file is absent —
|
||||
// callers treat that as "not yet installed". Decode errors are surfaced
|
||||
// so a corrupt file is never silently overwritten.
|
||||
func LoadState(path string) (*State, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s State
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// SaveState writes state.json atomically: a tmpfile next to the target is
|
||||
// fsynced then renamed in, so concurrent readers either see the previous
|
||||
// state or the new one — never a torn write.
|
||||
func SaveState(path string, s *State) error {
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dirOf(path), ".state-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // no-op after a successful Rename
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
func dirOf(path string) string {
|
||||
for i := len(path) - 1; i >= 0; i-- {
|
||||
if path[i] == '/' || path[i] == '\\' {
|
||||
return path[:i]
|
||||
}
|
||||
}
|
||||
return "."
|
||||
}
|
||||
48
internal/sec/state_test.go
Normal file
48
internal/sec/state_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSaveLoadState_Roundtrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "state.json")
|
||||
|
||||
in := &State{
|
||||
Version: "1.2.3",
|
||||
BuildID: "build-42",
|
||||
InstalledAt: time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC),
|
||||
BinaryPath: "/tmp/lark-sec-cli",
|
||||
}
|
||||
if err := SaveState(path, in); err != nil {
|
||||
t.Fatalf("SaveState: %v", err)
|
||||
}
|
||||
got, err := LoadState(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadState: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("LoadState returned nil")
|
||||
}
|
||||
if got.Version != in.Version || got.BuildID != in.BuildID || got.BinaryPath != in.BinaryPath {
|
||||
t.Errorf("roundtrip mismatch: got=%+v want=%+v", got, in)
|
||||
}
|
||||
if !got.InstalledAt.Equal(in.InstalledAt) {
|
||||
t.Errorf("InstalledAt mismatch: got=%v want=%v", got.InstalledAt, in.InstalledAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadState_AbsentFile(t *testing.T) {
|
||||
got, err := LoadState(filepath.Join(t.TempDir(), "missing.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for missing file, got %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil state for missing file, got %+v", got)
|
||||
}
|
||||
}
|
||||
135
internal/secplugin/README.md
Normal file
135
internal/secplugin/README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# secplugin Usage Guide
|
||||
|
||||
Chinese version: see `README.zh-CN.md`.
|
||||
|
||||
`secplugin` enables a secure proxy mode for the CLI. It forces outbound HTTP(S)
|
||||
requests to go through a local security proxy and can optionally trust an
|
||||
additional CA certificate bundle.
|
||||
|
||||
It supports two configuration methods:
|
||||
|
||||
1. `sec_config.json`
|
||||
2. `LARKSUITE_CLI_SEC_*` environment variables
|
||||
|
||||
## Config File Location
|
||||
|
||||
Default config file path:
|
||||
|
||||
```text
|
||||
~/.lark-cli/sec_config.json
|
||||
```
|
||||
|
||||
If `LARKSUITE_CLI_CONFIG_DIR` is set, the path becomes:
|
||||
|
||||
```text
|
||||
$LARKSUITE_CLI_CONFIG_DIR/sec_config.json
|
||||
```
|
||||
|
||||
## Option 1: Config File
|
||||
|
||||
Put the following content into `sec_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"LARKSUITE_CLI_SEC_ENABLE": true,
|
||||
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
|
||||
"LARKSUITE_CLI_SEC_CA": "/absolute/path/to/proxy-ca.pem",
|
||||
"LARKSUITE_CLI_SEC_AUTH": true,
|
||||
"LARKSUITE_CLI_APP_ID": "cli_xxx",
|
||||
"LARKSUITE_CLI_BRAND": "feishu",
|
||||
"LARKSUITE_CLI_DEFAULT_AS": "bot",
|
||||
"LARKSUITE_CLI_STRICT_MODE": "bot"
|
||||
}
|
||||
```
|
||||
|
||||
Field descriptions:
|
||||
|
||||
- `LARKSUITE_CLI_SEC_ENABLE`: Enables secplugin. Boolean values are supported.
|
||||
- `LARKSUITE_CLI_SEC_PROXY`: Local HTTP proxy address. It must be `http://127.0.0.1:<port>`.
|
||||
- `LARKSUITE_CLI_SEC_CA`: Absolute path to an extra trusted root CA PEM file. Leave empty if not needed.
|
||||
- `LARKSUITE_CLI_SEC_AUTH`: Enables proxy-injected token mode.
|
||||
- `LARKSUITE_CLI_APP_ID`: Optional app ID used in `SEC_AUTH` mode.
|
||||
- `LARKSUITE_CLI_BRAND`: Optional, must be `feishu` or `lark`.
|
||||
- `LARKSUITE_CLI_DEFAULT_AS`: Optional, must be `user`, `bot`, or `auto`.
|
||||
- `LARKSUITE_CLI_STRICT_MODE`: Optional, must be `user`, `bot`, or `off`.
|
||||
|
||||
## Option 2: Environment Variables
|
||||
|
||||
You can also enable secplugin directly with environment variables without
|
||||
creating `sec_config.json`:
|
||||
|
||||
```bash
|
||||
export LARKSUITE_CLI_SEC_ENABLE=true
|
||||
export LARKSUITE_CLI_SEC_PROXY=http://127.0.0.1:3128
|
||||
export LARKSUITE_CLI_SEC_CA=/absolute/path/to/proxy-ca.pem
|
||||
export LARKSUITE_CLI_SEC_AUTH=true
|
||||
```
|
||||
|
||||
If you want to provide app metadata in `SEC_AUTH` mode, set these as well:
|
||||
|
||||
```bash
|
||||
export LARKSUITE_CLI_APP_ID=cli_xxx
|
||||
export LARKSUITE_CLI_BRAND=feishu
|
||||
export LARKSUITE_CLI_DEFAULT_AS=bot
|
||||
export LARKSUITE_CLI_STRICT_MODE=bot
|
||||
```
|
||||
|
||||
## Precedence
|
||||
|
||||
The following environment variables override the corresponding fields in
|
||||
`sec_config.json` when they are present:
|
||||
|
||||
- `LARKSUITE_CLI_SEC_ENABLE`
|
||||
- `LARKSUITE_CLI_SEC_PROXY`
|
||||
- `LARKSUITE_CLI_SEC_CA`
|
||||
- `LARKSUITE_CLI_SEC_AUTH`
|
||||
- `LARKSUITE_CLI_APP_ID`
|
||||
- `LARKSUITE_CLI_BRAND`
|
||||
- `LARKSUITE_CLI_DEFAULT_AS`
|
||||
- `LARKSUITE_CLI_STRICT_MODE`
|
||||
|
||||
This means:
|
||||
|
||||
- Put stable defaults in `sec_config.json`.
|
||||
- Use environment variables for temporary overrides.
|
||||
- SEC-related environment variables can work even without a config file.
|
||||
|
||||
## SEC_AUTH Mode
|
||||
|
||||
The CLI enters `SEC_AUTH` mode when both of the following are true:
|
||||
|
||||
```text
|
||||
LARKSUITE_CLI_SEC_ENABLE=true
|
||||
LARKSUITE_CLI_SEC_AUTH=true
|
||||
```
|
||||
|
||||
In this mode, the CLI does not read real tokens directly. Instead, it returns
|
||||
placeholder tokens and expects the proxy to replace them with real credentials.
|
||||
|
||||
App information is resolved in this order:
|
||||
|
||||
1. `LARKSUITE_CLI_APP_ID` and `LARKSUITE_CLI_BRAND` from environment variables
|
||||
2. The same fields in `sec_config.json`
|
||||
3. The active profile in the regular CLI `config.json`
|
||||
|
||||
If no valid app information can be resolved from any source, the command fails.
|
||||
|
||||
## Constraints
|
||||
|
||||
- `LARKSUITE_CLI_SEC_PROXY` must use the `http` scheme only.
|
||||
- The host of `LARKSUITE_CLI_SEC_PROXY` must be `127.0.0.1`.
|
||||
- `LARKSUITE_CLI_SEC_PROXY` must not contain a path.
|
||||
- `LARKSUITE_CLI_SEC_CA` must be an absolute path to a PEM file.
|
||||
- Boolean values support `true/false`, `1/0`, `on/off`, `yes/no`, and `y/n`.
|
||||
|
||||
## Recommendations
|
||||
|
||||
For long-term stable setup, prefer `sec_config.json`:
|
||||
|
||||
- Good for developer machines or controlled environments.
|
||||
- Avoids repeatedly injecting environment variables into the shell.
|
||||
|
||||
For temporary debugging, prefer environment variables:
|
||||
|
||||
- Good for switching proxy or CA for just one session.
|
||||
- No need to modify files on disk.
|
||||
130
internal/secplugin/README.zh-CN.md
Normal file
130
internal/secplugin/README.zh-CN.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# secplugin 使用说明
|
||||
|
||||
English version: see `README.md`.
|
||||
|
||||
`secplugin` 用于开启安全代理模式,让 CLI 的 HTTP(S) 请求固定走本地安全代理,并按需信任额外 CA 证书。
|
||||
|
||||
支持两种配置方式:
|
||||
|
||||
1. `sec_config.json`
|
||||
2. `LARKSUITE_CLI_SEC_*` 环境变量
|
||||
|
||||
## 配置文件位置
|
||||
|
||||
默认配置文件路径:
|
||||
|
||||
```text
|
||||
~/.lark-cli/sec_config.json
|
||||
```
|
||||
|
||||
如果设置了 `LARKSUITE_CLI_CONFIG_DIR`,则配置文件路径变为:
|
||||
|
||||
```text
|
||||
$LARKSUITE_CLI_CONFIG_DIR/sec_config.json
|
||||
```
|
||||
|
||||
## 方式一:使用配置文件
|
||||
|
||||
在 `sec_config.json` 中写入:
|
||||
|
||||
```json
|
||||
{
|
||||
"LARKSUITE_CLI_SEC_ENABLE": true,
|
||||
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
|
||||
"LARKSUITE_CLI_SEC_CA": "/absolute/path/to/proxy-ca.pem",
|
||||
"LARKSUITE_CLI_SEC_AUTH": true,
|
||||
"LARKSUITE_CLI_APP_ID": "cli_xxx",
|
||||
"LARKSUITE_CLI_BRAND": "feishu",
|
||||
"LARKSUITE_CLI_DEFAULT_AS": "bot",
|
||||
"LARKSUITE_CLI_STRICT_MODE": "bot"
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `LARKSUITE_CLI_SEC_ENABLE`: 是否启用 secplugin,支持布尔值。
|
||||
- `LARKSUITE_CLI_SEC_PROXY`: 本地 HTTP 代理地址,必须是 `http://127.0.0.1:<port>`。
|
||||
- `LARKSUITE_CLI_SEC_CA`: 额外信任的根证书 PEM 文件绝对路径;不需要时可留空。
|
||||
- `LARKSUITE_CLI_SEC_AUTH`: 是否启用代理注入 token 模式。
|
||||
- `LARKSUITE_CLI_APP_ID`: 可选,`SEC_AUTH` 模式下使用的应用 ID。
|
||||
- `LARKSUITE_CLI_BRAND`: 可选,取值为 `feishu` 或 `lark`。
|
||||
- `LARKSUITE_CLI_DEFAULT_AS`: 可选,取值为 `user`、`bot` 或 `auto`。
|
||||
- `LARKSUITE_CLI_STRICT_MODE`: 可选,取值为 `user`、`bot` 或 `off`。
|
||||
|
||||
## 方式二:使用环境变量
|
||||
|
||||
也可以不写 `sec_config.json`,直接通过环境变量启用:
|
||||
|
||||
```bash
|
||||
export LARKSUITE_CLI_SEC_ENABLE=true
|
||||
export LARKSUITE_CLI_SEC_PROXY=http://127.0.0.1:3128
|
||||
export LARKSUITE_CLI_SEC_CA=/absolute/path/to/proxy-ca.pem
|
||||
export LARKSUITE_CLI_SEC_AUTH=true
|
||||
```
|
||||
|
||||
如果你在 `SEC_AUTH` 模式下希望同时提供应用信息,也可以继续设置:
|
||||
|
||||
```bash
|
||||
export LARKSUITE_CLI_APP_ID=cli_xxx
|
||||
export LARKSUITE_CLI_BRAND=feishu
|
||||
export LARKSUITE_CLI_DEFAULT_AS=bot
|
||||
export LARKSUITE_CLI_STRICT_MODE=bot
|
||||
```
|
||||
|
||||
## 配置优先级
|
||||
|
||||
以下环境变量存在时,会覆盖 `sec_config.json` 中对应字段:
|
||||
|
||||
- `LARKSUITE_CLI_SEC_ENABLE`
|
||||
- `LARKSUITE_CLI_SEC_PROXY`
|
||||
- `LARKSUITE_CLI_SEC_CA`
|
||||
- `LARKSUITE_CLI_SEC_AUTH`
|
||||
- `LARKSUITE_CLI_APP_ID`
|
||||
- `LARKSUITE_CLI_BRAND`
|
||||
- `LARKSUITE_CLI_DEFAULT_AS`
|
||||
- `LARKSUITE_CLI_STRICT_MODE`
|
||||
|
||||
也就是说:
|
||||
|
||||
- 你可以把默认值写进 `sec_config.json`。
|
||||
- 再用环境变量做临时覆盖。
|
||||
- 如果没有配置文件,但设置了 SEC 相关环境变量,也可以正常工作。
|
||||
|
||||
## SEC_AUTH 模式说明
|
||||
|
||||
当同时满足以下条件时,CLI 会进入 `SEC_AUTH` 模式:
|
||||
|
||||
```text
|
||||
LARKSUITE_CLI_SEC_ENABLE=true
|
||||
LARKSUITE_CLI_SEC_AUTH=true
|
||||
```
|
||||
|
||||
此时 CLI 不直接读取真实 token,而是返回占位 token,由代理替换成真实凭证。
|
||||
|
||||
应用信息来源优先级如下:
|
||||
|
||||
1. 环境变量中的 `LARKSUITE_CLI_APP_ID` 和 `LARKSUITE_CLI_BRAND`
|
||||
2. `sec_config.json` 中的同名字段
|
||||
3. 常规 CLI 配置文件 `config.json` 的当前 profile
|
||||
|
||||
如果以上来源都拿不到可用应用信息,命令会报错。
|
||||
|
||||
## 参数约束
|
||||
|
||||
- `LARKSUITE_CLI_SEC_PROXY` 只允许 `http` 协议。
|
||||
- `LARKSUITE_CLI_SEC_PROXY` 的 host 必须是 `127.0.0.1`。
|
||||
- `LARKSUITE_CLI_SEC_PROXY` 不能带路径。
|
||||
- `LARKSUITE_CLI_SEC_CA` 必须是 PEM 文件的绝对路径。
|
||||
- 布尔值支持 `true/false`、`1/0`、`on/off`、`yes/no`、`y/n`。
|
||||
|
||||
## 推荐用法
|
||||
|
||||
长期固定配置建议使用 `sec_config.json`:
|
||||
|
||||
- 适合开发机或受控环境的稳定配置。
|
||||
- 避免在 shell 中反复注入环境变量。
|
||||
|
||||
临时调试建议使用环境变量:
|
||||
|
||||
- 适合本次会话临时切换代理或证书。
|
||||
- 不需要修改磁盘上的配置文件。
|
||||
277
internal/secplugin/config.go
Normal file
277
internal/secplugin/config.go
Normal file
@@ -0,0 +1,277 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package secplugin implements the ~/.lark-cli/sec_config.json based security proxy plugin mode.
|
||||
//
|
||||
// It supports:
|
||||
// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy
|
||||
// - trusting an additional root CA PEM bundle for MITM/inspection proxies
|
||||
// - optional "proxy injects token" mode via placeholder tokens (SEC_AUTH)
|
||||
//
|
||||
// In sec plugin mode, certain common CLI env vars (APP_ID / BRAND / DEFAULT_AS /
|
||||
// STRICT_MODE) can also be set in sec_config.json so sandboxes can avoid
|
||||
// environment injection. When both are present, environment variables win.
|
||||
package secplugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// SEC plugin constants cover the config file name and placeholder token values.
|
||||
const (
|
||||
// ConfigFileName is the fixed config file name under core.GetConfigDir().
|
||||
ConfigFileName = "sec_config.json"
|
||||
|
||||
// SentinelUAT is the placeholder user access token used in SEC_AUTH mode.
|
||||
SentinelUAT = "secplugin-managed-uat"
|
||||
|
||||
// SentinelTAT is the placeholder tenant access token used in SEC_AUTH mode.
|
||||
SentinelTAT = "secplugin-managed-tat"
|
||||
)
|
||||
|
||||
// Config is the on-disk config format. Keys intentionally mirror env var names.
|
||||
type Config struct {
|
||||
// Enable turns on sec plugin transport handling.
|
||||
Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"`
|
||||
|
||||
// Proxy is the fixed HTTP proxy address used for all outbound requests.
|
||||
Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"`
|
||||
|
||||
// CAPath points to an extra PEM bundle trusted for proxy TLS interception.
|
||||
CAPath string `json:"LARKSUITE_CLI_SEC_CA"`
|
||||
|
||||
// Auth enables placeholder-token mode for proxy-side credential injection.
|
||||
Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"`
|
||||
|
||||
// Optional defaults for sec plugin mode; env vars override these.
|
||||
// AppID supplies the app ID when the environment does not set one.
|
||||
AppID string `json:"LARKSUITE_CLI_APP_ID,omitempty"`
|
||||
|
||||
// Brand supplies the tenant brand when the environment does not set one.
|
||||
Brand string `json:"LARKSUITE_CLI_BRAND,omitempty"` // feishu | lark
|
||||
|
||||
// DefaultAs supplies the default identity when the environment does not set one.
|
||||
DefaultAs string `json:"LARKSUITE_CLI_DEFAULT_AS,omitempty"` // user | bot | auto
|
||||
|
||||
// StrictMode supplies the strict mode when the environment does not set one.
|
||||
StrictMode string `json:"LARKSUITE_CLI_STRICT_MODE,omitempty"` // user | bot | off
|
||||
}
|
||||
|
||||
// Path returns the absolute path to the sec plugin config file.
|
||||
func Path() string {
|
||||
return filepath.Join(core.GetConfigDir(), ConfigFileName)
|
||||
}
|
||||
|
||||
// loadOnce guards one-time SEC config loading for process-wide transport reuse.
|
||||
var loadOnce sync.Once
|
||||
|
||||
// loadCfg stores the cached SEC config after the first successful Load call.
|
||||
var loadCfg *Config
|
||||
|
||||
// loadErr stores the cached Load error observed during the first load attempt.
|
||||
var loadErr error
|
||||
|
||||
// Load reads ~/.lark-cli/sec_config.json once and caches the parsed result.
|
||||
// Environment variables (CliSec*) take precedence over config file values.
|
||||
//
|
||||
// Returns (nil, nil) only when:
|
||||
// - the config file does not exist AND
|
||||
// - none of the SEC-related env vars are present.
|
||||
func Load() (*Config, error) {
|
||||
loadOnce.Do(func() {
|
||||
// Start from env-only config if any SEC env var is present.
|
||||
cfg, hasEnv, err := loadFromEnv()
|
||||
if err != nil {
|
||||
loadErr = err
|
||||
return
|
||||
}
|
||||
|
||||
p := Path()
|
||||
if _, err := vfs.Stat(p); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// No file: return env-only config (if any), else nil.
|
||||
if hasEnv {
|
||||
loadCfg = cfg
|
||||
} else {
|
||||
loadCfg = nil
|
||||
}
|
||||
loadErr = nil
|
||||
return
|
||||
}
|
||||
loadErr = fmt.Errorf("failed to stat sec plugin config %q: %w", p, err)
|
||||
return
|
||||
}
|
||||
b, err := vfs.ReadFile(p)
|
||||
if err != nil {
|
||||
loadErr = fmt.Errorf("failed to read sec plugin config %q: %w", p, err)
|
||||
return
|
||||
}
|
||||
var fileCfg Config
|
||||
if err := json.Unmarshal(b, &fileCfg); err != nil {
|
||||
loadErr = fmt.Errorf("invalid sec plugin config %q: %w", p, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Merge: file base + env overrides.
|
||||
if cfg == nil {
|
||||
cfg = &fileCfg
|
||||
} else {
|
||||
*cfg = fileCfg
|
||||
applyEnvOverrides(cfg)
|
||||
}
|
||||
loadCfg = cfg
|
||||
})
|
||||
return loadCfg, loadErr
|
||||
}
|
||||
|
||||
// Enabled reports whether SEC plugin mode is enabled.
|
||||
func (c *Config) Enabled() bool { return c != nil && c.Enable }
|
||||
|
||||
// AuthEnabled reports whether SEC_AUTH token placeholder mode is enabled.
|
||||
func (c *Config) AuthEnabled() bool { return c != nil && c.Enable && c.Auth }
|
||||
|
||||
// loadFromEnv builds a config from SEC-related environment variables only.
|
||||
// It reports whether any SEC-related environment variable was present.
|
||||
func loadFromEnv() (*Config, bool, error) {
|
||||
_, hasEnable := os.LookupEnv(envvars.CliSecEnable)
|
||||
_, hasProxy := os.LookupEnv(envvars.CliSecProxy)
|
||||
_, hasCA := os.LookupEnv(envvars.CliSecCA)
|
||||
_, hasAuth := os.LookupEnv(envvars.CliSecAuth)
|
||||
hasAny := hasEnable || hasProxy || hasCA || hasAuth
|
||||
if !hasAny {
|
||||
return nil, false, nil
|
||||
}
|
||||
cfg := &Config{}
|
||||
if err := applyEnvOverrides(cfg); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return cfg, true, nil
|
||||
}
|
||||
|
||||
// applyEnvOverrides copies SEC-related environment variable values into cfg.
|
||||
func applyEnvOverrides(cfg *Config) error {
|
||||
if v, ok := os.LookupEnv(envvars.CliSecEnable); ok {
|
||||
b, err := parseBoolEnv(envvars.CliSecEnable, v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Enable = b
|
||||
}
|
||||
if v, ok := os.LookupEnv(envvars.CliSecAuth); ok {
|
||||
b, err := parseBoolEnv(envvars.CliSecAuth, v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Auth = b
|
||||
}
|
||||
if v, ok := os.LookupEnv(envvars.CliSecProxy); ok {
|
||||
cfg.Proxy = v
|
||||
}
|
||||
if v, ok := os.LookupEnv(envvars.CliSecCA); ok {
|
||||
cfg.CAPath = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseBoolEnv accepts common boolean spellings used in environment variables.
|
||||
func parseBoolEnv(name, raw string) (bool, error) {
|
||||
s := strings.TrimSpace(strings.ToLower(raw))
|
||||
if s == "" {
|
||||
// Treat empty as false when explicitly present.
|
||||
return false, nil
|
||||
}
|
||||
switch s {
|
||||
case "1", "true", "on", "yes", "y":
|
||||
return true, nil
|
||||
case "0", "false", "off", "no", "n":
|
||||
return false, nil
|
||||
}
|
||||
if b, err := strconv.ParseBool(s); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
return false, fmt.Errorf("invalid %s %q (want true/false/1/0)", name, raw)
|
||||
}
|
||||
|
||||
// proxyURL validates the fixed SEC proxy configuration and returns its URL.
|
||||
func (c *Config) proxyURL() (*url.URL, error) {
|
||||
raw := strings.TrimSpace(c.Proxy)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("%s is empty", envvars.CliSecProxy)
|
||||
}
|
||||
redacted := redactProxyURL(raw)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid %s %q: %w", envvars.CliSecProxy, redacted, err)
|
||||
}
|
||||
if u.Scheme != "http" {
|
||||
return nil, fmt.Errorf("invalid %s %q: scheme must be http", envvars.CliSecProxy, redacted)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, fmt.Errorf("invalid %s %q: missing host", envvars.CliSecProxy, redacted)
|
||||
}
|
||||
// Security hardening: only allow a loopback proxy. This prevents accidental
|
||||
// cross-machine proxying of credentials/traffic.
|
||||
if u.Hostname() != "127.0.0.1" {
|
||||
return nil, fmt.Errorf("invalid %s %q: host must be 127.0.0.1", envvars.CliSecProxy, redacted)
|
||||
}
|
||||
if u.Port() == "" {
|
||||
return nil, fmt.Errorf("invalid %s %q: explicit port is required", envvars.CliSecProxy, redacted)
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return nil, fmt.Errorf("invalid %s %q: path is not allowed", envvars.CliSecProxy, redacted)
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
return nil, fmt.Errorf("invalid %s %q: query is not allowed", envvars.CliSecProxy, redacted)
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return nil, fmt.Errorf("invalid %s %q: fragment is not allowed", envvars.CliSecProxy, redacted)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// redactProxyURL masks userinfo (username:password) in a proxy URL.
|
||||
// Handles both scheme-prefixed ("http://user:pass@host") and bare formats.
|
||||
func redactProxyURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err == nil && u.User != nil {
|
||||
u.User = url.User("***")
|
||||
return u.String()
|
||||
}
|
||||
// Fallback: handle "user:pass@proxy:8080"
|
||||
if at := strings.LastIndex(raw, "@"); at > 0 {
|
||||
return "***@" + raw[at+1:]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// ApplyToTransport clones base and applies SEC plugin settings to the clone.
|
||||
// Caller owns the returned *http.Transport.
|
||||
func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport.(*http.Transport)
|
||||
}
|
||||
u, err := c.proxyURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := base.Clone()
|
||||
t.Proxy = http.ProxyURL(u) // fixed proxy overrides environment proxy vars
|
||||
if err := applyExtraRootCA(t, c.CAPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
245
internal/secplugin/config_test.go
Normal file
245
internal/secplugin/config_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package secplugin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// unsetEnv clears key for the duration of the test and restores its original value.
|
||||
func unsetEnv(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
old, had := os.LookupEnv(key)
|
||||
_ = os.Unsetenv(key)
|
||||
t.Cleanup(func() {
|
||||
if had {
|
||||
_ = os.Setenv(key, old)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// unsetSecPluginEnv clears SEC-related environment variables for deterministic tests.
|
||||
func unsetSecPluginEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
unsetEnv(t, envvars.CliSecEnable)
|
||||
unsetEnv(t, envvars.CliSecProxy)
|
||||
unsetEnv(t, envvars.CliSecCA)
|
||||
unsetEnv(t, envvars.CliSecAuth)
|
||||
}
|
||||
|
||||
// writeFile creates parent directories and writes test data for fixtures.
|
||||
func writeFile(t *testing.T, path string, data []byte, perm os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, perm); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_MissingFileReturnsNil verifies that Load reports no config when no file
|
||||
// or SEC environment overrides exist.
|
||||
func TestLoad_MissingFileReturnsNil(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
unsetSecPluginEnv(t)
|
||||
// TestLoad_MissingFileReturnsNil must reset loadOnce, loadCfg, and loadErr
|
||||
// because multiple tests in this package share the package-level Load()
|
||||
// cache via sync.Once.
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Fatalf("Load() = %#v, want nil (missing file)", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyToTransport_SetsProxy verifies that a valid SEC config installs a fixed proxy.
|
||||
func TestApplyToTransport_SetsProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
unsetSecPluginEnv(t)
|
||||
|
||||
cfgPath := Path()
|
||||
writeFile(t, cfgPath, []byte(`{
|
||||
"LARKSUITE_CLI_SEC_ENABLE": true,
|
||||
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
|
||||
"LARKSUITE_CLI_SEC_CA": "",
|
||||
"LARKSUITE_CLI_SEC_AUTH": false
|
||||
}`), 0600)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled() {
|
||||
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
|
||||
}
|
||||
|
||||
base := http.DefaultTransport.(*http.Transport)
|
||||
tr, err := cfg.ApplyToTransport(base)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyToTransport() error = %v", err)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("Proxy func is nil, want fixed proxy")
|
||||
}
|
||||
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Proxy() error = %v", err)
|
||||
}
|
||||
if u == nil || u.String() != "http://127.0.0.1:3128" {
|
||||
t.Fatalf("Proxy() = %v, want http://127.0.0.1:3128", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_RejectsNonLoopbackProxy verifies that SEC mode rejects non-loopback proxies.
|
||||
func TestLoad_RejectsNonLoopbackProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
unsetSecPluginEnv(t)
|
||||
|
||||
cfgPath := Path()
|
||||
writeFile(t, cfgPath, []byte(`{
|
||||
"LARKSUITE_CLI_SEC_ENABLE": true,
|
||||
"LARKSUITE_CLI_SEC_PROXY": "http://10.0.0.1:3128",
|
||||
"LARKSUITE_CLI_SEC_CA": "",
|
||||
"LARKSUITE_CLI_SEC_AUTH": false
|
||||
}`), 0600)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled() {
|
||||
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
|
||||
}
|
||||
_, err = cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport))
|
||||
if err == nil {
|
||||
t.Fatal("ApplyToTransport() error = nil, want invalid proxy host error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfig_ProxyURLRejectsUnsupportedParts verifies the SEC proxy validator
|
||||
// rejects URLs with missing ports, queries, and fragments.
|
||||
func TestConfig_ProxyURLRejectsUnsupportedParts(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing explicit port",
|
||||
raw: "http://127.0.0.1",
|
||||
want: "explicit port is required",
|
||||
},
|
||||
{
|
||||
name: "query string",
|
||||
raw: "http://127.0.0.1:3128?foo=bar",
|
||||
want: "query is not allowed",
|
||||
},
|
||||
{
|
||||
name: "fragment",
|
||||
raw: "http://127.0.0.1:3128#frag",
|
||||
want: "fragment is not allowed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := (&Config{Proxy: tt.raw}).proxyURL()
|
||||
if err == nil {
|
||||
t.Fatalf("proxyURL() error = nil, want substring %q", tt.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("proxyURL() error = %q, want substring %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_EnvOnlyConfig verifies that SEC settings can come entirely from environment variables.
|
||||
func TestLoad_EnvOnlyConfig(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
|
||||
t.Setenv(envvars.CliSecEnable, "true")
|
||||
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:7777")
|
||||
t.Setenv(envvars.CliSecCA, "")
|
||||
t.Setenv(envvars.CliSecAuth, "true")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled() {
|
||||
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
|
||||
}
|
||||
if !cfg.AuthEnabled() {
|
||||
t.Fatalf("cfg.AuthEnabled() = false, want true")
|
||||
}
|
||||
tr, err := cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport))
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyToTransport() error = %v", err)
|
||||
}
|
||||
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Proxy() error = %v", err)
|
||||
}
|
||||
if u == nil || u.String() != "http://127.0.0.1:7777" {
|
||||
t.Fatalf("Proxy() = %v, want http://127.0.0.1:7777", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_EnvOverridesFile verifies that SEC environment variables override file values.
|
||||
func TestLoad_EnvOverridesFile(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
|
||||
// File enables with one proxy.
|
||||
cfgPath := Path()
|
||||
writeFile(t, cfgPath, []byte(`{
|
||||
"LARKSUITE_CLI_SEC_ENABLE": true,
|
||||
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
|
||||
"LARKSUITE_CLI_SEC_CA": "",
|
||||
"LARKSUITE_CLI_SEC_AUTH": false
|
||||
}`), 0600)
|
||||
|
||||
// Env overrides: disable + different proxy (should be irrelevant once disabled).
|
||||
t.Setenv(envvars.CliSecEnable, "false")
|
||||
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:9999")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatalf("Load() = nil, want non-nil (file exists)")
|
||||
}
|
||||
if cfg.Enabled() {
|
||||
t.Fatalf("cfg.Enabled() = true, want false (env override)")
|
||||
}
|
||||
}
|
||||
51
internal/secplugin/tls_ca.go
Normal file
51
internal/secplugin/tls_ca.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package secplugin
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// applyExtraRootCA augments t with an additional PEM bundle used for SEC proxy
|
||||
// TLS interception.
|
||||
func applyExtraRootCA(t *http.Transport, caPath string) error {
|
||||
caPath = strings.TrimSpace(caPath)
|
||||
if caPath == "" {
|
||||
return nil
|
||||
}
|
||||
if !filepath.IsAbs(caPath) {
|
||||
return fmt.Errorf("invalid %s %q: must be an absolute path to a PEM file", envvars.CliSecCA, caPath)
|
||||
}
|
||||
pemBytes, err := vfs.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s %q: %w", envvars.CliSecCA, caPath, err)
|
||||
}
|
||||
|
||||
// Start from system pool when possible; if unavailable, create a new pool.
|
||||
pool, _ := x509.SystemCertPool()
|
||||
if pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if ok := pool.AppendCertsFromPEM(pemBytes); !ok {
|
||||
return fmt.Errorf("invalid %s %q: no certificates parsed from PEM", envvars.CliSecCA, caPath)
|
||||
}
|
||||
|
||||
if t.TLSClientConfig == nil {
|
||||
t.TLSClientConfig = &tls.Config{}
|
||||
} else {
|
||||
// Clone to avoid mutating shared config from the base transport.
|
||||
t.TLSClientConfig = t.TLSClientConfig.Clone()
|
||||
}
|
||||
t.TLSClientConfig.MinVersion = tls.VersionTLS12
|
||||
t.TLSClientConfig.RootCAs = pool
|
||||
return nil
|
||||
}
|
||||
138
internal/secplugin/tls_ca_test.go
Normal file
138
internal/secplugin/tls_ca_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package secplugin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mustCreateTestCertPEM generates a short-lived self-signed CA certificate for tests.
|
||||
func mustCreateTestCertPEM(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey() error = %v", err)
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "secplugin-test-ca",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}, &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "secplugin-test-ca",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate() error = %v", err)
|
||||
}
|
||||
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_EmptyPathIsNoop verifies that an empty CA path leaves the transport unchanged.
|
||||
func TestApplyExtraRootCA_EmptyPathIsNoop(t *testing.T) {
|
||||
tr := &http.Transport{}
|
||||
|
||||
if err := applyExtraRootCA(tr, " "); err != nil {
|
||||
t.Fatalf("applyExtraRootCA() error = %v", err)
|
||||
}
|
||||
if tr.TLSClientConfig != nil {
|
||||
t.Fatalf("TLSClientConfig = %#v, want nil", tr.TLSClientConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_RejectsRelativePath verifies that CA paths must be absolute.
|
||||
func TestApplyExtraRootCA_RejectsRelativePath(t *testing.T) {
|
||||
tr := &http.Transport{}
|
||||
|
||||
err := applyExtraRootCA(tr, "ca.pem")
|
||||
if err == nil || !strings.Contains(err.Error(), "must be an absolute path") {
|
||||
t.Fatalf("applyExtraRootCA() error = %v, want absolute-path error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_RejectsMissingFile verifies read errors for missing PEM bundles.
|
||||
func TestApplyExtraRootCA_RejectsMissingFile(t *testing.T) {
|
||||
tr := &http.Transport{}
|
||||
|
||||
err := applyExtraRootCA(tr, filepath.Join(t.TempDir(), "missing.pem"))
|
||||
if err == nil || !strings.Contains(err.Error(), "failed to read") {
|
||||
t.Fatalf("applyExtraRootCA() error = %v, want read error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_RejectsInvalidPEM verifies validation of malformed PEM bundles.
|
||||
func TestApplyExtraRootCA_RejectsInvalidPEM(t *testing.T) {
|
||||
caPath := filepath.Join(t.TempDir(), "invalid.pem")
|
||||
writeFile(t, caPath, []byte("not a pem"), 0600)
|
||||
|
||||
tr := &http.Transport{}
|
||||
err := applyExtraRootCA(tr, caPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "no certificates parsed from PEM") {
|
||||
t.Fatalf("applyExtraRootCA() error = %v, want invalid PEM error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_SetsTLSConfigWhenMissing verifies initialization of TLSClientConfig when absent.
|
||||
func TestApplyExtraRootCA_SetsTLSConfigWhenMissing(t *testing.T) {
|
||||
caPath := filepath.Join(t.TempDir(), "ca.pem")
|
||||
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
|
||||
|
||||
tr := &http.Transport{}
|
||||
if err := applyExtraRootCA(tr, caPath); err != nil {
|
||||
t.Fatalf("applyExtraRootCA() error = %v", err)
|
||||
}
|
||||
if tr.TLSClientConfig == nil {
|
||||
t.Fatal("TLSClientConfig = nil, want initialized config")
|
||||
}
|
||||
if tr.TLSClientConfig.RootCAs == nil {
|
||||
t.Fatal("RootCAs = nil, want cert pool")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_ClonesExistingTLSConfig verifies cloning when the base transport already has TLS settings.
|
||||
func TestApplyExtraRootCA_ClonesExistingTLSConfig(t *testing.T) {
|
||||
caPath := filepath.Join(t.TempDir(), "ca.pem")
|
||||
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
|
||||
|
||||
original := &tls.Config{ServerName: "open.feishu.cn"}
|
||||
tr := &http.Transport{TLSClientConfig: original}
|
||||
if err := applyExtraRootCA(tr, caPath); err != nil {
|
||||
t.Fatalf("applyExtraRootCA() error = %v", err)
|
||||
}
|
||||
if tr.TLSClientConfig == original {
|
||||
t.Fatal("TLSClientConfig pointer reused, want clone")
|
||||
}
|
||||
if tr.TLSClientConfig.ServerName != original.ServerName {
|
||||
t.Fatalf("ServerName = %q, want %q", tr.TLSClientConfig.ServerName, original.ServerName)
|
||||
}
|
||||
if tr.TLSClientConfig.RootCAs == nil {
|
||||
t.Fatal("RootCAs = nil, want cert pool")
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,11 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/secplugin"
|
||||
)
|
||||
|
||||
// Proxy environment constants control shared transport proxy behavior.
|
||||
const (
|
||||
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
|
||||
EnvNoProxy = "LARK_CLI_NO_PROXY"
|
||||
@@ -36,6 +39,7 @@ func DetectProxyEnv() (key, value string) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// proxyWarningOnce ensures proxy environment warnings are emitted at most once.
|
||||
var proxyWarningOnce sync.Once
|
||||
|
||||
// redactProxyURL masks userinfo (username:password) in a proxy URL.
|
||||
@@ -84,6 +88,31 @@ var noProxyTransport = sync.OnceValue(func() *http.Transport {
|
||||
return t
|
||||
})
|
||||
|
||||
// secProxyTransport is a fixed-proxy clone of http.DefaultTransport (with optional
|
||||
// custom root CA), lazily built on first use when sec plugin mode is enabled.
|
||||
var secProxyTransport = sync.OnceValue(func() *http.Transport {
|
||||
def, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return &http.Transport{}
|
||||
}
|
||||
|
||||
cfg, err := secplugin.Load()
|
||||
if err != nil || cfg == nil || !cfg.Enabled() {
|
||||
return def
|
||||
}
|
||||
t, err := cfg.ApplyToTransport(def)
|
||||
if err != nil {
|
||||
// Fail closed: do not silently fall back to direct egress when the
|
||||
// operator explicitly enabled SEC plugin mode.
|
||||
blocked := def.Clone()
|
||||
blocked.Proxy = func(*http.Request) (*url.URL, error) {
|
||||
return nil, fmt.Errorf("sec plugin enabled but config is invalid: %v", err)
|
||||
}
|
||||
return blocked
|
||||
}
|
||||
return t
|
||||
})
|
||||
|
||||
// SharedTransport returns the base http.RoundTripper for CLI HTTP clients.
|
||||
//
|
||||
// By default it returns http.DefaultTransport — the stdlib-provided
|
||||
@@ -99,6 +128,23 @@ var noProxyTransport = sync.OnceValue(func() *http.Transport {
|
||||
// goroutines are reused; cloning per call leaks them until IdleConnTimeout
|
||||
// (~90s) fires.
|
||||
func SharedTransport() http.RoundTripper {
|
||||
// SEC plugin mode overrides all other proxy behavior (env proxies and
|
||||
// LARK_CLI_NO_PROXY), per operator intent.
|
||||
if cfg, err := secplugin.Load(); err != nil {
|
||||
// Fail closed: if the config file exists but is malformed/unreadable,
|
||||
// do not silently fall back to direct egress.
|
||||
def, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return http.DefaultTransport
|
||||
}
|
||||
blocked := def.Clone()
|
||||
blocked.Proxy = func(*http.Request) (*url.URL, error) {
|
||||
return nil, fmt.Errorf("sec plugin config is invalid: %v", err)
|
||||
}
|
||||
return blocked
|
||||
} else if cfg != nil && cfg.Enabled() {
|
||||
return secProxyTransport()
|
||||
}
|
||||
if os.Getenv(EnvNoProxy) != "" {
|
||||
return noProxyTransport()
|
||||
}
|
||||
|
||||
@@ -6,11 +6,43 @@ package util
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// unsetEnv clears key for the duration of the test and restores its original value.
|
||||
func unsetEnv(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
old, had := os.LookupEnv(key)
|
||||
_ = os.Unsetenv(key)
|
||||
t.Cleanup(func() {
|
||||
if had {
|
||||
_ = os.Setenv(key, old)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// unsetSecPluginEnv clears SEC-related environment variables for deterministic tests.
|
||||
func unsetSecPluginEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
// Ensure developer machine env doesn't accidentally enable SEC plugin mode
|
||||
// and change expectations for SharedTransport().
|
||||
unsetEnv(t, envvars.CliSecEnable)
|
||||
unsetEnv(t, envvars.CliSecProxy)
|
||||
unsetEnv(t, envvars.CliSecCA)
|
||||
unsetEnv(t, envvars.CliSecAuth)
|
||||
}
|
||||
|
||||
// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior.
|
||||
func TestDetectProxyEnv(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
|
||||
// Clear all proxy env vars first
|
||||
for _, k := range proxyEnvKeys {
|
||||
t.Setenv(k, "")
|
||||
@@ -28,7 +60,10 @@ func TestDetectProxyEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_DefaultReturnsStdlibSingleton verifies the default shared transport.
|
||||
func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
tr := SharedTransport()
|
||||
if tr != http.DefaultTransport {
|
||||
@@ -36,7 +71,10 @@ func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport.
|
||||
func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
tr := SharedTransport()
|
||||
if tr == http.DefaultTransport {
|
||||
@@ -51,7 +89,10 @@ func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport.
|
||||
func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
a := SharedTransport()
|
||||
b := SharedTransport()
|
||||
@@ -60,7 +101,10 @@ func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib transport after unsetting EnvNoProxy.
|
||||
func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
|
||||
// the no-proxy singleton), then unsets it. Subsequent calls must return
|
||||
// http.DefaultTransport, NOT the cached no-proxy clone.
|
||||
@@ -77,7 +121,10 @@ func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_NoProxyOverridesSystemProxy verifies that EnvNoProxy disables system proxies.
|
||||
func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
@@ -90,7 +137,10 @@ func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning.
|
||||
func TestWarnIfProxied_WithProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
// Reset the once guard for this test
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
@@ -111,7 +161,10 @@ func TestWarnIfProxied_WithProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_WithoutProxy verifies that no warning is emitted without proxy settings.
|
||||
func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
for _, k := range proxyEnvKeys {
|
||||
@@ -126,7 +179,10 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_SilentWhenDisabled verifies that EnvNoProxy suppresses warnings.
|
||||
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
|
||||
@@ -140,7 +196,10 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once.
|
||||
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTP_PROXY", "http://proxy:1234")
|
||||
@@ -160,7 +219,10 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats.
|
||||
func TestRedactProxyURL(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
@@ -183,7 +245,10 @@ func TestRedactProxyURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_RedactsCredentials verifies that warning output never leaks credentials.
|
||||
func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetSecPluginEnv(t)
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")
|
||||
|
||||
3
main.go
3
main.go
@@ -9,7 +9,8 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider
|
||||
_ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider
|
||||
_ "github.com/larksuite/cli/extension/credential/secplugin" // activate sec plugin credential provider (SEC_AUTH placeholder tokens)
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.37",
|
||||
"version": "1.0.34",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// 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
|
||||
},
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// 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"
|
||||
@@ -1,83 +0,0 @@
|
||||
// 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 ""
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseFormDetail = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+form-detail",
|
||||
Description: "Get form detail by share token",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:form:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "share-token", Desc: "Form share token (share_token)", Required: true},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/tables/forms/detail").
|
||||
Body(map[string]interface{}{
|
||||
"share_token": runtime.Str("share-token"),
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body := map[string]interface{}{
|
||||
"share_token": runtime.Str("share-token"),
|
||||
}
|
||||
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
baseV3Path("bases", "tables", "forms", "detail"), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
uploadAttachConcurrency = 5
|
||||
)
|
||||
|
||||
var BaseFormSubmit = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+form-submit",
|
||||
Description: "Submit a form (fill and submit form data)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:form:update", "docs:document.media:upload"},
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "share-token", Desc: "Form share token (required), extracted from the form share link", Required: true},
|
||||
{Name: "base-token", Desc: "Base token (required when --json contains attachments, used for uploading attachments to Base Drive Media)"},
|
||||
{Name: "json", Desc: `JSON object containing "fields" (field values) and "attachments" (attachment file paths). Example: '{"fields":{"Rating":5,"Review":"Good"},"attachments":{"Attachment":["./a.pdf","./b.png"]}}'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
|
||||
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
|
||||
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFormSubmit(runtime)
|
||||
},
|
||||
DryRun: dryRunFormSubmit,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeFormSubmit(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func validateFormSubmit(runtime *common.RuntimeContext) error {
|
||||
// 校验 --json 结构:提取 "fields" 和 "attachments"
|
||||
pc := newParseCtx(runtime)
|
||||
raw, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fields, _ := raw["fields"].(map[string]interface{})
|
||||
attachments, hasAttachments := raw["attachments"]
|
||||
|
||||
if !hasAttachments && fields == nil {
|
||||
return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
|
||||
}
|
||||
|
||||
if hasAttachments {
|
||||
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
|
||||
if runtime.Str("base-token") == "" {
|
||||
return common.FlagErrorf("--base-token is required when --json contains \"attachments\"")
|
||||
}
|
||||
|
||||
attMap, ok := attachments.(map[string]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
|
||||
}
|
||||
for fieldName, value := range attMap {
|
||||
paths, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
}
|
||||
for i, item := range paths {
|
||||
if _, ok := item.(string); !ok {
|
||||
return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseFormSubmitJSON 将 --json 解析为字段和附件映射。
|
||||
func parseFormSubmitJSON(runtime *common.RuntimeContext) (map[string]interface{}, map[string][]string, error) {
|
||||
pc := newParseCtx(runtime)
|
||||
raw, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
fields, _ := raw["fields"].(map[string]interface{})
|
||||
if fields == nil {
|
||||
fields = make(map[string]interface{})
|
||||
}
|
||||
|
||||
var attMap map[string][]string
|
||||
if attachments, ok := raw["attachments"]; ok {
|
||||
attObj, ok := attachments.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, nil, common.FlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
|
||||
}
|
||||
if len(attObj) > 0 {
|
||||
attMap = make(map[string][]string, len(attObj))
|
||||
for fieldName, value := range attObj {
|
||||
paths, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return nil, nil, common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
}
|
||||
filePaths := make([]string, 0, len(paths))
|
||||
for _, item := range paths {
|
||||
if s, ok := item.(string); ok {
|
||||
filePaths = append(filePaths, s)
|
||||
} else {
|
||||
return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
|
||||
}
|
||||
}
|
||||
if len(filePaths) > 0 {
|
||||
attMap[fieldName] = filePaths
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields, attMap, nil
|
||||
}
|
||||
|
||||
func dryRunFormSubmit(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
fields, attachmentMap, err := parseFormSubmitJSON(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
||||
}
|
||||
|
||||
if len(attachmentMap) > 0 {
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("Form submit with attachments: upload local files per field → merge with fields → submit")
|
||||
|
||||
for fieldName, filePaths := range attachmentMap {
|
||||
for _, p := range filePaths {
|
||||
fileName := filepath.Base(p)
|
||||
dry = dry.POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc(fmt.Sprintf("Upload attachment for field %q: %s", fieldName, fileName)).
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": baseFormAttachmentParentType,
|
||||
"parent_node": runtime.Str("base-token"),
|
||||
"extra": baseFormAttachmentExtra(runtime.Str("share-token")),
|
||||
"file": "@" + p,
|
||||
"size": "<file_size>",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
body := buildFormSubmitBody(runtime, fields)
|
||||
dry = dry.POST("/open-apis/base/v3/bases/tables/forms/submit").
|
||||
Body(body).
|
||||
Desc("Submit form with uploaded attachment tokens merged with fields")
|
||||
return dry
|
||||
}
|
||||
|
||||
body := buildFormSubmitBody(runtime, fields)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/tables/forms/submit").
|
||||
Body(body)
|
||||
}
|
||||
|
||||
func buildFormSubmitBody(runtime *common.RuntimeContext, content map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"share_token": runtime.Str("share-token"),
|
||||
"content": content,
|
||||
}
|
||||
}
|
||||
|
||||
func executeFormSubmit(runtime *common.RuntimeContext) error {
|
||||
fields, attachmentMap, err := parseFormSubmitJSON(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 上传附件并合并到字段中
|
||||
if len(attachmentMap) > 0 {
|
||||
baseToken := runtime.Str("base-token")
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)")
|
||||
}
|
||||
|
||||
// Step 1: 收集所有唯一路径(跨字段去重)
|
||||
allPaths := collectUniquePaths(attachmentMap)
|
||||
if len(allPaths) == 0 {
|
||||
return common.FlagErrorf("attachments in --json contains no valid file paths")
|
||||
}
|
||||
|
||||
// Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
|
||||
sizeMap := make(map[string]int64, len(allPaths))
|
||||
for _, filePath := range allPaths {
|
||||
if _, err := validate.SafeInputPath(filePath); err != nil {
|
||||
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
||||
}
|
||||
return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
|
||||
}
|
||||
if !fileInfo.Mode().IsRegular() {
|
||||
return output.ErrValidation("attachment file %s is not a regular file", filePath)
|
||||
}
|
||||
sizeMap[filePath] = fileInfo.Size()
|
||||
}
|
||||
|
||||
// Step 3: 并行上传,构建路径 → 附件结果映射
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading %d unique attachment(s)...\n", len(allPaths))
|
||||
resultMap, err := uploadAttachmentsParallel(runtime, allPaths, baseFormAttachmentUploadTarget(baseToken, runtime.Str("share-token")), sizeMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4: 根据共享结果映射,按字段组装单元格
|
||||
for fieldName, filePaths := range attachmentMap {
|
||||
cell := make([]interface{}, 0, len(filePaths))
|
||||
for _, p := range filePaths {
|
||||
if att, ok := resultMap[p]; ok {
|
||||
cell = append(cell, att)
|
||||
}
|
||||
}
|
||||
fields[fieldName] = cell
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploaded %d unique file(s) into %d field(s)\n", len(resultMap), len(attachmentMap))
|
||||
}
|
||||
|
||||
body := buildFormSubmitBody(runtime, fields)
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
baseV3Path("bases", "tables", "forms", "submit"),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectUniquePaths 收集所有字段中的文件路径,返回去重后的有序列表。
|
||||
func collectUniquePaths(attachmentMap map[string][]string) []string {
|
||||
seen := make(map[string]bool, len(attachmentMap)*4)
|
||||
var order []string
|
||||
for _, filePaths := range attachmentMap {
|
||||
for _, p := range filePaths {
|
||||
if !seen[p] {
|
||||
seen[p] = true
|
||||
order = append(order, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func baseFormAttachmentUploadTarget(baseToken, shareToken string) baseAttachmentUploadTarget {
|
||||
return baseAttachmentUploadTarget{
|
||||
ParentType: baseFormAttachmentParentType,
|
||||
ParentNode: baseToken,
|
||||
Extra: baseFormAttachmentExtra(shareToken),
|
||||
}
|
||||
}
|
||||
|
||||
func baseFormAttachmentExtra(shareToken string) string {
|
||||
extra, err := json.Marshal(map[string]string{"share_token": shareToken})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(extra)
|
||||
}
|
||||
|
||||
// uploadAttachmentsParallel 并发上传文件,返回路径 → 附件对象的映射。
|
||||
func uploadAttachmentsParallel(runtime *common.RuntimeContext, paths []string, target baseAttachmentUploadTarget, sizeMap map[string]int64) (map[string]interface{}, error) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
resultMap = make(map[string]interface{}, len(paths))
|
||||
)
|
||||
|
||||
g, _ := errgroup.WithContext(runtime.Ctx())
|
||||
g.SetLimit(uploadAttachConcurrency) // 限制并发数
|
||||
|
||||
for _, filePath := range paths {
|
||||
fp := filePath // 捕获循环变量
|
||||
g.Go(func() error {
|
||||
fileName := filepath.Base(fp)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Uploading: %s\n", fileName)
|
||||
|
||||
att, err := uploadSingleAttachment(runtime, fp, fileName, sizeMap[fp], target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
resultMap[fp] = att
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resultMap, nil
|
||||
}
|
||||
|
||||
// uploadSingleAttachment 上传单个文件,返回附件单元格项。
|
||||
// 前置条件:文件已通过校验(存在、常规文件、大小在限制内)。
|
||||
func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) {
|
||||
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
|
||||
}
|
||||
return att, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,17 +31,10 @@ import (
|
||||
const (
|
||||
baseAttachmentUploadMaxFileSize int64 = 2 * 1024 * 1024 * 1024
|
||||
baseAttachmentParentType = "bitable_file"
|
||||
baseFormAttachmentParentType = "bitable_tmp_point"
|
||||
baseAttachmentMaxBatchSize = 50
|
||||
baseAttachmentGetMaxRecords = 10
|
||||
)
|
||||
|
||||
type baseAttachmentUploadTarget struct {
|
||||
ParentType string
|
||||
ParentNode string
|
||||
Extra string
|
||||
}
|
||||
|
||||
var BaseRecordUploadAttachment = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-upload-attachment",
|
||||
@@ -285,10 +278,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, fileInfo.Size(), baseAttachmentUploadTarget{
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: runtime.Str("base-token"),
|
||||
})
|
||||
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -469,33 +459,31 @@ func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValu
|
||||
return attachments, nil
|
||||
}
|
||||
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (map[string]interface{}, error) {
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
|
||||
mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentNode := baseToken
|
||||
var (
|
||||
fileToken string
|
||||
)
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
parentNode := target.ParentNode
|
||||
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: target.ParentType,
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: &parentNode,
|
||||
Extra: target.Extra,
|
||||
})
|
||||
} else {
|
||||
fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: target.ParentType,
|
||||
ParentNode: target.ParentNode,
|
||||
Extra: target.Extra,
|
||||
ParentType: baseAttachmentParentType,
|
||||
ParentNode: parentNode,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -70,12 +70,10 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseFormsList,
|
||||
BaseFormUpdate,
|
||||
BaseFormGet,
|
||||
BaseFormDetail,
|
||||
BaseFormQuestionsCreate,
|
||||
BaseFormQuestionsDelete,
|
||||
BaseFormQuestionsUpdate,
|
||||
BaseFormQuestionsList,
|
||||
BaseFormSubmit,
|
||||
BaseDashboardList,
|
||||
BaseDashboardGet,
|
||||
BaseDashboardCreate,
|
||||
|
||||
@@ -3,61 +3,29 @@
|
||||
|
||||
package common
|
||||
|
||||
// DriveMeta is the subset of drive metas/batch_query fields used by shortcuts.
|
||||
type DriveMeta struct {
|
||||
Title string
|
||||
URL string
|
||||
}
|
||||
|
||||
// FetchDriveMeta looks up document metadata via the drive metas batch_query API.
|
||||
func FetchDriveMeta(runtime *RuntimeContext, token, docType string, withURL bool) (DriveMeta, error) {
|
||||
body := map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": token,
|
||||
"doc_type": docType,
|
||||
},
|
||||
},
|
||||
}
|
||||
if withURL {
|
||||
body["with_url"] = true
|
||||
}
|
||||
|
||||
// FetchDriveMetaTitle looks up the document title via the drive metas batch_query API.
|
||||
func FetchDriveMetaTitle(runtime *RuntimeContext, token, docType string) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
body,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": token,
|
||||
"doc_type": docType,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return DriveMeta{}, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
metas := GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return DriveMeta{}, nil
|
||||
return "", nil
|
||||
}
|
||||
meta, _ := metas[0].(map[string]interface{})
|
||||
return DriveMeta{
|
||||
Title: GetString(meta, "title"),
|
||||
URL: GetString(meta, "url"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FetchDriveMetaTitle looks up the document title via the drive metas batch_query API.
|
||||
func FetchDriveMetaTitle(runtime *RuntimeContext, token, docType string) (string, error) {
|
||||
meta, err := FetchDriveMeta(runtime, token, docType, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return meta.Title, nil
|
||||
}
|
||||
|
||||
// FetchDriveMetaURL looks up the document access URL via the drive metas batch_query API.
|
||||
func FetchDriveMetaURL(runtime *RuntimeContext, token, docType string) (string, error) {
|
||||
meta, err := FetchDriveMeta(runtime, token, docType, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return meta.URL, nil
|
||||
return GetString(meta, "title"), nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -106,44 +105,6 @@ 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,7 +34,6 @@ 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.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,14 +43,11 @@ func AutoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
|
||||
func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourceType string) map[string]interface{} {
|
||||
userOpenID := strings.TrimSpace(runtime.UserOpenId())
|
||||
if userOpenID == "" {
|
||||
result := buildPermissionGrantResult(
|
||||
return 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{}{
|
||||
@@ -74,26 +70,21 @@ func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
errMsg := compactPermissionGrantError(err)
|
||||
result := buildPermissionGrantResult(
|
||||
return 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(), errMsg),
|
||||
fmt.Sprintf("Auto-grant failed: %s. The app may lack the required scope or the resource restricts permission changes.", errMsg),
|
||||
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.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, reason string) map[string]interface{} {
|
||||
func buildPermissionGrantResult(status, userOpenID, message string) map[string]interface{} {
|
||||
result := map[string]interface{}{
|
||||
"status": status,
|
||||
"perm": permissionGrantPerm,
|
||||
@@ -103,11 +94,6 @@ func buildPermissionGrantResult(status, userOpenID, message, reason string) map[
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
// 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, stderr, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
@@ -107,9 +107,6 @@ 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) {
|
||||
@@ -143,7 +140,7 @@ func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
||||
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
@@ -183,9 +180,6 @@ 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,34 +47,6 @@ 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
|
||||
@@ -121,18 +93,17 @@ const (
|
||||
var DriveAddComment = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+add-comment",
|
||||
Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only",
|
||||
Description: "Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides",
|
||||
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, 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: "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: "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')"},
|
||||
@@ -174,6 +145,7 @@ var DriveAddComment = common.Shortcut{
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
selection := runtime.Str("selection-with-ellipsis")
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if strings.TrimSpace(selection) != "" && blockID != "" {
|
||||
@@ -184,9 +156,6 @@ 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")
|
||||
}
|
||||
@@ -248,33 +217,6 @@ 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"
|
||||
@@ -375,9 +317,6 @@ 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 {
|
||||
@@ -482,9 +421,6 @@ 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
|
||||
}
|
||||
@@ -495,7 +431,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/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", 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)
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
|
||||
@@ -504,7 +440,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, file, sheet, slides)")
|
||||
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, sheet, slides)")
|
||||
}
|
||||
return commentDocRef{Kind: docType, Token: raw}, nil
|
||||
}
|
||||
@@ -515,16 +451,9 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
return resolvedCommentTarget{}, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
return resolvedCommentTarget{
|
||||
DocID: docRef.Token,
|
||||
@@ -578,24 +507,11 @@ 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/file/sheet/slides", objType)
|
||||
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/sheet/slides", objType)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -802,10 +718,6 @@ 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,
|
||||
@@ -897,107 +809,6 @@ 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 {
|
||||
@@ -1038,48 +849,6 @@ 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,13 +105,6 @@ 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",
|
||||
@@ -129,12 +122,6 @@ 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",
|
||||
@@ -558,29 +545,6 @@ 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()
|
||||
|
||||
@@ -942,34 +906,6 @@ 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) {
|
||||
@@ -1180,146 +1116,6 @@ 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) {
|
||||
@@ -1550,43 +1346,6 @@ 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) {
|
||||
@@ -1638,7 +1397,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/file/sheet/slides") {
|
||||
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/sheet/slides") {
|
||||
t.Fatalf("expected unsupported type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ var DriveImport = common.Shortcut{
|
||||
{Name: "type", Desc: "target document type (docx, sheet, bitable)", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
|
||||
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
|
||||
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
@@ -39,7 +38,6 @@ var DriveImport = common.Shortcut{
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -48,15 +46,11 @@ var DriveImport = common.Shortcut{
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
@@ -82,7 +76,6 @@ var DriveImport = common.Shortcut{
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
|
||||
@@ -51,7 +51,6 @@ type driveImportSpec struct {
|
||||
DocType string
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string // existing bitable token to import data into (only for type=bitable)
|
||||
}
|
||||
|
||||
func (s driveImportSpec) FileExtension() string {
|
||||
@@ -68,7 +67,7 @@ func (s driveImportSpec) TargetFileName() string {
|
||||
|
||||
// CreateTaskBody builds the request body expected by /drive/v1/import_tasks.
|
||||
func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
return map[string]interface{}{
|
||||
"file_extension": s.FileExtension(),
|
||||
"file_token": fileToken,
|
||||
"type": s.DocType,
|
||||
@@ -80,12 +79,6 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{}
|
||||
"mount_key": s.FolderToken,
|
||||
},
|
||||
}
|
||||
|
||||
if s.DocType == "bitable" && s.TargetToken != "" {
|
||||
body["token"] = s.TargetToken
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// uploadMediaForImport uploads the source file to the temporary import media
|
||||
@@ -239,15 +232,6 @@ func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.TargetToken) != "" {
|
||||
if spec.DocType != "bitable" {
|
||||
return output.ErrValidation("--target-token is only supported when --type is bitable")
|
||||
}
|
||||
if err := validate.ResourceName(spec.TargetToken, "--target-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -45,19 +45,6 @@ func TestValidateDriveImportSpec(t *testing.T) {
|
||||
spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"},
|
||||
wantErr: "unsupported file extension",
|
||||
},
|
||||
{
|
||||
name: "target-token rejected for non-bitable type",
|
||||
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "sheet", TargetToken: "bascnxxx"},
|
||||
wantErr: "--target-token is only supported when --type is bitable",
|
||||
},
|
||||
{
|
||||
name: "target-token accepted for bitable",
|
||||
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "bitable", TargetToken: "bascnxxx"},
|
||||
},
|
||||
{
|
||||
name: "target-token empty for bitable still ok",
|
||||
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "bitable"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -84,7 +84,6 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./base-import.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -149,7 +148,6 @@ func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./large.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -199,7 +197,6 @@ func TestDriveImportDryRunReturnsErrorForUnsafePath(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "../outside.md"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -253,7 +250,6 @@ func TestDriveImportDryRunReturnsErrorForOversizedMarkdown(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./large.md"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -300,7 +296,6 @@ func TestDriveImportDryRunReturnsErrorForDirectoryInput(t *testing.T) {
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./folder-input"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
@@ -371,165 +366,6 @@ func TestDriveImportCreateTaskBodyKeepsEmptyMountKeyForRoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportCreateTaskBodyWithTargetToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := driveImportSpec{
|
||||
FilePath: "/tmp/data.xlsx",
|
||||
DocType: "bitable",
|
||||
TargetToken: "bascnxxxxx",
|
||||
}
|
||||
|
||||
body := spec.CreateTaskBody("file_token_test")
|
||||
|
||||
// point stays the same as default (mount_type=1)
|
||||
point, ok := body["point"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("point = %#v, want map", body["point"])
|
||||
}
|
||||
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
|
||||
t.Fatalf("mount_type = %v (%T), want 1", mt, mt)
|
||||
}
|
||||
|
||||
// token is injected at body top-level
|
||||
if tt, _ := body["token"].(string); tt != "bascnxxxxx" {
|
||||
t.Fatalf("token = %q, want %q", tt, "bascnxxxxx")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportCreateTaskBodyTargetTokenIgnoredForNonBitable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := driveImportSpec{
|
||||
FilePath: "/tmp/data.xlsx",
|
||||
DocType: "sheet",
|
||||
TargetToken: "bascnxxxxx",
|
||||
FolderToken: "fld_test",
|
||||
}
|
||||
|
||||
body := spec.CreateTaskBody("file_token_test")
|
||||
point, ok := body["point"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("point = %#v, want map", body["point"])
|
||||
}
|
||||
|
||||
// Non-bitable should use default folder mount (type=1), ignoring TargetToken
|
||||
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
|
||||
t.Fatalf("mount_type = %v (%T), want 1 (folder mount)", mt, mt)
|
||||
}
|
||||
if _, exists := point["target_token"]; exists {
|
||||
t.Fatal("target_token should not be present for non-bitable type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunWithTargetToken(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./data.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "bitable"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("target-token", "bascntarget123"); err != nil {
|
||||
t.Fatalf("set --target-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 3 {
|
||||
t.Fatalf("expected 3 API calls, got %d", len(got.API))
|
||||
}
|
||||
|
||||
// The import task body (API[1]) should contain target_token in point
|
||||
importTaskBody := got.API[1].Body
|
||||
point, ok := importTaskBody["point"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("point = %#v, want map", importTaskBody["point"])
|
||||
}
|
||||
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
|
||||
t.Fatalf("dry-run mount_type = %v (%T), want 1 (unchanged)", mt, mt)
|
||||
}
|
||||
if tt, _ := importTaskBody["token"].(string); tt != "bascntarget123" {
|
||||
t.Fatalf("dry-run token = %q, want %q", tt, "bascntarget123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportDryRunTargetTokenRejectedForSheet(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("target-token", "", "")
|
||||
if err := cmd.Flags().Set("file", "./data.xlsx"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", "sheet"); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("target-token", "bascnxxx"); err != nil {
|
||||
t.Fatalf("set --target-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
dry := DriveImport.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got.Error == "" || !strings.Contains(got.Error, "--target-token is only supported when --type is bitable") {
|
||||
t.Fatalf("dry-run error = %q, want target-token validation error", got.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// driveImportMockEnv mounts the three stubs needed for a full +import run:
|
||||
// media upload_all -> import_tasks (create) -> import_tasks/<ticket> (poll).
|
||||
// Returns nothing; caller asserts on stdout via decodeDriveEnvelope.
|
||||
|
||||
@@ -604,9 +604,9 @@ func TestDriveUploadSmallFileToWiki(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadUsesMetaURLForExplorerParent(t *testing.T) {
|
||||
func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-explorer-meta-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
AppID: "drive-upload-explorer-fallback-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
|
||||
@@ -615,21 +615,12 @@ func TestDriveUploadUsesMetaURLForExplorerParent(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()
|
||||
@@ -650,14 +641,14 @@ func TestDriveUploadUsesMetaURLForExplorerParent(t *testing.T) {
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadUsesMetaURLForWikiParent(t *testing.T) {
|
||||
func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-wiki-meta-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
AppID: "drive-upload-wiki-no-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
|
||||
@@ -669,18 +660,6 @@ func TestDriveUploadUsesMetaURLForWikiParent(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)
|
||||
@@ -698,8 +677,8 @@ func TestDriveUploadUsesMetaURLForWikiParent(t *testing.T) {
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
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)
|
||||
if _, ok := data["url"]; ok {
|
||||
t.Fatalf("data.url should be omitted for wiki-hosted files (no standalone URL); got %#v", data["url"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1099,15 +1078,14 @@ 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) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(got.API))
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, 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)
|
||||
@@ -1115,12 +1093,6 @@ 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) {
|
||||
@@ -1196,25 +1168,18 @@ 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) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(got.API))
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, 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) {
|
||||
@@ -1257,8 +1222,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) != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", len(got.API))
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, 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,94 +284,3 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestDriveUploadBotAutoGrantSkippedNoUser(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, ""))
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "file_skipped",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("report.pdf", []byte("pdf"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpload, []string{
|
||||
"+upload",
|
||||
"--file", "report.pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantSkipped {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
|
||||
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadBotAutoGrantFailed(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "file_grant_fail",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/file_grant_fail/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("report.pdf", []byte("pdf"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpload, []string{
|
||||
"+upload",
|
||||
"--file", "report.pdf",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
|
||||
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ var DriveSearch = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
|
||||
|
||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
||||
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I created (uses current user's open_id)"},
|
||||
{Name: "creator-ids", Desc: "comma-separated creator open_ids; mutually exclusive with --mine"},
|
||||
|
||||
{Name: "edited-since", Desc: "start of [my edited] time window (e.g. 7d, 1m, 1y, 2026-04-01, RFC3339, unix seconds)"},
|
||||
{Name: "edited-until", Desc: "end of [my edited] time window"},
|
||||
@@ -108,7 +108,7 @@ var DriveSearch = common.Shortcut{
|
||||
Tips: []string{
|
||||
"Time flags accept relative (e.g. 7d, 1m, 1y), absolute (2026-04-01, RFC3339), or unix seconds.",
|
||||
"my_edit_time and my_comment_time are hour-aggregated server-side; sub-hour inputs are snapped and a notice is printed to stderr.",
|
||||
"Use --mine for a quick \"docs I own\" filter (owner semantic, not original creator). For other people, use --creator-ids ou_xxx,ou_yyy.",
|
||||
"Use --mine for a quick \"docs I created\" filter. For other people, use --creator-ids ou_xxx,ou_yyy.",
|
||||
"--folder-tokens limits to doc-only search; --space-ids limits to wiki-only. They cannot be combined.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
|
||||
@@ -92,7 +92,7 @@ var DriveUpload = common.Shortcut{
|
||||
Command: "+upload",
|
||||
Description: "Upload a local file to Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
|
||||
Scopes: []string{"drive:file:upload"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
@@ -124,22 +124,11 @@ var DriveUpload = common.Shortcut{
|
||||
body["file_token"] = spec.FileToken
|
||||
}
|
||||
d := common.NewDryRunAPI().
|
||||
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload), then fetch the real Drive URL via metadata").
|
||||
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload)").
|
||||
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.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.")
|
||||
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.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
@@ -176,10 +165,13 @@ var DriveUpload = common.Shortcut{
|
||||
if uploadResult.Version != "" {
|
||||
out["version"] = uploadResult.Version
|
||||
}
|
||||
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)
|
||||
// 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 !isOverwrite {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, uploadResult.FileToken, "file"); grant != nil {
|
||||
|
||||
@@ -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 (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: "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: "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 (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: "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"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
chatFlag := runtime.Str("chat-id")
|
||||
|
||||
@@ -6,11 +6,9 @@ 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) {
|
||||
@@ -51,37 +49,3 @@ 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 ""
|
||||
}
|
||||
|
||||
@@ -24,16 +24,10 @@ import (
|
||||
const markdownSinglePartSizeLimit = common.MaxDriveMediaUploadSinglePartSize
|
||||
const markdownEmptyContentError = "empty markdown content is not supported; cannot create or overwrite an empty file"
|
||||
|
||||
const (
|
||||
markdownUploadParentTypeExplorer = "explorer"
|
||||
markdownUploadParentTypeWiki = "wiki"
|
||||
)
|
||||
|
||||
type markdownUploadSpec struct {
|
||||
FileToken string
|
||||
FileName string
|
||||
FolderToken string
|
||||
WikiToken string
|
||||
FilePath string
|
||||
Content string
|
||||
ContentSet bool
|
||||
@@ -51,25 +45,6 @@ type markdownMultipartSession struct {
|
||||
BlockNum int
|
||||
}
|
||||
|
||||
type markdownUploadTarget struct {
|
||||
ParentType string
|
||||
ParentNode string
|
||||
}
|
||||
|
||||
func (spec markdownUploadSpec) Target() markdownUploadTarget {
|
||||
if spec.WikiToken != "" {
|
||||
return markdownUploadTarget{
|
||||
ParentType: markdownUploadParentTypeWiki,
|
||||
ParentNode: spec.WikiToken,
|
||||
}
|
||||
}
|
||||
// An empty explorer parent node uploads to the user's Drive root folder.
|
||||
return markdownUploadTarget{
|
||||
ParentType: markdownUploadParentTypeExplorer,
|
||||
ParentNode: spec.FolderToken,
|
||||
}
|
||||
}
|
||||
|
||||
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
|
||||
switch {
|
||||
case spec.ContentSet && spec.FileSet:
|
||||
@@ -78,32 +53,14 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
|
||||
return common.FlagErrorf("specify exactly one of --content or --file")
|
||||
}
|
||||
|
||||
if markdownFlagExplicitlyEmpty(runtime, "folder-token") {
|
||||
if runtime.Changed("folder-token") && strings.TrimSpace(spec.FolderToken) == "" {
|
||||
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
|
||||
}
|
||||
if markdownFlagExplicitlyEmpty(runtime, "wiki-token") {
|
||||
return common.FlagErrorf("--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
|
||||
}
|
||||
targets := 0
|
||||
if spec.FolderToken != "" {
|
||||
targets++
|
||||
}
|
||||
if spec.WikiToken != "" {
|
||||
targets++
|
||||
}
|
||||
if targets > 1 {
|
||||
return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive")
|
||||
}
|
||||
if spec.FolderToken != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
if spec.WikiToken != "" {
|
||||
if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if requireName && spec.ContentSet {
|
||||
if strings.TrimSpace(spec.FileName) == "" {
|
||||
@@ -135,10 +92,6 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
|
||||
return nil
|
||||
}
|
||||
|
||||
func markdownFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName string) bool {
|
||||
return runtime.Changed(flagName) && strings.TrimSpace(runtime.Str(flagName)) == ""
|
||||
}
|
||||
|
||||
func validateMarkdownFileName(name, flagName string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
@@ -184,19 +137,11 @@ func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, f
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, wrapMarkdownDownloadError(err)
|
||||
return nil, output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func wrapMarkdownDownloadError(err error) error {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
|
||||
func validateNonEmptyMarkdownSize(size int64) error {
|
||||
if size == 0 {
|
||||
return output.ErrValidation("%s", markdownEmptyContentError)
|
||||
@@ -225,24 +170,6 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (*http.Response, string, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
req.QueryParams = larkcore.QueryParams{
|
||||
"version": []string{strings.TrimSpace(version)},
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", wrapMarkdownDownloadError(err)
|
||||
}
|
||||
return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil
|
||||
}
|
||||
|
||||
func markdownDryRunFileField(spec markdownUploadSpec) string {
|
||||
if spec.FilePath != "" {
|
||||
return "@" + spec.FilePath
|
||||
@@ -252,13 +179,12 @@ func markdownDryRunFileField(spec markdownUploadSpec) string {
|
||||
|
||||
func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
target := spec.Target()
|
||||
|
||||
if !multipart {
|
||||
body := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
"file": markdownDryRunFileField(spec),
|
||||
}
|
||||
@@ -279,8 +205,8 @@ func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart boo
|
||||
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
@@ -315,7 +241,6 @@ func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart boo
|
||||
|
||||
func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
|
||||
fileName := strings.TrimSpace(spec.FileName)
|
||||
target := spec.Target()
|
||||
if fileName == "" && spec.FileSet {
|
||||
fileName = finalMarkdownFileName(spec)
|
||||
}
|
||||
@@ -342,8 +267,8 @@ func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart
|
||||
Desc("[2] Overwrite file contents with multipart/form-data upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.FileName,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
"file": markdownDryRunFileField(spec),
|
||||
"file_token": spec.FileToken,
|
||||
@@ -355,8 +280,8 @@ func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart
|
||||
Desc("[2] Initialize multipart overwrite upload").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": spec.FileName,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
"file_token": spec.FileToken,
|
||||
}).
|
||||
@@ -401,11 +326,10 @@ func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUpload
|
||||
}
|
||||
|
||||
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
target := spec.Target()
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", target.ParentType)
|
||||
fd.AddField("parent_node", target.ParentNode)
|
||||
fd.AddField("parent_type", "explorer")
|
||||
fd.AddField("parent_node", spec.FolderToken)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
if spec.FileToken != "" {
|
||||
fd.AddField("file_token", spec.FileToken)
|
||||
@@ -433,11 +357,10 @@ func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSp
|
||||
}
|
||||
|
||||
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
target := spec.Target()
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": target.ParentType,
|
||||
"parent_node": target.ParentNode,
|
||||
"parent_type": "explorer",
|
||||
"parent_node": spec.FolderToken,
|
||||
"size": fileSize,
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
|
||||
@@ -5,7 +5,6 @@ package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
@@ -17,25 +16,19 @@ var MarkdownCreate = common.Shortcut{
|
||||
Command: "+create",
|
||||
Description: "Create a Markdown file in Drive",
|
||||
Risk: "write",
|
||||
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
|
||||
Scopes: []string{"drive:file:upload"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "folder-token", Desc: "target Drive folder token (default: root folder; mutually exclusive with --wiki-token)"},
|
||||
{Name: "wiki-token", Desc: "target wiki node token (uploads under that wiki node; mutually exclusive with --folder-token)"},
|
||||
{Name: "folder-token", Desc: "target Drive folder token (default: root folder)"},
|
||||
{Name: "name", Desc: "file name with .md suffix; required with --content, optional with --file"},
|
||||
{Name: "content", Desc: "Markdown content", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "file", Desc: "local .md file path"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
|
||||
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
@@ -46,7 +39,6 @@ var MarkdownCreate = common.Shortcut{
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
@@ -56,25 +48,12 @@ var MarkdownCreate = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
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
|
||||
return markdownUploadDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
@@ -100,10 +79,8 @@ var MarkdownCreate = common.Shortcut{
|
||||
"file_name": finalMarkdownFileName(spec),
|
||||
"size_bytes": fileSize,
|
||||
}
|
||||
if u, metaErr := common.FetchDriveMetaURL(runtime, result.FileToken, "file"); metaErr == nil && strings.TrimSpace(u) != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "file", result.FileToken); 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
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
markdownDiffModeRemoteVsRemote = "remote_vs_remote"
|
||||
markdownDiffModeRemoteVsLocal = "remote_vs_local"
|
||||
markdownDiffMaxContentBytes = 10 * 1024 * 1024
|
||||
markdownDiffTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var markdownDiffVersionRe = regexp.MustCompile(`^\d{1,19}$`)
|
||||
|
||||
type markdownDiffSpec struct {
|
||||
FileToken string
|
||||
FromVersion string
|
||||
ToVersion string
|
||||
FilePath string
|
||||
ContextLines int
|
||||
Format string
|
||||
}
|
||||
|
||||
type markdownDiffHunk struct {
|
||||
Header string `json:"header"`
|
||||
OldStart int `json:"old_start"`
|
||||
OldLines int `json:"old_lines"`
|
||||
NewStart int `json:"new_start"`
|
||||
NewLines int `json:"new_lines"`
|
||||
}
|
||||
|
||||
type markdownDiffLineKind int
|
||||
|
||||
const (
|
||||
markdownDiffLineEqual markdownDiffLineKind = iota
|
||||
markdownDiffLineDelete
|
||||
markdownDiffLineInsert
|
||||
)
|
||||
|
||||
type markdownDiffLineOp struct {
|
||||
Kind markdownDiffLineKind
|
||||
Content string
|
||||
}
|
||||
|
||||
type markdownDiffHunkRange struct {
|
||||
Start int
|
||||
End int
|
||||
}
|
||||
|
||||
func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if spec.FromVersion != "" {
|
||||
if err := validateMarkdownDiffVersionValue(spec.FromVersion, "--from-version"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if spec.ToVersion != "" {
|
||||
if err := validateMarkdownDiffVersionValue(spec.ToVersion, "--to-version"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if spec.FilePath != "" {
|
||||
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
|
||||
return output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
if err := validateMarkdownFileName(spec.FilePath, "--file"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if spec.ContextLines < 0 {
|
||||
return output.ErrValidation("--context-lines must be >= 0")
|
||||
}
|
||||
if spec.Format != "" && spec.Format != "json" && spec.Format != "pretty" {
|
||||
return output.ErrValidation("markdown +diff only supports --format json or pretty")
|
||||
}
|
||||
if spec.FilePath == "" {
|
||||
if spec.FromVersion == "" && spec.ToVersion == "" {
|
||||
return common.FlagErrorf("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
|
||||
}
|
||||
if spec.FromVersion == "" && spec.ToVersion != "" {
|
||||
return common.FlagErrorf("--to-version requires --from-version")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if spec.ToVersion != "" {
|
||||
return common.FlagErrorf("--to-version is not supported together with --file")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMarkdownDiffVersionValue(value, flagName string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return output.ErrValidation("%s cannot be empty", flagName)
|
||||
}
|
||||
if !markdownDiffVersionRe.MatchString(value) {
|
||||
return output.ErrValidation("%s must be a numeric version string", flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markdownDiffMode(spec markdownDiffSpec) string {
|
||||
if spec.FilePath != "" {
|
||||
return markdownDiffModeRemoteVsLocal
|
||||
}
|
||||
return markdownDiffModeRemoteVsRemote
|
||||
}
|
||||
|
||||
func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI().Desc("Download the requested Markdown content, compute a unified diff locally, and print the result without modifying the remote file")
|
||||
switch markdownDiffMode(spec) {
|
||||
case markdownDiffModeRemoteVsLocal:
|
||||
if spec.FromVersion != "" {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the specified remote Markdown version").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.FromVersion})
|
||||
} else {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the latest remote Markdown version").
|
||||
Set("file_token", spec.FileToken)
|
||||
}
|
||||
dry.Set("local_file", spec.FilePath)
|
||||
dry.Set("mode", markdownDiffModeRemoteVsLocal)
|
||||
default:
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the base remote Markdown version").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.FromVersion})
|
||||
if spec.ToVersion != "" {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[2] Download the target remote Markdown version").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.ToVersion})
|
||||
} else {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[2] Download the latest remote Markdown version").
|
||||
Set("file_token", spec.FileToken)
|
||||
}
|
||||
dry.Set("mode", markdownDiffModeRemoteVsRemote)
|
||||
}
|
||||
dry.Set("context_lines", spec.ContextLines)
|
||||
return dry
|
||||
}
|
||||
|
||||
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (string, string, error) {
|
||||
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
payload, err := readMarkdownDiffPayload(resp.Body, "remote Markdown content")
|
||||
if err != nil {
|
||||
return "", "", wrapMarkdownDownloadError(err)
|
||||
}
|
||||
return fileName, string(payload), nil
|
||||
}
|
||||
|
||||
func readMarkdownLocalFile(runtime *common.RuntimeContext, filePath string) (string, error) {
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
payload, err := readMarkdownDiffPayload(f, "local Markdown file")
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", err
|
||||
}
|
||||
return "", output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
return string(payload), nil
|
||||
}
|
||||
|
||||
func readMarkdownDiffPayload(r io.Reader, source string) ([]byte, error) {
|
||||
payload, err := io.ReadAll(io.LimitReader(r, markdownDiffMaxContentBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload) > markdownDiffMaxContentBytes {
|
||||
return nil, output.ErrValidation("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func splitMarkdownDiffLines(text string) []string {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
lines := strings.SplitAfter(text, "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func markdownDiffLineOps(fromContent, toContent string) []markdownDiffLineOp {
|
||||
dmp := diffmatchpatch.New()
|
||||
dmp.DiffTimeout = markdownDiffTimeout
|
||||
before, after, lineArray := dmp.DiffLinesToRunes(fromContent, toContent)
|
||||
diffs := dmp.DiffMainRunes(before, after, false)
|
||||
// Keep the diff line-based. Running cleanup after hydrating real text
|
||||
// would re-split replacements into word-level edits.
|
||||
diffs = dmp.DiffCharsToLines(diffs, lineArray)
|
||||
|
||||
ops := make([]markdownDiffLineOp, 0, len(diffs))
|
||||
for _, diff := range diffs {
|
||||
lines := splitMarkdownDiffLines(diff.Text)
|
||||
for _, line := range lines {
|
||||
switch diff.Type {
|
||||
case diffmatchpatch.DiffDelete:
|
||||
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineDelete, Content: line})
|
||||
case diffmatchpatch.DiffInsert:
|
||||
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineInsert, Content: line})
|
||||
default:
|
||||
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineEqual, Content: line})
|
||||
}
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
func markdownDiffSummary(ops []markdownDiffLineOp) (bool, int, int) {
|
||||
added := 0
|
||||
deleted := 0
|
||||
changed := false
|
||||
for _, op := range ops {
|
||||
switch op.Kind {
|
||||
case markdownDiffLineDelete:
|
||||
changed = true
|
||||
deleted++
|
||||
case markdownDiffLineInsert:
|
||||
changed = true
|
||||
added++
|
||||
}
|
||||
}
|
||||
return changed, added, deleted
|
||||
}
|
||||
|
||||
func markdownDiffHunkRanges(ops []markdownDiffLineOp, contextLines int) []markdownDiffHunkRange {
|
||||
if len(ops) == 0 {
|
||||
return nil
|
||||
}
|
||||
changedLines := make([]int, 0)
|
||||
for i, op := range ops {
|
||||
if op.Kind != markdownDiffLineEqual {
|
||||
changedLines = append(changedLines, i)
|
||||
}
|
||||
}
|
||||
if len(changedLines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ranges := make([]markdownDiffHunkRange, 0, len(changedLines))
|
||||
current := markdownDiffHunkRange{
|
||||
Start: max(0, changedLines[0]-contextLines),
|
||||
End: min(len(ops), changedLines[0]+contextLines+1),
|
||||
}
|
||||
for _, idx := range changedLines[1:] {
|
||||
next := markdownDiffHunkRange{
|
||||
Start: max(0, idx-contextLines),
|
||||
End: min(len(ops), idx+contextLines+1),
|
||||
}
|
||||
if next.Start <= current.End {
|
||||
if next.End > current.End {
|
||||
current.End = next.End
|
||||
}
|
||||
continue
|
||||
}
|
||||
ranges = append(ranges, current)
|
||||
current = next
|
||||
}
|
||||
ranges = append(ranges, current)
|
||||
return ranges
|
||||
}
|
||||
|
||||
func markdownDiffHunkAt(ops []markdownDiffLineOp, r markdownDiffHunkRange) markdownDiffHunk {
|
||||
oldBefore := 0
|
||||
newBefore := 0
|
||||
for _, op := range ops[:r.Start] {
|
||||
if op.Kind != markdownDiffLineInsert {
|
||||
oldBefore++
|
||||
}
|
||||
if op.Kind != markdownDiffLineDelete {
|
||||
newBefore++
|
||||
}
|
||||
}
|
||||
|
||||
oldLines := 0
|
||||
newLines := 0
|
||||
for _, op := range ops[r.Start:r.End] {
|
||||
if op.Kind != markdownDiffLineInsert {
|
||||
oldLines++
|
||||
}
|
||||
if op.Kind != markdownDiffLineDelete {
|
||||
newLines++
|
||||
}
|
||||
}
|
||||
|
||||
oldStart := oldBefore + 1
|
||||
newStart := newBefore + 1
|
||||
if oldLines == 0 {
|
||||
oldStart = oldBefore
|
||||
}
|
||||
if newLines == 0 {
|
||||
newStart = newBefore
|
||||
}
|
||||
|
||||
return markdownDiffHunk{
|
||||
Header: fmt.Sprintf("@@ -%d,%d +%d,%d @@", oldStart, oldLines, newStart, newLines),
|
||||
OldStart: oldStart,
|
||||
OldLines: oldLines,
|
||||
NewStart: newStart,
|
||||
NewLines: newLines,
|
||||
}
|
||||
}
|
||||
|
||||
func buildMarkdownUnifiedDiff(fromLabel, toLabel string, ops []markdownDiffLineOp, ranges []markdownDiffHunkRange) string {
|
||||
if len(ranges) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "--- %s\n", fromLabel)
|
||||
fmt.Fprintf(&b, "+++ %s\n", toLabel)
|
||||
for _, r := range ranges {
|
||||
hunk := markdownDiffHunkAt(ops, r)
|
||||
b.WriteString(hunk.Header)
|
||||
b.WriteByte('\n')
|
||||
for _, op := range ops[r.Start:r.End] {
|
||||
prefix := ' '
|
||||
switch op.Kind {
|
||||
case markdownDiffLineDelete:
|
||||
prefix = '-'
|
||||
case markdownDiffLineInsert:
|
||||
prefix = '+'
|
||||
}
|
||||
b.WriteByte(byte(prefix))
|
||||
b.WriteString(op.Content)
|
||||
if !strings.HasSuffix(op.Content, "\n") {
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(`\ No newline at end of file`)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func summarizeMarkdownDiff(fromLabel, toLabel, fromContent, toContent string, contextLines int) (string, bool, int, int, []markdownDiffHunk) {
|
||||
ops := markdownDiffLineOps(fromContent, toContent)
|
||||
changed, added, deleted := markdownDiffSummary(ops)
|
||||
ranges := markdownDiffHunkRanges(ops, contextLines)
|
||||
hunks := make([]markdownDiffHunk, 0, len(ranges))
|
||||
for _, r := range ranges {
|
||||
hunks = append(hunks, markdownDiffHunkAt(ops, r))
|
||||
}
|
||||
return buildMarkdownUnifiedDiff(fromLabel, toLabel, ops, ranges), changed, added, deleted, hunks
|
||||
}
|
||||
|
||||
func colorizeUnifiedDiff(diffText string) string {
|
||||
if diffText == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.SplitAfter(diffText, "\n")
|
||||
var b strings.Builder
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimRight(line, "\n")
|
||||
suffix := ""
|
||||
if strings.HasSuffix(line, "\n") {
|
||||
suffix = "\n"
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "@@"):
|
||||
b.WriteString(output.Cyan)
|
||||
b.WriteString(trimmed)
|
||||
b.WriteString(output.Reset)
|
||||
case strings.HasPrefix(trimmed, "+++"), strings.HasPrefix(trimmed, "---"):
|
||||
b.WriteString(output.Bold)
|
||||
b.WriteString(trimmed)
|
||||
b.WriteString(output.Reset)
|
||||
case strings.HasPrefix(trimmed, "+") && !strings.HasPrefix(trimmed, "+++"):
|
||||
b.WriteString(output.Green)
|
||||
b.WriteString(trimmed)
|
||||
b.WriteString(output.Reset)
|
||||
case strings.HasPrefix(trimmed, "-") && !strings.HasPrefix(trimmed, "---"):
|
||||
b.WriteString(output.Red)
|
||||
b.WriteString(trimmed)
|
||||
b.WriteString(output.Reset)
|
||||
default:
|
||||
b.WriteString(trimmed)
|
||||
}
|
||||
b.WriteString(suffix)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownDiff(w io.Writer, data map[string]interface{}) {
|
||||
if !common.GetBool(data, "changed") {
|
||||
io.WriteString(w, "No differences.\n")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, colorizeUnifiedDiff(common.GetString(data, "diff")))
|
||||
}
|
||||
|
||||
var MarkdownDiff = common.Shortcut{
|
||||
Service: "markdown",
|
||||
Command: "+diff",
|
||||
Description: "Compare remote Markdown versions or compare remote Markdown against a local file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "target Markdown file token", Required: true},
|
||||
{Name: "from-version", Desc: "base remote version; when --to-version is omitted, compare this version to the latest remote version"},
|
||||
{Name: "to-version", Desc: "target remote version; requires --from-version"},
|
||||
{Name: "file", Desc: "local .md file path to compare against the remote content"},
|
||||
{Name: "context-lines", Desc: "number of unchanged context lines to include around each diff hunk", Type: "int", Default: "3"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMarkdownDiffSpec(runtime, markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
Format: runtime.Format,
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return markdownDiffDryRun(markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
}
|
||||
|
||||
var (
|
||||
fromLabel string
|
||||
toLabel string
|
||||
fromContent string
|
||||
toContent string
|
||||
err error
|
||||
)
|
||||
|
||||
switch markdownDiffMode(spec) {
|
||||
case markdownDiffModeRemoteVsLocal:
|
||||
fromLabel = "a/" + spec.FileToken
|
||||
if spec.FromVersion != "" {
|
||||
fromLabel += "@version:" + spec.FromVersion
|
||||
} else {
|
||||
fromLabel += "@latest"
|
||||
}
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
toLabel = "b/" + spec.FilePath
|
||||
toContent, err = readMarkdownLocalFile(runtime, spec.FilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
fromLabel = "a/" + spec.FileToken + "@version:" + spec.FromVersion
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.ToVersion != "" {
|
||||
toLabel = "b/" + spec.FileToken + "@version:" + spec.ToVersion
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion)
|
||||
} else {
|
||||
toLabel = "b/" + spec.FileToken + "@latest"
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
diffText, changed, addedLines, deletedLines, hunks := summarizeMarkdownDiff(fromLabel, toLabel, fromContent, toContent, spec.ContextLines)
|
||||
|
||||
out := map[string]interface{}{
|
||||
"changed": changed,
|
||||
"mode": markdownDiffMode(spec),
|
||||
"file_token": spec.FileToken,
|
||||
"from_version": spec.FromVersion,
|
||||
"to_version": spec.ToVersion,
|
||||
"from_label": fromLabel,
|
||||
"to_label": toLabel,
|
||||
"added_lines": addedLines,
|
||||
"deleted_lines": deletedLines,
|
||||
"context_lines": spec.ContextLines,
|
||||
"hunks": hunks,
|
||||
"diff": diffText,
|
||||
}
|
||||
if spec.FilePath != "" {
|
||||
out["local_file"] = spec.FilePath
|
||||
}
|
||||
|
||||
runtime.OutFormatRaw(out, nil, func(w io.Writer) {
|
||||
prettyPrintMarkdownDiff(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestMarkdownDiffRejectsUnsupportedFormat(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--format", "table",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only supports --format json or pretty") {
|
||||
t.Fatalf("expected format validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRejectsToVersionWithoutFromVersion(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--to-version", "7633658129540910628",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--to-version requires --from-version") {
|
||||
t.Fatalf("expected version validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\n- alpha\n- beta\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\n- alpha\n- beta updated\n- gamma\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--to-version", "7633658129540910628",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Changed bool `json:"changed"`
|
||||
Mode string `json:"mode"`
|
||||
FromVersion string `json:"from_version"`
|
||||
ToVersion string `json:"to_version"`
|
||||
AddedLines int `json:"added_lines"`
|
||||
DeletedLines int `json:"deleted_lines"`
|
||||
Diff string `json:"diff"`
|
||||
Hunks []markdownDiffHunk `json:"hunks"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json unmarshal error: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("expected ok=true, got false: %s", stdout.String())
|
||||
}
|
||||
if !env.Data.Changed {
|
||||
t.Fatalf("expected changed=true: %s", stdout.String())
|
||||
}
|
||||
if env.Data.Mode != markdownDiffModeRemoteVsRemote {
|
||||
t.Fatalf("mode = %q, want %q", env.Data.Mode, markdownDiffModeRemoteVsRemote)
|
||||
}
|
||||
if env.Data.FromVersion != "7633658129540910621" || env.Data.ToVersion != "7633658129540910628" {
|
||||
t.Fatalf("versions = %q -> %q", env.Data.FromVersion, env.Data.ToVersion)
|
||||
}
|
||||
if env.Data.AddedLines != 2 || env.Data.DeletedLines != 1 {
|
||||
t.Fatalf("added/deleted = %d/%d, want 2/1", env.Data.AddedLines, env.Data.DeletedLines)
|
||||
}
|
||||
if len(env.Data.Hunks) != 1 {
|
||||
t.Fatalf("len(hunks) = %d, want 1", len(env.Data.Hunks))
|
||||
}
|
||||
if !strings.Contains(env.Data.Diff, "@@") || !strings.Contains(env.Data.Diff, "+- gamma") {
|
||||
t.Fatalf("diff missing expected content: %s", env.Data.Diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRemoteVsLocalPretty(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\nhello old\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("local.md", []byte("# Title\n\nhello new\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--file", "./local.md",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "@@") {
|
||||
t.Fatalf("pretty output missing hunk header: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), output.Red+"-hello old"+output.Reset) {
|
||||
t.Fatalf("pretty output missing removed line color: %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), output.Green+"+hello new"+output.Reset) {
|
||||
t.Fatalf("pretty output missing added line color: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRejectsOversizedRemoteContent(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
Status: 200,
|
||||
RawBody: bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("local.md", []byte("# Title\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--file", "./local.md",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "remote Markdown content exceeds 10.0 MB markdown +diff content limit") {
|
||||
t.Fatalf("expected remote content size error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("local.md", bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--file", "./local.md",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "local Markdown file exceeds 10.0 MB markdown +diff content limit") {
|
||||
t.Fatalf("expected local content size error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDownloadErrorPreservesStructuredErrors(t *testing.T) {
|
||||
apiErr := output.ErrAPI(99991663, "permission denied", map[string]interface{}{"permission": "drive:file:download"})
|
||||
if got := wrapMarkdownDownloadError(apiErr); got != apiErr {
|
||||
t.Fatalf("wrapMarkdownDownloadError() = %v, want original API error", got)
|
||||
}
|
||||
|
||||
got := wrapMarkdownDownloadError(errors.New("dial tcp timeout"))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("wrapMarkdownDownloadError() = %T, want *output.ExitError", got)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if !strings.Contains(got.Error(), "download failed: dial tcp timeout") {
|
||||
t.Fatalf("wrapped error = %q", got.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffIncludesNoNewlineMarker(t *testing.T) {
|
||||
diffText, changed, added, deleted, hunks := summarizeMarkdownDiff(
|
||||
"a/test.md",
|
||||
"b/test.md",
|
||||
"# Title\n\nhello old",
|
||||
"# Title\n\nhello new",
|
||||
3,
|
||||
)
|
||||
if !changed {
|
||||
t.Fatalf("expected changed=true")
|
||||
}
|
||||
if added != 1 || deleted != 1 {
|
||||
t.Fatalf("added/deleted = %d/%d, want 1/1", added, deleted)
|
||||
}
|
||||
if len(hunks) != 1 {
|
||||
t.Fatalf("len(hunks) = %d, want 1", len(hunks))
|
||||
}
|
||||
if strings.Count(diffText, "\\ No newline at end of file") != 2 {
|
||||
t.Fatalf("diff should contain two no-newline markers: %q", diffText)
|
||||
}
|
||||
if !strings.Contains(diffText, "-hello old\n\\ No newline at end of file\n+hello new\n\\ No newline at end of file\n") {
|
||||
t.Fatalf("diff missing expected no-newline marker sequence: %q", diffText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("line1\nline2\nline3\nline4\nline5\nline6\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
|
||||
Status: 200,
|
||||
RawBody: []byte("line1\nline2 changed\nline3\nline4\nline5 changed\nline6\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--to-version", "7633658129540910628",
|
||||
"--context-lines", "0",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Changed bool `json:"changed"`
|
||||
AddedLines int `json:"added_lines"`
|
||||
DeletedLines int `json:"deleted_lines"`
|
||||
Hunks []markdownDiffHunk `json:"hunks"`
|
||||
Diff string `json:"diff"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json unmarshal error: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !env.OK || !env.Data.Changed {
|
||||
t.Fatalf("expected changed=true: %s", stdout.String())
|
||||
}
|
||||
if env.Data.AddedLines != 2 || env.Data.DeletedLines != 2 {
|
||||
t.Fatalf("added/deleted = %d/%d, want 2/2", env.Data.AddedLines, env.Data.DeletedLines)
|
||||
}
|
||||
if len(env.Data.Hunks) != 2 {
|
||||
t.Fatalf("len(hunks) = %d, want 2", len(env.Data.Hunks))
|
||||
}
|
||||
if !strings.Contains(env.Data.Diff, "-line2") || !strings.Contains(env.Data.Diff, "+line5 changed") {
|
||||
t.Fatalf("diff missing expected content: %s", env.Data.Diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffNoChangesPretty(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "No differences." {
|
||||
t.Fatalf("pretty no-change output = %q, want %q", got, "No differences.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffDryRunRemoteVsLocal(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
localPath := filepath.Join(".", "local.md")
|
||||
if err := os.WriteFile(localPath, []byte("# local\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--file", localPath,
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/:file_token/download") && !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/box_md_diff/download") {
|
||||
t.Fatalf("dry-run missing download call: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"local_file": "local.md"`) && !strings.Contains(stdout.String(), `"local_file": "./local.md"`) {
|
||||
t.Fatalf("dry-run missing local file metadata: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,6 @@ 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"}
|
||||
@@ -189,7 +182,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Shortcuts()
|
||||
want := []string{"+create", "+diff", "+fetch", "+patch", "+overwrite"}
|
||||
want := []string{"+create", "+fetch", "+patch", "+overwrite"}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want))
|
||||
@@ -276,27 +269,6 @@ func TestMarkdownCreateValidationBranches(t *testing.T) {
|
||||
},
|
||||
want: "--folder-token cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "wiki token cannot be empty",
|
||||
args: []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--wiki-token=",
|
||||
},
|
||||
want: "--wiki-token cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "folder and wiki tokens are mutually exclusive",
|
||||
args: []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--folder-token", "fld_target",
|
||||
"--wiki-token", "wikcn_target",
|
||||
},
|
||||
want: "--folder-token and --wiki-token are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "folder token must be valid",
|
||||
args: []string{
|
||||
@@ -307,16 +279,6 @@ func TestMarkdownCreateValidationBranches(t *testing.T) {
|
||||
},
|
||||
want: "--folder-token",
|
||||
},
|
||||
{
|
||||
name: "wiki token must be valid",
|
||||
args: []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--wiki-token", "../bad",
|
||||
},
|
||||
want: "--wiki-token",
|
||||
},
|
||||
{
|
||||
name: "content mode still validates markdown file name",
|
||||
args: []string{
|
||||
@@ -410,40 +372,11 @@ func TestMarkdownCreateDryRunWithInlineContent(t *testing.T) {
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/files/upload_all") {
|
||||
t.Fatalf("dry-run missing upload_all: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/metas/batch_query") || !strings.Contains(out, `"with_url": true`) {
|
||||
t.Fatalf("dry-run missing metadata URL lookup: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "markdown content") {
|
||||
t.Fatalf("dry-run missing content marker: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--wiki-token", "wikcn_markdown_dryrun_target",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"parent_type": "wiki"`) {
|
||||
t.Fatalf("dry-run missing wiki parent_type: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"parent_node": "wikcn_markdown_dryrun_target"`) {
|
||||
t.Fatalf("dry-run missing wiki parent_node: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/metas/batch_query") || !strings.Contains(out, `"with_url": true`) {
|
||||
t.Fatalf("dry-run missing metadata URL lookup: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
@@ -483,9 +416,6 @@ 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)
|
||||
}
|
||||
@@ -505,18 +435,6 @@ 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",
|
||||
@@ -549,60 +467,11 @@ 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://tenant.example.com/file/box_md_create"`) {
|
||||
if !strings.Contains(stdout.String(), `"url": "https://www.feishu.cn/file/box_md_create"`) {
|
||||
t.Fatalf("stdout missing url: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateSuccessUploadAllToWikiReturnsMetaURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_create_wiki",
|
||||
"version": "1002",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_create_wiki", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_create_wiki"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello\n",
|
||||
"--wiki-token", "wikcn_markdown_create_target",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["parent_type"]; got != markdownUploadParentTypeWiki {
|
||||
t.Fatalf("parent_type = %q, want %q", got, markdownUploadParentTypeWiki)
|
||||
}
|
||||
if got := body.Fields["parent_node"]; got != "wikcn_markdown_create_target" {
|
||||
t.Fatalf("parent_node = %q, want %q", got, "wikcn_markdown_create_target")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_create_wiki"`) {
|
||||
t.Fatalf("stdout missing metadata url for wiki-hosted markdown file: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -615,18 +484,6 @@ 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",
|
||||
@@ -643,7 +500,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://tenant.example.com/file/box_md_create_pretty") {
|
||||
if !strings.Contains(out, "url: https://www.feishu.cn/file/box_md_create_pretty") {
|
||||
t.Fatalf("pretty output missing url: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "permission_grant.status: skipped") {
|
||||
@@ -654,114 +511,6 @@ 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())
|
||||
|
||||
@@ -809,18 +558,6 @@ 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)
|
||||
@@ -849,96 +586,6 @@ func TestMarkdownCreateMultipartUploadSuccess(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), `"file_token": "box_md_multipart"`) {
|
||||
t.Fatalf("stdout missing multipart file_token: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_multipart"`) {
|
||||
t.Fatalf("stdout missing multipart metadata url: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateMultipartUploadToWikiUsesWikiParent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_markdown_wiki_ok",
|
||||
"block_size": float64(markdownSinglePartSizeLimit),
|
||||
"block_num": float64(2),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(prepareStub)
|
||||
uploadPartStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_part",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadPartStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_multipart_wiki",
|
||||
"version": "1005",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_multipart_wiki", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_multipart_wiki"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withMarkdownWorkingDir(t, tmpDir)
|
||||
fh, err := os.Create("large.md")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(markdownSinglePartSizeLimit + 1); err != nil {
|
||||
fh.Close()
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close() error: %v", err)
|
||||
}
|
||||
|
||||
err = mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--file", "large.md",
|
||||
"--wiki-token", "wikcn_markdown_multipart_target",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(prepareStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode upload_prepare body: %v\nraw=%s", err, string(prepareStub.CapturedBody))
|
||||
}
|
||||
if got := body["parent_type"]; got != markdownUploadParentTypeWiki {
|
||||
t.Fatalf("parent_type = %#v, want %q", got, markdownUploadParentTypeWiki)
|
||||
}
|
||||
if got := body["parent_node"]; got != "wikcn_markdown_multipart_target" {
|
||||
t.Fatalf("parent_node = %#v, want %q", got, "wikcn_markdown_multipart_target")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_multipart_wiki"`) {
|
||||
t.Fatalf("stdout missing metadata url for wiki-hosted multipart markdown file: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateFailsWhenMultipartPlanIsTooSmall(t *testing.T) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MarkdownCreate,
|
||||
MarkdownDiff,
|
||||
MarkdownFetch,
|
||||
MarkdownPatch,
|
||||
MarkdownOverwrite,
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"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"
|
||||
@@ -36,7 +35,6 @@ import (
|
||||
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()...)
|
||||
|
||||
@@ -16,7 +16,6 @@ func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) {
|
||||
|
||||
for _, path := range [][]string{
|
||||
{"markdown", "+create"},
|
||||
{"markdown", "+diff"},
|
||||
{"markdown", "+fetch"},
|
||||
{"markdown", "+overwrite"},
|
||||
} {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user