mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
9 Commits
v1.0.22
...
sun/doubao
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73ea222ba7 | ||
|
|
d8e08736f1 | ||
|
|
138a2ef785 | ||
|
|
0cb6cdf818 | ||
|
|
5d9b3d305f | ||
|
|
9229c50fcf | ||
|
|
d25f79bb64 | ||
|
|
4d84994ce6 | ||
|
|
6b56e0fdde |
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -45,15 +45,6 @@ jobs:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
gh release download "${TAG}" --pattern checksums.txt --dir .
|
||||
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -37,5 +37,3 @@ tests/mail/reports/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
|
||||
@@ -54,12 +54,6 @@ linters:
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
# The shortcuts-no-raw-http forbidigo rule below is shortcuts-only;
|
||||
# internal/ legitimately wraps raw HTTP for the client / credential layer.
|
||||
- path-except: shortcuts/
|
||||
text: shortcuts-no-raw-http
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
@@ -76,18 +70,16 @@ linters:
|
||||
desc: >-
|
||||
shortcuts must not import internal/vfs/localfileio directly.
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
shortcuts-no-raw-http:
|
||||
files:
|
||||
- "**/shortcuts/**"
|
||||
deny:
|
||||
- pkg: "net/http"
|
||||
desc: >-
|
||||
use RuntimeContext.DoAPI/CallAPI/DoAPIJSON instead of raw net/http.
|
||||
The client layer handles auth, headers, and error normalization.
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── http: shortcuts must not construct raw HTTP requests ──
|
||||
# Bans request / client construction; constants (http.MethodPost,
|
||||
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
||||
# intentionally allowed since they don't bypass the runtime layer.
|
||||
- pattern: http\.(Client|NewRequest|NewRequestWithContext|Get|Post|PostForm|Head|DefaultClient|DefaultTransport|RoundTripper|Do|Serve|ListenAndServe)\b
|
||||
msg: >-
|
||||
[shortcuts-no-raw-http] use RuntimeContext.DoAPI/CallAPI/DoAPIJSON
|
||||
instead of constructing raw HTTP. The runtime handles auth, headers,
|
||||
and error normalization. (Constants and helpers like http.MethodPost,
|
||||
http.StatusOK, http.StatusText remain allowed.)
|
||||
# ── os: already wrapped in internal/vfs ──
|
||||
- pattern: os\.(Stat|Lstat|Open|OpenFile|Rename|ReadFile|WriteFile|Getwd|UserHomeDir|ReadDir)\b
|
||||
msg: "use the corresponding vfs.Xxx() from internal/vfs"
|
||||
|
||||
164
CHANGELOG.md
164
CHANGELOG.md
@@ -2,162 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.22] - 2026-04-29
|
||||
|
||||
### Features
|
||||
|
||||
- **task**: Add resource agent & `agent_task_step_info` (#693)
|
||||
- **task**: Support app task members by id (#712)
|
||||
- **contact**: Add `--queries` multi-name fanout to `+search-user` (#707)
|
||||
- **slides**: Add slide templates with template-first skill guidance (#684)
|
||||
- **mail**: Support calendar events in emails (#646)
|
||||
- **install**: Honor `npm_config_registry` for binary URL resolution with npmmirror fallback (#690)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **install**: Make Windows zip extraction resilient (#713)
|
||||
- **config/init**: Respect `--brand` flag in `--new` mode (#711)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Clarify base search routing (#708)
|
||||
- **base**: Align base skills and view config contracts (#653)
|
||||
|
||||
## [v1.0.21] - 2026-04-28
|
||||
|
||||
### Features
|
||||
|
||||
- **contact**: Add search filters and richer profile fields to `+search-user` (#648)
|
||||
- **common**: Backfill resource URL when create APIs omit it (#680)
|
||||
- **risk**: Add risk tiering for command sensitivity classification (#633)
|
||||
- **okr**: Add progress records support (#574)
|
||||
- **calendar**: Enhance event search and meeting room finding (#679)
|
||||
- **event**: Add event subscription & consume system (#654)
|
||||
- **drive**: Extend `+add-comment` to support slides targets (#674)
|
||||
- **slides**: Add font management for slides (#681)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cmdutil**: Default flag completions to disabled (#688)
|
||||
- **e2e/wiki**: Pass `obj_type` when deleting wiki nodes in cleanup (#687)
|
||||
- **readme**: Fix readme statistics (#691)
|
||||
|
||||
## [v1.0.20] - 2026-04-27
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Add `+search` shortcut with flat filter flags (#658)
|
||||
- **mail**: Support sharing emails to IM chats via `+share-to-chat` (#637)
|
||||
- **calendar**: Add `+update` shortcut (#678)
|
||||
- **im**: Add `--at-chatter-ids` filter to `+messages-search` (#612)
|
||||
- **pagination**: Preserve pagination state on truncation and natural end (#659)
|
||||
- **lark-im**: Add `chat.members.bots` to skill docs (#616)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **strict-mode**: Reject explicit `--as` instead of silently overriding it (#673)
|
||||
- **whiteboard**: Manual disable edge case for svg compatibility (#661)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **lark-drive**: Add missing import command examples (#669)
|
||||
- **readme**: Add Project (Meegle) to Features table (#660)
|
||||
|
||||
## [v1.0.19] - 2026-04-24
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Add read receipt support — `--request-receipt` on compose, `+send-receipt` / `+decline-receipt` for response
|
||||
- **doc**: Add v2 API for `docs +create` / `+fetch` / `+update` (#638)
|
||||
- **im**: Request thread roots for chat message list (#635)
|
||||
- **drive**: Support wiki node targets in `+upload` (#611)
|
||||
- **config**: Block `auth` / `config` when external credential provider is active (#627)
|
||||
- **whiteboard**: Pin `whiteboard-cli` to `v0.2.10` in `lark-whiteboard` skill (#649)
|
||||
|
||||
## [v1.0.18] - 2026-04-23
|
||||
|
||||
### Features
|
||||
|
||||
- **base**: Support `.base` import and export for bitable (#599)
|
||||
- **config**: Add `config bind` for per-Agent credential isolation (#515)
|
||||
- **slides**: Add `+replace-slide` shortcut for block-level XML edits (#516)
|
||||
- **wiki**: Add `+delete-space` shortcut with async task polling (#610)
|
||||
- **doc**: Add `--from-clipboard` flag to `docs +media-insert` (#508)
|
||||
- **minutes**: Unify minute artifacts output to `./minutes/{minute_token}/` (#604)
|
||||
- Add configurable content-safety scanning (#606)
|
||||
- **install**: Add SHA-256 checksum verification to `install.js` (#592)
|
||||
- **whiteboard**: Pin `whiteboard-cli` to `^0.2.9` (#617)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Escape angle brackets in comment text (#632)
|
||||
- **im**: Unify `messages-search` pagination int flags (#446)
|
||||
- **im**: Fix markdown URL rendering issues in post content (#206)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Refine record cell value guidance (#636)
|
||||
|
||||
## [v1.0.17] - 2026-04-22
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Use `Content-Disposition` filename when downloading message resources (#536)
|
||||
- **drive**: Add `+apply-permission` to request doc access (#588)
|
||||
- Support record share link (#466)
|
||||
- **whiteboard**: Add image support to `whiteboard-cli` skill (#553)
|
||||
- **cmdutil**: Add `X-Cli-Build` header for CLI build classification (#596)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Add default-table follow-up hint to `base-create` (#600)
|
||||
- Skip flag-completion registration outside completion path (#598)
|
||||
- Add `record-share-link-create` in `SKILL.md` (#597)
|
||||
- **mail**: Remove leftover conflict marker in skill docs (#594)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Clarify that comment listing defaults to unresolved comments only (#609)
|
||||
- **doc**: Fix `--markdown` examples that teach literal `\n` (#602)
|
||||
- **mail**: Remove `get_signatures` from skill reference, exposed via `+signature` instead (#545)
|
||||
|
||||
## [v1.0.16] - 2026-04-21
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Support large email attachments (#537)
|
||||
- **mail**: Add draft preview URL to draft operations (#438)
|
||||
- **doc**: Add pre-write semantic warnings to `docs +update` (#569)
|
||||
- **doc**: Add `--selection-with-ellipsis` position flag to `+media-insert` (#335)
|
||||
- **calendar**: Support event share link and error details (#583)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Preserve round-trip formatting in `+fetch` output (#469)
|
||||
- **docs**: Validate `--selection-by-title` format early (#256)
|
||||
- **whiteboard**: Register `+media-upload` shortcut and add whiteboard parent type
|
||||
|
||||
### Refactor
|
||||
|
||||
- Split `Execute` into `Build` + `Execute` with explicit IO and keychain injection (#371)
|
||||
- **auth**: Simplify scope reporting in login flow (#582)
|
||||
|
||||
## [v1.0.15] - 2026-04-20
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add float image shortcuts (#494)
|
||||
- **approval**: Document `remind` and `initiated` methods in skill (#554)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Preserve attachment metadata on base uploads (#563)
|
||||
- **base**: Fix role view and record default permission on edit (#530)
|
||||
- **sheets**: Normalize single-cell range in `+set-style` and `+batch-set-style` (#548)
|
||||
- **im**: Cap `basic_batch` user_ids at 10 per API limit (#551)
|
||||
- **install**: Refine install wizard messages (#529)
|
||||
- **whiteboard**: Deprecate old `lark-whiteboard-cli` skill (#547)
|
||||
|
||||
## [v1.0.14] - 2026-04-17
|
||||
|
||||
### Features
|
||||
@@ -560,14 +404,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.22]: https://github.com/larksuite/cli/releases/tag/v1.0.22
|
||||
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21
|
||||
[v1.0.20]: https://github.com/larksuite/cli/releases/tag/v1.0.20
|
||||
[v1.0.19]: https://github.com/larksuite/cli/releases/tag/v1.0.19
|
||||
[v1.0.18]: https://github.com/larksuite/cli/releases/tag/v1.0.18
|
||||
[v1.0.17]: https://github.com/larksuite/cli/releases/tag/v1.0.17
|
||||
[v1.0.16]: https://github.com/larksuite/cli/releases/tag/v1.0.16
|
||||
[v1.0.15]: https://github.com/larksuite/cli/releases/tag/v1.0.15
|
||||
[v1.0.14]: https://github.com/larksuite/cli/releases/tag/v1.0.14
|
||||
[v1.0.13]: https://github.com/larksuite/cli/releases/tag/v1.0.13
|
||||
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12
|
||||
|
||||
12
README.md
12
README.md
@@ -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, and more, with 200+ commands and 23 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, and more, with 200+ commands and 22 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** — 23 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 16 business domains, 200+ curated commands, 23 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 22 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 14 business domains, 200+ curated commands, 22 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
|
||||
@@ -38,8 +38,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| 🕐 Attendance | Query personal attendance check-in records |
|
||||
| ✍️ 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) |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
@@ -156,7 +155,6 @@ lark-cli auth status
|
||||
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
|
||||
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
|
||||
| `lark-okr` | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -203,7 +201,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
lark-cli docs +create --doc-format markdown --content "<title>Weekly Report</title>\n# Progress\n- Completed feature X"
|
||||
```
|
||||
|
||||
Run `lark-cli <service> --help` to see all shortcut commands.
|
||||
|
||||
12
README.zh.md
12
README.zh.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 23 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 22 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — 23 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 16 大业务域、200+ 精选命令、23 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 22 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 14 大业务域、200+ 精选命令、22 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -38,8 +38,7 @@
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐和指标 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -157,7 +156,6 @@ lark-cli auth status
|
||||
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
| `lark-okr` | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
|
||||
## 认证
|
||||
|
||||
@@ -204,7 +202,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
lark-cli docs +create --doc-format markdown --content "<title>周报</title>\n# 本周进展\n- 完成了 X 功能"
|
||||
```
|
||||
|
||||
运行 `lark-cli <service> --help` 查看所有快捷命令。
|
||||
|
||||
@@ -57,10 +57,6 @@ func normalisePath(raw string) string {
|
||||
|
||||
// NewCmdApi creates the api command. If runF is non-nil it is called instead of apiRun (test hook).
|
||||
func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
||||
return NewCmdApiWithContext(context.Background(), f, runF)
|
||||
}
|
||||
|
||||
func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
||||
opts := &APIOptions{Factory: f}
|
||||
var asStr string
|
||||
|
||||
@@ -83,7 +79,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
||||
cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)")
|
||||
@@ -100,6 +96,9 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
}
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
@@ -239,13 +238,12 @@ func apiRun(opts *APIOptions) error {
|
||||
return output.MarkRaw(client.WrapDoAPIError(err))
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
})
|
||||
// MarkRaw tells root error handler to skip enrichPermissionError,
|
||||
// preserving the original API error detail (log_id, troubleshooter, etc.).
|
||||
|
||||
@@ -180,24 +180,6 @@ func TestApiValidArgsFunction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
}
|
||||
if !flag.Hidden {
|
||||
t.Fatal("expected --as flag to be hidden in strict mode")
|
||||
}
|
||||
if got := flag.DefValue; got != "bot" {
|
||||
t.Fatalf("default value = %q, want %q", got, "bot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -24,16 +24,6 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "OAuth credentials and authorization management",
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first
|
||||
// PersistentPreRun[E] found walking up the chain, so the root-level
|
||||
// SilenceUsage=true would be skipped without this line.
|
||||
cmd.SilenceUsage = true
|
||||
// cmd.Name() returns the subcommand name (e.g. "login"), not "auth".
|
||||
// Pass "auth" as a literal so the error message reads
|
||||
// `"auth" is not supported: ...`
|
||||
return f.RequireBuiltinCredentialProvider(cmd.Context(), "auth")
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
|
||||
@@ -5,19 +5,15 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
@@ -307,72 +303,3 @@ func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credenti
|
||||
return &credential.TokenResult{Token: "unexpected-token"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// stubExternalProvider is a minimal extcred.Provider that always reports an account,
|
||||
// simulating env/sidecar mode for guard tests.
|
||||
type stubExternalProvider struct{ name string }
|
||||
|
||||
func (s *stubExternalProvider) Name() string { return s.name }
|
||||
func (s *stubExternalProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
|
||||
return &extcred.Account{AppID: "test-app"}, nil
|
||||
}
|
||||
func (s *stubExternalProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// newFactoryWithExternalProvider creates a Factory whose Credential uses a stub
|
||||
// extension provider, simulating env/sidecar credential mode.
|
||||
func newFactoryWithExternalProvider(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
stub := &stubExternalProvider{name: "env"}
|
||||
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
return f
|
||||
}
|
||||
|
||||
func TestAuthBlockedByExternalProvider(t *testing.T) {
|
||||
f := newFactoryWithExternalProvider(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"login", []string{"login"}},
|
||||
{"logout", []string{"logout"}},
|
||||
{"status", []string{"status"}},
|
||||
{"check", []string{"check", "--scope", "calendar:read"}}, // --scope is required
|
||||
{"list", []string{"list"}},
|
||||
{"scopes", []string{"scopes"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := NewCmdAuth(f)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tt.args)
|
||||
|
||||
// Locate the subcommand before execution (PersistentPreRunE receives it as cmd).
|
||||
matched, _, _ := cmd.Find(tt.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
// PersistentPreRunE sets SilenceUsage on the matched subcommand, not the parent.
|
||||
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
||||
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
|
||||
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ type loginMsg struct {
|
||||
ScopeHint string
|
||||
RequestedScopes string
|
||||
NewlyGrantedScopes string
|
||||
MissingScopes string
|
||||
NoScopes string
|
||||
StatusHint string
|
||||
|
||||
@@ -58,13 +59,14 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
||||
AuthSuccess: "授权已完成,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
ScopeMismatch: "授权结果异常: 以下请求 scopes 未被授予: %s",
|
||||
ScopeMismatch: "授权结果异常:以下请求 scopes 未被授予: %s",
|
||||
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
RequestedScopes: " 本次请求 scopes: %s\n",
|
||||
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
|
||||
MissingScopes: " 本次未授予 scopes: %s\n",
|
||||
NoScopes: "(空)",
|
||||
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
|
||||
@@ -93,13 +95,14 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
||||
AuthSuccess: "Authorization completed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s",
|
||||
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
RequestedScopes: " Requested scopes: %s\n",
|
||||
NewlyGrantedScopes: " Newly granted scopes: %s\n",
|
||||
MissingScopes: " Not granted scopes: %s\n",
|
||||
NoScopes: "(none)",
|
||||
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ func emptyIfNil(s []string) []string {
|
||||
return s
|
||||
}
|
||||
|
||||
// writeLoginScopeBreakdown renders the requested/newly granted scope
|
||||
// writeLoginScopeBreakdown renders the requested/newly granted/missing scope
|
||||
// breakdown to stderr.
|
||||
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
|
||||
if summary == nil {
|
||||
@@ -136,6 +136,7 @@ func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary
|
||||
}
|
||||
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
|
||||
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
|
||||
fmt.Fprintf(errOut.ErrOut, msg.MissingScopes, formatScopeList(summary.Missing, msg.NoScopes))
|
||||
}
|
||||
|
||||
// writeLoginSuccess emits the successful login payload in either JSON or text
|
||||
|
||||
@@ -363,7 +363,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
|
||||
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
|
||||
Message: "授权结果异常: 以下请求 scopes 未被授予: im:message:send",
|
||||
Message: "授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
Hint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
@@ -376,10 +376,11 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
|
||||
"授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
@@ -394,9 +395,6 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
if strings.Contains(got, "授权成功") {
|
||||
t.Fatalf("stderr should not contain success wording, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "本次未授予 scopes:") {
|
||||
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
@@ -474,10 +472,10 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
"授权成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"本次未授予 scopes: (空)",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"本次未授予 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"已有 scopes:",
|
||||
},
|
||||
@@ -492,10 +490,10 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
expectedPresent: []string{
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: (空)",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"本次未授予 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"已有 scopes:",
|
||||
},
|
||||
@@ -510,9 +508,9 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
expectedPresent: []string{
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"本次未授予 scopes:",
|
||||
"已有 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
@@ -621,9 +619,10 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
|
||||
"授权结果异常:以下请求 scopes 未被授予: im:message:send",
|
||||
"当前授权账号: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
@@ -638,9 +637,6 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
if strings.Contains(got, "OK: 授权成功") {
|
||||
t.Fatalf("stderr should not contain success prefix when scopes are missing, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "本次未授予 scopes:") {
|
||||
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
}
|
||||
@@ -781,15 +777,13 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
|
||||
"Authorization successful! User: tester (ou_user)",
|
||||
"Requested scopes: im:message:send",
|
||||
"Newly granted scopes: im:message:send",
|
||||
"Not granted scopes: (none)",
|
||||
"Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "Not granted scopes:") {
|
||||
t.Fatalf("stderr should not contain not granted scopes, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
||||
|
||||
57
cmd/build.go
57
cmd/build.go
@@ -6,18 +6,19 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/doctor"
|
||||
cmdevent "github.com/larksuite/cli/cmd/event"
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
@@ -31,14 +32,16 @@ type BuildOption func(*buildConfig)
|
||||
type buildConfig struct {
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
globals GlobalOptions
|
||||
}
|
||||
|
||||
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
|
||||
// Terminal detection is delegated to cmdutil.NewIOStreams.
|
||||
// WithIO sets the IO streams for the CLI. If not provided, os.Stdin/Stdout/Stderr are used.
|
||||
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.streams = cmdutil.NewIOStreams(in, out, errOut)
|
||||
isTerminal := false
|
||||
if f, ok := in.(*os.File); ok {
|
||||
isTerminal = term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
c.streams = &cmdutil.IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,16 +52,6 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||
// When hide is true the flag stays registered (so existing invocations still
|
||||
// parse) but is omitted from help and shell completion. Typically called as
|
||||
// HideProfile(isSingleAppMode()).
|
||||
func HideProfile(hide bool) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.globals.HideProfile = hide
|
||||
}
|
||||
}
|
||||
|
||||
// Build constructs the full command tree without executing.
|
||||
// Returns only the cobra.Command; Factory is internal.
|
||||
// Use Execute for the standard production entry point.
|
||||
@@ -67,30 +60,21 @@ func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOpti
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// buildInternal is a pure assembly function: it wires the command tree from
|
||||
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
|
||||
// env) belongs in the caller and must be threaded in via BuildOption.
|
||||
// buildInternal is the internal constructor that also returns Factory for error handling.
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
|
||||
// cfg.globals.Profile is left zero here; it's bound to the --profile
|
||||
// flag in RegisterGlobalFlags and filled by cobra's parse step.
|
||||
cfg := &buildConfig{}
|
||||
for _, o := range opts {
|
||||
if o != nil {
|
||||
o(cfg)
|
||||
}
|
||||
cfg := &buildConfig{
|
||||
streams: cmdutil.SystemIO(),
|
||||
}
|
||||
// Default streams when WithIO is not supplied so the root command's
|
||||
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
|
||||
// partial streams internally; keep both in sync so cfg.streams reflects
|
||||
// the same values the Factory ends up using.
|
||||
if cfg.streams == nil {
|
||||
cfg.streams = cmdutil.SystemIO()
|
||||
for _, o := range opts {
|
||||
o(cfg)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(cfg.streams, inv)
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
}
|
||||
|
||||
globals := &GlobalOptions{Profile: inv.Profile}
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
@@ -106,7 +90,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
@@ -115,13 +99,12 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
|
||||
// Prune commands incompatible with strict mode.
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// noopKeychain is a zero-side-effect KeychainAccess for exercising
|
||||
// WithKeychain without touching the platform keychain.
|
||||
type noopKeychain struct{}
|
||||
|
||||
func (noopKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||
func (noopKeychain) Set(service, account, value string) error { return nil }
|
||||
func (noopKeychain) Remove(service, account string) error { return nil }
|
||||
|
||||
// TestBuild_ExternalAPI asserts the library surface that external consumers
|
||||
// (e.g. cli-server) depend on: Build composes a root command from an
|
||||
// InvocationContext plus BuildOptions (WithIO, WithKeychain, HideProfile),
|
||||
// and SetDefaultFS swaps the global VFS. This test is the contract guard.
|
||||
func TestBuild_ExternalAPI(t *testing.T) {
|
||||
// Exercise SetDefaultFS both directions. Passing nil restores the OS FS.
|
||||
SetDefaultFS(vfs.OsFs{})
|
||||
SetDefaultFS(nil)
|
||||
|
||||
var in, out, errOut bytes.Buffer
|
||||
rootCmd := Build(
|
||||
context.Background(),
|
||||
cmdutil.InvocationContext{},
|
||||
WithIO(&in, &out, &errOut),
|
||||
WithKeychain(noopKeychain{}),
|
||||
HideProfile(true),
|
||||
)
|
||||
|
||||
if rootCmd == nil {
|
||||
t.Fatal("Build returned nil root command")
|
||||
}
|
||||
if rootCmd.Use != "lark-cli" {
|
||||
t.Errorf("rootCmd.Use = %q, want %q", rootCmd.Use, "lark-cli")
|
||||
}
|
||||
if len(rootCmd.Commands()) == 0 {
|
||||
t.Error("Build produced a root command with no subcommands")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuild_NoOptions guards against regression of the nil-streams panic:
|
||||
// calling Build without WithIO must fall back to SystemIO rather than
|
||||
// deref nil at rootCmd.SetIn/Out/Err.
|
||||
func TestBuild_NoOptions(t *testing.T) {
|
||||
rootCmd := Build(context.Background(), cmdutil.InvocationContext{})
|
||||
if rootCmd == nil {
|
||||
t.Fatal("Build returned nil root command")
|
||||
}
|
||||
if rootCmd.Use != "lark-cli" {
|
||||
t.Errorf("rootCmd.Use = %q, want %q", rootCmd.Use, "lark-cli")
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// TestBuild_DefaultNoCompletionLeak verifies that, without any call to
|
||||
// SetFlagCompletionsEnabled, repeated cmd.Build invocations do not leak
|
||||
// *cobra.Command instances into cobra's package-global flag-completion map.
|
||||
//
|
||||
// This guards the new default (completions disabled) — if someone flips the
|
||||
// zero-value back to "enabled", the per-Build memory growth observed under
|
||||
// `scripts/bench_build` would resurface in production hot paths that build
|
||||
// the root command without serving a completion request.
|
||||
func TestBuild_DefaultNoCompletionLeak(t *testing.T) {
|
||||
if cmdutil.FlagCompletionsEnabled() {
|
||||
t.Fatalf("precondition: FlagCompletionsEnabled() = true, want false (state polluted by another test)")
|
||||
}
|
||||
|
||||
snap := func() (heapMB float64, objs uint64) {
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
return float64(m.HeapAlloc) / 1024 / 1024, m.HeapObjects
|
||||
}
|
||||
|
||||
// Warm one-time caches (registry JSON decode, embed reads) so the first
|
||||
// Build's lazy allocations don't skew the per-iteration delta.
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{})
|
||||
baseMB, baseObj := snap()
|
||||
|
||||
const N = 20
|
||||
for range N {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{})
|
||||
}
|
||||
mb, obj := snap()
|
||||
|
||||
deltaMB := mb - baseMB
|
||||
deltaObj := int64(obj) - int64(baseObj)
|
||||
perBuildKB := deltaMB * 1024 / float64(N)
|
||||
perBuildObj := deltaObj / int64(N)
|
||||
|
||||
t.Logf("%d builds: +%.2f MB, +%d objects (%.1f KB/build, %d objs/build)",
|
||||
N, deltaMB, deltaObj, perBuildKB, perBuildObj)
|
||||
|
||||
// With completions disabled (the default), per-Build retained growth
|
||||
// should be minimal. Threshold is conservative: the previously observed
|
||||
// leak with completions enabled was ~hundreds of KB and thousands of
|
||||
// objects per Build, well above this bound.
|
||||
const maxKBPerBuild = 50.0
|
||||
const maxObjsPerBuild = 500
|
||||
if perBuildKB > maxKBPerBuild {
|
||||
t.Errorf("per-build heap growth = %.1f KB, want <= %.1f KB (completion registration may be leaking)", perBuildKB, maxKBPerBuild)
|
||||
}
|
||||
if perBuildObj > maxObjsPerBuild {
|
||||
t.Errorf("per-build object growth = %d, want <= %d", perBuildObj, maxObjsPerBuild)
|
||||
}
|
||||
}
|
||||
@@ -1,586 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"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/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// BindOptions holds all inputs for config bind.
|
||||
type BindOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Source string
|
||||
AppID string
|
||||
// Identity selects one of two presets — "bot-only" or "user-default" —
|
||||
// that expand to underlying StrictMode + DefaultAs in applyPreferences.
|
||||
// Empty means "decide later": TUI prompts, flag mode defaults to bot-only
|
||||
// (the safer choice — bot acts under its own identity, no impersonation
|
||||
// risk; users can still opt into "user-default" via --identity).
|
||||
Identity string
|
||||
|
||||
// Force opts in to an otherwise-blocked flag-mode transition — currently
|
||||
// only the bot-only → user-default identity escalation. TUI mode ignores
|
||||
// this flag because its own prompts already require human confirmation.
|
||||
Force bool
|
||||
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
|
||||
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
|
||||
// the account being bound. Populated after resolveAccount; TUI stages
|
||||
// that run before that (source / account selection) render brand-aware
|
||||
// text with an empty value, which brandDisplay falls back to Feishu.
|
||||
Brand string
|
||||
|
||||
// IsTUI is the resolved interactive-mode flag: true only when Source is
|
||||
// empty and stdin is a terminal. Computed once at the top of
|
||||
// configBindRun; downstream branches read this instead of rechecking
|
||||
// IOStreams.IsTerminal. Do not set from outside — it is overwritten.
|
||||
IsTUI bool
|
||||
}
|
||||
|
||||
// NewCmdConfigBind creates the config bind subcommand.
|
||||
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
|
||||
opts := &BindOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "bind",
|
||||
Short: "Bind Agent config to a workspace (source / app-id / force)",
|
||||
Long: `Bind an AI Agent's (OpenClaw / Hermes) Feishu credentials to a lark-cli workspace.
|
||||
|
||||
For AI agents: pass --source and --app-id to bind non-interactively.
|
||||
Credentials are synced once; subsequent calls in the Agent's process
|
||||
context automatically use the bound workspace.`,
|
||||
Example: ` lark-cli config bind --source openclaw --app-id <id>
|
||||
lark-cli config bind --source hermes`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return configBindRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Source, "source", "", "Agent source to bind from (openclaw|hermes); auto-detected from env signals when omitted")
|
||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
|
||||
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// configBindRun is the top-level orchestrator. Each step delegates to a named
|
||||
// helper whose signature declares its contract; the body reads as the shape of
|
||||
// the bind flow itself, not its mechanics.
|
||||
func configBindRun(opts *BindOptions) error {
|
||||
if err := validateBindFlags(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Decide TUI-vs-flag mode exactly once; every downstream branch reads
|
||||
// opts.IsTUI instead of re-checking IOStreams.IsTerminal.
|
||||
opts.IsTUI = opts.Source == "" && opts.Factory.IOStreams.IsTerminal
|
||||
|
||||
source, err := finalizeSource(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
core.SetCurrentWorkspace(core.Workspace(source))
|
||||
targetConfigPath := core.GetConfigPath()
|
||||
|
||||
existing, err := reconcileExistingBinding(opts, source, targetConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.Cancelled {
|
||||
return nil
|
||||
}
|
||||
|
||||
appConfig, err := resolveAccount(opts, source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Brand = string(appConfig.Brand)
|
||||
|
||||
if err := resolveIdentity(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
applyPreferences(appConfig, opts)
|
||||
|
||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
||||
}
|
||||
|
||||
// existingBinding is the outcome of checking whether a workspace was already
|
||||
// bound. ConfigBytes is non-nil iff a previous binding existed (and the caller
|
||||
// should pass it to commitBinding for stale-keychain cleanup after the new
|
||||
// config is durably written). Cancelled is true iff the user declined to
|
||||
// replace it in the TUI prompt; the caller should exit cleanly.
|
||||
type existingBinding struct {
|
||||
ConfigBytes []byte
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// finalizeSource returns the validated bind source, reconciling three inputs:
|
||||
// - opts.Source: the value of --source (may be empty)
|
||||
// - env signals: OPENCLAW_* / HERMES_* detected via DetectWorkspaceFromEnv
|
||||
// - TUI mode: can prompt the user if neither flag nor env yields a source
|
||||
//
|
||||
// Resolution (in order):
|
||||
// 1. If --source is a non-empty invalid value → fail with ErrValidation.
|
||||
// 2. If both --source and an env signal are present and disagree → fail
|
||||
// loud; the user almost certainly ran the command in the wrong context.
|
||||
// 3. TUI mode only: prompt for language first (so later prompts respect it).
|
||||
// 4. --source wins if set. Otherwise use the env-detected source. Otherwise
|
||||
// fall back to a TUI prompt (TUI mode) or an error (flag mode).
|
||||
func finalizeSource(opts *BindOptions) (string, error) {
|
||||
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
|
||||
if explicit != "" && explicit != "openclaw" && explicit != "hermes" {
|
||||
return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes", explicit)
|
||||
}
|
||||
|
||||
var detected string
|
||||
switch core.DetectWorkspaceFromEnv(os.Getenv) {
|
||||
case core.WorkspaceOpenClaw:
|
||||
detected = "openclaw"
|
||||
case core.WorkspaceHermes:
|
||||
detected = "hermes"
|
||||
}
|
||||
|
||||
// Explicit and env detection must agree when both are present. Reject
|
||||
// before any interactive prompts — running inside Hermes with
|
||||
// --source openclaw (or vice versa) is almost always a mistake.
|
||||
if explicit != "" && detected != "" && explicit != detected {
|
||||
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
||||
fmt.Sprintf("--source %q does not match detected Agent environment (%s)", explicit, detected),
|
||||
"remove --source to auto-detect, or run this command in the correct Agent context")
|
||||
}
|
||||
|
||||
// TUI: prompt for language before any downstream prompts. The source
|
||||
// selection itself may still be skipped entirely if --source or the
|
||||
// env already pinned it.
|
||||
if opts.IsTUI && !opts.langExplicit {
|
||||
lang, err := promptLangSelection("")
|
||||
if err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return "", output.ErrBare(1)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
opts.Lang = lang
|
||||
}
|
||||
|
||||
if explicit != "" {
|
||||
return explicit, nil
|
||||
}
|
||||
if detected != "" {
|
||||
return detected, nil
|
||||
}
|
||||
if opts.IsTUI {
|
||||
return tuiSelectSource(opts)
|
||||
}
|
||||
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
||||
"cannot determine Agent source: no --source flag and no Agent environment detected",
|
||||
"pass --source openclaw|hermes, or run this command inside an OpenClaw or Hermes chat")
|
||||
}
|
||||
|
||||
// reconcileExistingBinding reads any existing config at configPath and decides
|
||||
// how to proceed. In TUI mode the user is prompted to keep or replace. In flag
|
||||
// mode the existing binding is silently overwritten — commitBinding will emit a
|
||||
// notice on success so the caller still sees that a rebind happened.
|
||||
// See existingBinding for the returned fields.
|
||||
func reconcileExistingBinding(opts *BindOptions, source, configPath string) (existingBinding, error) {
|
||||
oldConfigData, _ := vfs.ReadFile(configPath)
|
||||
if oldConfigData == nil {
|
||||
return existingBinding{}, nil
|
||||
}
|
||||
|
||||
if opts.IsTUI {
|
||||
action, err := tuiConflictPrompt(opts, source, configPath)
|
||||
if err != nil {
|
||||
return existingBinding{}, err
|
||||
}
|
||||
if action == "cancel" {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
|
||||
return existingBinding{Cancelled: true}, nil
|
||||
}
|
||||
return existingBinding{ConfigBytes: oldConfigData}, nil
|
||||
}
|
||||
|
||||
return existingBinding{ConfigBytes: oldConfigData}, nil
|
||||
}
|
||||
|
||||
// resolveAccount runs the source-agnostic bind flow: construct the binder,
|
||||
// enumerate candidates, pick one via the shared decision layer, and build a
|
||||
// ready-to-persist AppConfig. Adding a new bind source only requires
|
||||
// implementing SourceBinder — none of the logic below needs to change.
|
||||
func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) {
|
||||
binder, err := newBinder(source, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates, err := binder.ListCandidates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
picked, err := selectCandidate(binder, candidates, opts.AppID, opts.IsTUI,
|
||||
func(cs []Candidate) (*Candidate, error) { return tuiSelectApp(opts, source, cs) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return binder.Build(picked.AppID)
|
||||
}
|
||||
|
||||
// resolveIdentity ensures opts.Identity is set before applyPreferences runs.
|
||||
// TUI mode prompts when empty; flag mode defaults to "bot-only" — the safer
|
||||
// preset (bot acts under its own identity, no impersonation). Users who
|
||||
// want the broader capability set can pass --identity user-default.
|
||||
func resolveIdentity(opts *BindOptions) error {
|
||||
if opts.Identity != "" {
|
||||
return nil
|
||||
}
|
||||
if opts.IsTUI {
|
||||
id, err := tuiSelectIdentity(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Identity = id
|
||||
return nil
|
||||
}
|
||||
opts.Identity = "bot-only"
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasStrictBotLock reports whether the given config bytes declare a
|
||||
// bot-only lock on at least one app. Unparseable input returns false — it
|
||||
// signals "no enforceable lock to honor", consistent with how the rest of
|
||||
// the bind flow treats a corrupt previous config (commitBinding will
|
||||
// overwrite it cleanly).
|
||||
func hasStrictBotLock(data []byte) bool {
|
||||
var multi core.MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, app := range multi.Apps {
|
||||
if app.StrictMode != nil && *app.StrictMode == core.StrictModeBot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// warnIdentityEscalation surfaces the risk of a flag-mode bot-only →
|
||||
// user-default identity change. Without --force, the CLI refuses so an AI
|
||||
// Agent has to relay the warning to the user and get explicit opt-in before
|
||||
// retrying. TUI mode is exempt: tuiConflictPrompt + tuiSelectIdentity
|
||||
// already require human confirmation in-flow.
|
||||
func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error {
|
||||
if opts.IsTUI || opts.Force || previousConfigBytes == nil {
|
||||
return nil
|
||||
}
|
||||
if opts.Identity != "user-default" {
|
||||
return nil
|
||||
}
|
||||
if !hasStrictBotLock(previousConfigBytes) {
|
||||
return nil
|
||||
}
|
||||
msg := getBindMsg(opts.Lang)
|
||||
return output.ErrWithHint(output.ExitValidation, "bind",
|
||||
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
|
||||
}
|
||||
|
||||
// applyPreferences expands the chosen identity preset into the underlying
|
||||
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
|
||||
// profile's intent survives later changes to global strict-mode settings.
|
||||
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
|
||||
switch opts.Identity {
|
||||
case "bot-only":
|
||||
sm := core.StrictModeBot
|
||||
appConfig.StrictMode = &sm
|
||||
appConfig.DefaultAs = core.AsBot
|
||||
case "user-default":
|
||||
sm := core.StrictModeOff
|
||||
appConfig.StrictMode = &sm
|
||||
appConfig.DefaultAs = core.AsUser
|
||||
}
|
||||
if opts.Lang != "" {
|
||||
appConfig.Lang = opts.Lang
|
||||
}
|
||||
}
|
||||
|
||||
// commitBinding finalizes the bind: atomic write of the new workspace config,
|
||||
// best-effort cleanup of stale keychain entries from the previous binding (if
|
||||
// any), and a JSON success envelope. Cleanup runs only after the new config
|
||||
// is durably written — if anything fails earlier, the old workspace stays
|
||||
// usable.
|
||||
func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
|
||||
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
|
||||
|
||||
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "bind",
|
||||
"failed to create workspace directory: %v", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(multi, "", " ")
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "bind",
|
||||
"failed to marshal config: %v", err)
|
||||
}
|
||||
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "bind",
|
||||
"failed to write config %s: %v", configPath, err)
|
||||
}
|
||||
|
||||
replaced := previousConfigBytes != nil
|
||||
msg := getBindMsg(opts.Lang)
|
||||
display := sourceDisplayName(source)
|
||||
|
||||
if replaced {
|
||||
cleanupKeychainFromData(opts.Factory.Keychain, previousConfigBytes, appConfig)
|
||||
}
|
||||
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
|
||||
fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice)
|
||||
|
||||
// TUI mode is a human sitting at a terminal; the BindSuccess notice on
|
||||
// stderr is enough and a machine-readable JSON dump on stdout is just
|
||||
// noise. Flag mode (Agent orchestration, scripts, piped output) still
|
||||
// gets the full envelope for programmatic consumption.
|
||||
if opts.IsTUI {
|
||||
return nil
|
||||
}
|
||||
|
||||
envelope := map[string]interface{}{
|
||||
"ok": true,
|
||||
"workspace": source,
|
||||
"app_id": appConfig.AppId,
|
||||
"config_path": configPath,
|
||||
"replaced": replaced,
|
||||
"identity": opts.Identity,
|
||||
}
|
||||
brand := brandDisplay(string(appConfig.Brand), opts.Lang)
|
||||
switch opts.Identity {
|
||||
case "bot-only":
|
||||
envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand)
|
||||
case "user-default":
|
||||
envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display)
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(envelope)
|
||||
fmt.Fprintln(opts.Factory.IOStreams.Out, string(resultJSON))
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupKeychainFromData removes keychain entries referenced by a previous
|
||||
// config snapshot, skipping any entry whose keychain ID is still in use by
|
||||
// the new app config. This prevents rebinding the same appId from deleting
|
||||
// the secret that ForStorage just wrote (old and new secret share the same
|
||||
// keychain key, derived from appId). Best-effort: errors are silently
|
||||
// ignored (same contract as config init's cleanup).
|
||||
func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core.AppConfig) {
|
||||
var multi core.MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
return
|
||||
}
|
||||
keepID := ""
|
||||
if keep != nil && keep.AppSecret.Ref != nil && keep.AppSecret.Ref.Source == "keychain" {
|
||||
keepID = keep.AppSecret.Ref.ID
|
||||
}
|
||||
for _, app := range multi.Apps {
|
||||
if keepID != "" && app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" && app.AppSecret.Ref.ID == keepID {
|
||||
continue
|
||||
}
|
||||
core.RemoveSecretStore(app.AppSecret, kc)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// TUI helpers (huh forms, matching config init interactive style)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
// tuiSelectSource prompts user to choose bind source.
|
||||
func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
var source string
|
||||
|
||||
// Pre-select based on detected env signals
|
||||
detected := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
switch detected {
|
||||
case core.WorkspaceOpenClaw:
|
||||
source = "openclaw"
|
||||
case core.WorkspaceHermes:
|
||||
source = "hermes"
|
||||
default:
|
||||
source = "openclaw" // default first option
|
||||
}
|
||||
|
||||
// Resolve actual paths for display
|
||||
openclawPath := resolveOpenClawConfigPath()
|
||||
hermesEnvPath := resolveHermesEnvPath()
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title(msg.SelectSource).
|
||||
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))).
|
||||
Options(
|
||||
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
|
||||
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
|
||||
).
|
||||
Value(&source),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return "", output.ErrBare(1)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
|
||||
// tuiSelectApp prompts the user to choose from multiple account candidates.
|
||||
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
|
||||
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
options := make([]huh.Option[int], 0, len(candidates))
|
||||
for i, c := range candidates {
|
||||
label := c.AppID
|
||||
if c.Label != "" {
|
||||
label = fmt.Sprintf("%s (%s)", c.Label, c.AppID)
|
||||
}
|
||||
options = append(options, huh.NewOption(label, i))
|
||||
}
|
||||
|
||||
var selected int
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))).
|
||||
Options(options...).
|
||||
Value(&selected),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return nil, output.ErrBare(1)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &candidates[selected], nil
|
||||
}
|
||||
|
||||
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
|
||||
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
|
||||
// Build existing binding summary
|
||||
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
|
||||
if data, err := vfs.ReadFile(configPath); err == nil {
|
||||
var multi core.MultiAppConfig
|
||||
if json.Unmarshal(data, &multi) == nil && len(multi.Apps) > 0 {
|
||||
app := multi.Apps[0]
|
||||
existingSummary = fmt.Sprintf(msg.ConflictDesc,
|
||||
source, app.AppId, app.Brand, configPath)
|
||||
}
|
||||
}
|
||||
|
||||
var action string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewNote().
|
||||
Title(msg.ConflictTitle).
|
||||
Description(existingSummary),
|
||||
huh.NewSelect[string]().
|
||||
Options(
|
||||
huh.NewOption(msg.ConflictForce, "force"),
|
||||
huh.NewOption(msg.ConflictCancel, "cancel"),
|
||||
).
|
||||
Value(&action),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return "cancel", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return action, nil
|
||||
}
|
||||
|
||||
// indent prepends two spaces to every line of s. Used to visually nest
|
||||
// multi-line option descriptions under their label in tuiSelectIdentity.
|
||||
func indent(s string) string {
|
||||
return " " + strings.ReplaceAll(s, "\n", "\n ")
|
||||
}
|
||||
|
||||
// validateBindFlags validates enum flags early, before any side effects.
|
||||
func validateBindFlags(opts *BindOptions) error {
|
||||
if opts.Identity != "" {
|
||||
switch opts.Identity {
|
||||
case "bot-only", "user-default":
|
||||
default:
|
||||
return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tuiSelectIdentity prompts user to pick one of two identity presets.
|
||||
// bot-only is listed first so Enter on the default highlight maps to the
|
||||
// flag-mode default for consistency across the two modes, and also because
|
||||
// bot-only is the safer preset (no impersonation risk).
|
||||
//
|
||||
// Layout: each option's description is embedded under its label using a
|
||||
// multi-line option value. huh styles the whole option block (label +
|
||||
// indented description) as selected / unselected, giving a clear visual
|
||||
// mapping between picker rows and their explanations — the dynamic
|
||||
// DescriptionFunc approach breaks here because a longer description on
|
||||
// hover pushes options out of the field's initial viewport.
|
||||
func tuiSelectIdentity(opts *BindOptions) (string, error) {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
brand := brandDisplay(opts.Brand, opts.Lang)
|
||||
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
|
||||
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
|
||||
var value string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title(msg.SelectIdentity).
|
||||
Options(
|
||||
huh.NewOption(botLabel, "bot-only"),
|
||||
huh.NewOption(userLabel, "user-default"),
|
||||
).
|
||||
Value(&value),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return "", output.ErrBare(1)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
// bindMsg holds all TUI text for config bind, supporting zh/en via --lang.
|
||||
//
|
||||
// Brand-aware strings use a %s slot where the UI-friendly product name
|
||||
// should appear; callers pass brandDisplay(brand, lang) at that position.
|
||||
// English templates use %[N]s positional indices when the natural English
|
||||
// order puts brand before source.
|
||||
type bindMsg struct {
|
||||
// Source selection.
|
||||
// SelectSourceDesc format: brand.
|
||||
SelectSource string
|
||||
SelectSourceDesc string
|
||||
SourceOpenClaw string // format: resolved config path.
|
||||
SourceHermes string // format: resolved dotenv path.
|
||||
|
||||
// Account selection (OpenClaw multi-account).
|
||||
// Format: source display name ("OpenClaw" | "Hermes"), brand.
|
||||
SelectAccount string
|
||||
|
||||
// Conflict prompt.
|
||||
ConflictTitle string
|
||||
ConflictDesc string // format: workspace, appId, brand, configPath.
|
||||
ConflictForce string
|
||||
ConflictCancel string
|
||||
ConflictCancelled string
|
||||
|
||||
// Post-bind agent-friendly message emitted in the stdout JSON envelope's
|
||||
// "message" field. Written as imperative instructions to the agent reading
|
||||
// the JSON — not as description for a human reader.
|
||||
// MessageBotOnly format: app_id, source display name, brand.
|
||||
// MessageUserDefault format: app_id, source display name, source display
|
||||
// name (second source ref anchors the "run in this chat" directive).
|
||||
// MessageUserDefault directs the Agent at the blocking single-call
|
||||
// `auth login --recommend` flow: the CLI streams verification_url to
|
||||
// stderr, which Agent runtimes (OpenClaw, Hermes) relay to the user in
|
||||
// real time, then blocks until the user authorizes in their own browser.
|
||||
// The Agent also needs an explicit "do not navigate the URL yourself"
|
||||
// guard — its own browser is sandboxed and cannot complete the user's
|
||||
// authorization.
|
||||
MessageBotOnly string
|
||||
MessageUserDefault string
|
||||
|
||||
// Identity preset (collapses strict-mode + default-as into one choice).
|
||||
// IdentityBotOnly/IdentityUserDefault are short, single-line labels for
|
||||
// the huh Select options. IdentityBotOnlyDesc / IdentityUserDefaultDesc
|
||||
// carry the longer explanation for each choice; tuiSelectIdentity
|
||||
// embeds the description under its label as a multi-line option value,
|
||||
// so huh renders the whole "label + indented description" block as one
|
||||
// picker row and styles it selected / unselected as a unit. Dynamic
|
||||
// DescriptionFunc was tried first but breaks here: a longer description
|
||||
// on hover pushes the field's initial viewport, clipping the selected
|
||||
// option row on terminals that fit the smaller description.
|
||||
// IdentityBotOnlyDesc format: brand.
|
||||
// IdentityUserDefaultDesc format: brand, brand.
|
||||
SelectIdentity string
|
||||
IdentityBotOnly string
|
||||
IdentityUserDefault string
|
||||
IdentityBotOnlyDesc string
|
||||
IdentityUserDefaultDesc string
|
||||
|
||||
// Post-bind success notice printed to stderr once the workspace config
|
||||
// has been durably written. Rendered as two parts joined with "\n":
|
||||
// BindSuccessHeader — format: source display name.
|
||||
// BindSuccessNotice — caveat about one-time sync.
|
||||
// We intentionally do NOT emit a "replaced" suffix here (the TUI already
|
||||
// asked the user to confirm overwrite; flag mode carries `replaced:true`
|
||||
// in the stdout JSON envelope), and we do NOT emit an inline "next step"
|
||||
// line for user-default (stderr is the human channel; agents read the
|
||||
// MessageUserDefault field in the JSON envelope).
|
||||
BindSuccessHeader string
|
||||
BindSuccessNotice string
|
||||
|
||||
// IdentityEscalationMessage / IdentityEscalationHint are returned when a
|
||||
// previous bind set the workspace to bot-only and a flag-mode (AI-driven)
|
||||
// caller tries to rebind with --identity user-default without --force.
|
||||
// The error asks the Agent to surface the risk to the user and re-run
|
||||
// with --force only after explicit user confirmation. TUI mode does not
|
||||
// hit this code path — tuiConflictPrompt + tuiSelectIdentity already
|
||||
// require in-flow human confirmation.
|
||||
IdentityEscalationMessage string
|
||||
IdentityEscalationHint string
|
||||
}
|
||||
|
||||
var bindMsgZh = &bindMsg{
|
||||
SelectSource: "你想在哪个 Agent 中使用 lark-cli?",
|
||||
SelectSourceDesc: "从你选择的 Agent 中获取%s应用信息,并配置到 lark-cli 中",
|
||||
SourceOpenClaw: "OpenClaw — 配置文件: %s",
|
||||
SourceHermes: "Hermes — 配置文件: %s",
|
||||
|
||||
SelectAccount: "检测到 %s 中已配置多个%s应用,请选择一个",
|
||||
|
||||
ConflictTitle: "检测到已有配置",
|
||||
ConflictDesc: "%q 已配置 lark-cli:\n App ID: %s\n 品牌: %s\n 配置文件: %s",
|
||||
ConflictForce: "修改配置",
|
||||
ConflictCancel: "保留当前配置",
|
||||
ConflictCancelled: "已保留当前配置",
|
||||
|
||||
MessageBotOnly: "已绑定应用 %s 到 %s,可立即以应用(bot)身份调用%s API,现在可以继续执行用户的请求。",
|
||||
MessageUserDefault: "已绑定应用 %s 到 %s。请接着在此 %s 对话中运行 `lark-cli auth login --recommend`。该命令会在 stderr 打出 verification_url 后阻塞等待用户授权;请将此链接原样发给用户在其浏览器中完成授权(不要自己调 browser_navigate 之类的工具打开,授权必须在用户的浏览器里完成),命令会在用户授权完成后自动返回。",
|
||||
|
||||
SelectIdentity: "你希望 AI 如何与你协作?",
|
||||
IdentityBotOnly: "以机器人身份",
|
||||
IdentityUserDefault: "以你的身份",
|
||||
IdentityBotOnlyDesc: "AI 将在%s中以机器人的身份执行所有操作,适合作为团队助手,用于多人协作场景,如群聊问答、团队通知、公共文档维护。",
|
||||
IdentityUserDefaultDesc: "AI 将在%s中以你的名义执行所有操作,如读写文档、搜索消息、修改日程等,建议仅限个人使用。\n" +
|
||||
"⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的%s数据。",
|
||||
|
||||
BindSuccessHeader: "配置成功!lark-cli 已可在 %s 中使用。",
|
||||
BindSuccessNotice: "注意:这是一次性同步,后续 Agent 配置变更不会自动更新到 lark-cli。如需重新同步,请执行 `lark-cli config bind`",
|
||||
|
||||
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
|
||||
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
|
||||
}
|
||||
|
||||
var bindMsgEn = &bindMsg{
|
||||
SelectSource: "Which Agent are you running?",
|
||||
SelectSourceDesc: "lark-cli will read your %s app credentials from the selected Agent and apply them automatically.",
|
||||
SourceOpenClaw: "OpenClaw — config: %s",
|
||||
SourceHermes: "Hermes — config: %s",
|
||||
|
||||
// Args order (source, brand) matches the Chinese template; %[N]s lets the
|
||||
// English reading order differ while the caller passes args in one order.
|
||||
SelectAccount: "Multiple %[2]s apps configured in %[1]s — select one to continue.",
|
||||
|
||||
ConflictTitle: "Existing configuration found",
|
||||
ConflictDesc: "lark-cli is already set up for %q:\n App ID: %s\n Brand: %s\n Config: %s",
|
||||
ConflictForce: "Update config",
|
||||
ConflictCancel: "Keep current config",
|
||||
ConflictCancelled: "Current config kept. No changes made.",
|
||||
|
||||
MessageBotOnly: "Bound app %s to %s. The %s app (bot) identity is ready — you can now continue with the user's request.",
|
||||
MessageUserDefault: "Bound app %s to %s. Next, in this %s chat, run `lark-cli auth login --recommend`. The command prints the verification URL to stderr and then blocks until the user authorizes it; relay the URL to the user so they can approve it in their own browser (do not call browser_navigate or any tool that opens a browser yourself — your browser is sandboxed and cannot complete the authorization). The command returns automatically once authorization completes.",
|
||||
|
||||
SelectIdentity: "How should the AI work with you?",
|
||||
IdentityBotOnly: "As bot",
|
||||
IdentityUserDefault: "As you",
|
||||
IdentityBotOnlyDesc: "Works under its own identity in %s. Best for group chats, team notifications, and shared documents.",
|
||||
IdentityUserDefaultDesc: "Works under your identity in %s, managing docs, messages, calendar, and more on your behalf. Personal use only.\n" +
|
||||
"⚠️ Don't share this bot with others or add it to group chats. It has access to your personal %s data.",
|
||||
|
||||
BindSuccessHeader: "All set! lark-cli is now ready to use in %s.",
|
||||
BindSuccessNotice: "Note: This is a one-time sync. To re-sync future changes, run `lark-cli config bind`",
|
||||
|
||||
IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.",
|
||||
IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`",
|
||||
}
|
||||
|
||||
func getBindMsg(lang string) *bindMsg {
|
||||
if lang == "en" {
|
||||
return bindMsgEn
|
||||
}
|
||||
return bindMsgZh
|
||||
}
|
||||
|
||||
// brandDisplay returns the UI-friendly product name for the given brand
|
||||
// identifier and display language. "lark" maps to "Lark" in both zh and en.
|
||||
// "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en —
|
||||
// this is the safe default when the brand hasn't been resolved yet (for
|
||||
// example, on the pre-binding source-selection screen).
|
||||
func brandDisplay(brand, lang string) string {
|
||||
if brand == "lark" || brand == "Lark" || brand == "LARK" {
|
||||
return "Lark"
|
||||
}
|
||||
if lang == "en" {
|
||||
return "Feishu"
|
||||
}
|
||||
return "飞书"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,414 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/binding"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// Candidate is the source-agnostic view of a bindable account.
|
||||
// It carries only the identity fields needed by selectCandidate / TUI;
|
||||
// secrets remain inside the SourceBinder implementation.
|
||||
type Candidate struct {
|
||||
AppID string
|
||||
Label string
|
||||
}
|
||||
|
||||
// SourceBinder abstracts a bind source (openclaw / hermes / future sources).
|
||||
// Implementations only list candidates and build an AppConfig for a chosen
|
||||
// candidate — they stay out of mode (TUI vs flag) and orchestration concerns.
|
||||
type SourceBinder interface {
|
||||
// Name returns the source identifier (used in error envelopes).
|
||||
Name() string
|
||||
// ConfigPath returns the resolved path to the source's config file.
|
||||
ConfigPath() string
|
||||
// ListCandidates enumerates bindable accounts from the source config.
|
||||
// An empty slice is valid (selectCandidate will turn it into a typed error).
|
||||
ListCandidates() ([]Candidate, error)
|
||||
// Build resolves secrets, persists to keychain, and returns a ready AppConfig
|
||||
// for the chosen candidate AppID. Must be called after ListCandidates succeeds.
|
||||
Build(appID string) (*core.AppConfig, error)
|
||||
}
|
||||
|
||||
// newBinder constructs the SourceBinder for the given source name.
|
||||
func newBinder(source string, opts *BindOptions) (SourceBinder, error) {
|
||||
switch source {
|
||||
case "openclaw":
|
||||
return &openclawBinder{opts: opts, path: resolveOpenClawConfigPath()}, nil
|
||||
case "hermes":
|
||||
return &hermesBinder{opts: opts, path: resolveHermesEnvPath()}, nil
|
||||
default:
|
||||
return nil, output.ErrValidation("unsupported source: %s", source)
|
||||
}
|
||||
}
|
||||
|
||||
// selectCandidate is the single source of truth for account-selection logic.
|
||||
// Every bind source funnels through this function, so the "how many
|
||||
// candidates × was --app-id given × is this TUI" policy is defined once.
|
||||
//
|
||||
// Decision matrix:
|
||||
//
|
||||
// candidates=0 → error "no app configured"
|
||||
// appID set, match → selected
|
||||
// appID set, no match → error + candidate list
|
||||
// candidates=1, appID="" → auto-select
|
||||
// candidates≥2, appID="", isTUI=true → tuiPrompt
|
||||
// candidates≥2, appID="", isTUI=false → error + candidate list
|
||||
//
|
||||
// The last branch is the one that matters for flag-mode callers: an explicit
|
||||
// --source must never silently drop into an interactive prompt just because
|
||||
// stdin happens to be a terminal.
|
||||
func selectCandidate(
|
||||
binder SourceBinder,
|
||||
candidates []Candidate,
|
||||
appIDFlag string,
|
||||
isTUI bool,
|
||||
tuiPrompt func([]Candidate) (*Candidate, error),
|
||||
) (*Candidate, error) {
|
||||
src := binder.Name()
|
||||
cfgBase := filepath.Base(binder.ConfigPath())
|
||||
|
||||
if len(candidates) == 0 {
|
||||
// Reader succeeded but yielded nothing — e.g. every openclaw account
|
||||
// is disabled. Missing-file / missing-field cases return typed errors
|
||||
// from ListCandidates itself and never reach here.
|
||||
switch src {
|
||||
case "openclaw":
|
||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||
"no Feishu app configured in openclaw.json",
|
||||
"configure channels.feishu.appId in openclaw.json")
|
||||
default:
|
||||
return nil, output.ErrValidation("%s: no app configured", src)
|
||||
}
|
||||
}
|
||||
|
||||
if appIDFlag != "" {
|
||||
for i := range candidates {
|
||||
if candidates[i].AppID == appIDFlag {
|
||||
return &candidates[i], nil
|
||||
}
|
||||
}
|
||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||
fmt.Sprintf("--app-id %q not found in %s", appIDFlag, cfgBase),
|
||||
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
|
||||
}
|
||||
|
||||
if len(candidates) == 1 {
|
||||
return &candidates[0], nil
|
||||
}
|
||||
|
||||
if isTUI {
|
||||
return tuiPrompt(candidates)
|
||||
}
|
||||
|
||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||
fmt.Sprintf("multiple accounts in %s; pass --app-id <id>", cfgBase),
|
||||
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
|
||||
}
|
||||
|
||||
// formatCandidates renders candidates as "AppID (Label)" lines for error hints.
|
||||
func formatCandidates(candidates []Candidate) string {
|
||||
ids := make([]string, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
label := c.AppID
|
||||
if c.Label != "" {
|
||||
label = fmt.Sprintf("%s (%s)", c.AppID, c.Label)
|
||||
}
|
||||
ids = append(ids, label)
|
||||
}
|
||||
return strings.Join(ids, "\n ")
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// openclawBinder
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
type openclawBinder struct {
|
||||
opts *BindOptions
|
||||
path string
|
||||
|
||||
// Cached between ListCandidates and Build so we don't re-read / re-parse.
|
||||
cfg *binding.OpenClawRoot
|
||||
rawApps []binding.CandidateApp
|
||||
}
|
||||
|
||||
func (b *openclawBinder) Name() string { return "openclaw" }
|
||||
func (b *openclawBinder) ConfigPath() string { return b.path }
|
||||
|
||||
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||
cfg, err := binding.ReadOpenClawConfig(b.path)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
fmt.Sprintf("cannot read %s: %v", b.path, err),
|
||||
"verify OpenClaw is installed and configured")
|
||||
}
|
||||
if cfg.Channels.Feishu == nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
"openclaw.json missing channels.feishu section",
|
||||
"configure Feishu in OpenClaw first")
|
||||
}
|
||||
|
||||
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
|
||||
b.cfg = cfg
|
||||
b.rawApps = raw
|
||||
|
||||
result := make([]Candidate, 0, len(raw))
|
||||
for _, c := range raw {
|
||||
result = append(result, Candidate{AppID: c.AppID, Label: c.Label})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.cfg == nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||
"internal: Build called before ListCandidates")
|
||||
}
|
||||
|
||||
var selected *binding.CandidateApp
|
||||
for i := range b.rawApps {
|
||||
if b.rawApps[i].AppID == appID {
|
||||
selected = &b.rawApps[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||
"internal: appID %q not in candidates", appID)
|
||||
}
|
||||
|
||||
if selected.AppSecret.IsZero() {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
fmt.Sprintf("appSecret is empty for app %s in %s", selected.AppID, b.path),
|
||||
"configure channels.feishu.appSecret in openclaw.json")
|
||||
}
|
||||
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
fmt.Sprintf("failed to resolve appSecret for %s: %v", selected.AppID, err),
|
||||
fmt.Sprintf("check appSecret configuration in %s", b.path))
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
AppId: selected.AppID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// hermesBinder
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
type hermesBinder struct {
|
||||
opts *BindOptions
|
||||
path string
|
||||
envMap map[string]string // cached between ListCandidates and Build
|
||||
}
|
||||
|
||||
func (b *hermesBinder) Name() string { return "hermes" }
|
||||
func (b *hermesBinder) ConfigPath() string { return b.path }
|
||||
|
||||
func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
||||
envMap, err := readDotenv(b.path)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||
fmt.Sprintf("failed to read Hermes config: %v", err),
|
||||
fmt.Sprintf("verify Hermes is installed and configured at %s", b.path))
|
||||
}
|
||||
appID := envMap["FEISHU_APP_ID"]
|
||||
if appID == "" {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||
fmt.Sprintf("FEISHU_APP_ID not found in %s", b.path),
|
||||
"run 'hermes setup' to configure Feishu credentials")
|
||||
}
|
||||
b.envMap = envMap
|
||||
return []Candidate{{AppID: appID, Label: "default"}}, nil
|
||||
}
|
||||
|
||||
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.envMap == nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||
"internal: Build called before ListCandidates")
|
||||
}
|
||||
if b.envMap["FEISHU_APP_ID"] != appID {
|
||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||
"internal: appID %q does not match env", appID)
|
||||
}
|
||||
appSecret := b.envMap["FEISHU_APP_SECRET"]
|
||||
if appSecret == "" {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||
fmt.Sprintf("FEISHU_APP_SECRET not found in %s", b.path),
|
||||
"run 'hermes setup' to configure Feishu credentials")
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Source-specific helpers (path / dotenv / brand) — kept private to this package.
|
||||
// Moved here from bind.go so bind.go can focus on orchestration.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
// sourceDisplayName returns the user-facing label for a source identifier,
|
||||
// matching the casing used in bind_messages.go (OpenClaw / Hermes).
|
||||
func sourceDisplayName(source string) string {
|
||||
switch source {
|
||||
case "openclaw":
|
||||
return "OpenClaw"
|
||||
case "hermes":
|
||||
return "Hermes"
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeBrand applies .strip().lower() and defaults to "feishu".
|
||||
// Aligns with Hermes gateway/platforms/feishu.py:1119 behavior.
|
||||
func normalizeBrand(raw string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(raw))
|
||||
if s == "" {
|
||||
return "feishu"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// resolveHermesEnvPath returns the path to Hermes's .env file.
|
||||
// Respects HERMES_HOME override; defaults to ~/.hermes/.env.
|
||||
//
|
||||
// Note: HERMES_HOME is typically unset when users run bind from a regular
|
||||
// terminal. When AI agents execute bind within a Hermes subprocess, HERMES_HOME
|
||||
// may be set and should be respected.
|
||||
func resolveHermesEnvPath() string {
|
||||
hermesHome := os.Getenv("HERMES_HOME")
|
||||
if hermesHome == "" {
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||
}
|
||||
hermesHome = filepath.Join(home, ".hermes")
|
||||
}
|
||||
return filepath.Join(hermesHome, ".env")
|
||||
}
|
||||
|
||||
// resolveOpenClawConfigPath resolves openclaw.json path using the same priority
|
||||
// chain as OpenClaw's src/config/paths.ts:
|
||||
// 1. OPENCLAW_CONFIG_PATH env → exact file path
|
||||
// 2. OPENCLAW_STATE_DIR env → <dir>/openclaw.json
|
||||
// 3. OPENCLAW_HOME env → <home>/.openclaw/openclaw.json
|
||||
// 4. ~/.openclaw/openclaw.json (default)
|
||||
// 5. Legacy: ~/.clawdbot/clawdbot.json, ~/.openclaw/clawdbot.json
|
||||
func resolveOpenClawConfigPath() string {
|
||||
if p := os.Getenv("OPENCLAW_CONFIG_PATH"); p != "" {
|
||||
return expandHome(p)
|
||||
}
|
||||
|
||||
if stateDir := os.Getenv("OPENCLAW_STATE_DIR"); stateDir != "" {
|
||||
dir := expandHome(stateDir)
|
||||
return findConfigInDir(dir)
|
||||
}
|
||||
|
||||
home := os.Getenv("OPENCLAW_HOME")
|
||||
if home == "" {
|
||||
h, err := vfs.UserHomeDir()
|
||||
if err != nil || h == "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||
}
|
||||
home = h
|
||||
} else {
|
||||
home = expandHome(home)
|
||||
}
|
||||
|
||||
newDir := filepath.Join(home, ".openclaw")
|
||||
if configFile := findConfigInDir(newDir); fileExists(configFile) {
|
||||
return configFile
|
||||
}
|
||||
|
||||
legacyDir := filepath.Join(home, ".clawdbot")
|
||||
if configFile := findConfigInDir(legacyDir); fileExists(configFile) {
|
||||
return configFile
|
||||
}
|
||||
|
||||
return filepath.Join(newDir, "openclaw.json")
|
||||
}
|
||||
|
||||
func findConfigInDir(dir string) string {
|
||||
primary := filepath.Join(dir, "openclaw.json")
|
||||
if fileExists(primary) {
|
||||
return primary
|
||||
}
|
||||
legacy := filepath.Join(dir, "clawdbot.json")
|
||||
if fileExists(legacy) {
|
||||
return legacy
|
||||
}
|
||||
return primary
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := vfs.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func expandHome(path string) string {
|
||||
if strings.HasPrefix(path, "~/") || path == "~" {
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(home, path[1:])
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// readDotenv reads a KEY=VALUE .env file. Comments (#) and blank lines skipped.
|
||||
// Matches Hermes's load_env() in hermes_cli/config.py.
|
||||
func readDotenv(path string) (map[string]string, error) {
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]string)
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
idx := strings.IndexByte(line, '=')
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
if key != "" {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// fakeBinder is a test double for SourceBinder. selectCandidate only touches
|
||||
// Name and ConfigPath (for error messages); ListCandidates/Build are not called
|
||||
// from selectCandidate, so we can leave them as no-ops.
|
||||
type fakeBinder struct {
|
||||
name string
|
||||
path string
|
||||
}
|
||||
|
||||
func (b *fakeBinder) Name() string { return b.name }
|
||||
func (b *fakeBinder) ConfigPath() string { return b.path }
|
||||
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
|
||||
func (b *fakeBinder) Build(appID string) (*core.AppConfig, error) { return nil, nil }
|
||||
|
||||
// tuiUnreachable is a tuiPrompt that fails the test if called. It's the
|
||||
// guardrail that proves the non-TUI decision paths really do stay out of the
|
||||
// interactive prompt — otherwise a green test could still hide a silent TUI.
|
||||
func tuiUnreachable(t *testing.T) func([]Candidate) (*Candidate, error) {
|
||||
t.Helper()
|
||||
return func([]Candidate) (*Candidate, error) {
|
||||
t.Fatal("tuiPrompt must not be called in flag mode")
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// assertCandidate compares the full Candidate struct via DeepEqual so that
|
||||
// any future field added to Candidate is covered automatically.
|
||||
func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
|
||||
t.Helper()
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil Candidate")
|
||||
}
|
||||
if !reflect.DeepEqual(*got, want) {
|
||||
t.Errorf("candidate mismatch:\n got: %+v\n want: %+v", *got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "no Feishu app configured in openclaw.json",
|
||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
|
||||
// Locks in the generic fallback so that any future source added to
|
||||
// newBinder gets a well-formed validation error on "zero candidates"
|
||||
// even before it has a bespoke error message.
|
||||
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
|
||||
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: "hermes: no app configured",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_SingleCandidate_NoFlag_AutoSelect(t *testing.T) {
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{{AppID: "cli_only", Label: "default"}}
|
||||
got, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertCandidate(t, got, Candidate{AppID: "cli_only", Label: "default"})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_AppIDFlag_ExactMatch(t *testing.T) {
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{
|
||||
{AppID: "cli_work", Label: "work"},
|
||||
{AppID: "cli_home", Label: "home"},
|
||||
}
|
||||
got, err := selectCandidate(b, candidates, "cli_home", false, tuiUnreachable(t))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertCandidate(t, got, Candidate{AppID: "cli_home", Label: "home"})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{
|
||||
{AppID: "cli_work", Label: "work"},
|
||||
{AppID: "cli_home", Label: "home"},
|
||||
}
|
||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
|
||||
// Flag-mode with multiple candidates and no --app-id must produce a
|
||||
// validation error and the candidate list, never an interactive prompt.
|
||||
// isTUI is the single gate; a real terminal alone must not trigger TUI.
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{
|
||||
{AppID: "cli_work", Label: "work"},
|
||||
{AppID: "cli_home", Label: "home"},
|
||||
}
|
||||
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_MultiCandidate_NoFlag_TUI(t *testing.T) {
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{
|
||||
{AppID: "cli_work", Label: "work"},
|
||||
{AppID: "cli_home", Label: "home"},
|
||||
}
|
||||
var gotCandidates []Candidate
|
||||
got, err := selectCandidate(b, candidates, "", true, func(cs []Candidate) (*Candidate, error) {
|
||||
gotCandidates = cs
|
||||
return &cs[1], nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Whole-slice DeepEqual so additions to Candidate propagate to this check.
|
||||
if !reflect.DeepEqual(gotCandidates, candidates) {
|
||||
t.Errorf("tuiPrompt received %+v, want %+v", gotCandidates, candidates)
|
||||
}
|
||||
assertCandidate(t, got, Candidate{AppID: "cli_home", Label: "home"})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
|
||||
// Even with only one candidate, a wrong --app-id must error rather than
|
||||
// silently auto-selecting. An explicit mismatch is always a user mistake,
|
||||
// not a reason to override their intent.
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{{AppID: "cli_only"}}
|
||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_only",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
|
||||
// An explicit --app-id short-circuits the prompt even in TUI mode: a
|
||||
// flag the user typed should never be second-guessed by an interactive
|
||||
// prompt asking the same question.
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
candidates := []Candidate{
|
||||
{AppID: "cli_a"},
|
||||
{AppID: "cli_b"},
|
||||
}
|
||||
got, err := selectCandidate(b, candidates, "cli_b", true, tuiUnreachable(t))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertCandidate(t, got, Candidate{AppID: "cli_b"})
|
||||
}
|
||||
@@ -14,19 +14,10 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Global CLI configuration management",
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first
|
||||
// PersistentPreRun[E] found walking up the chain, so the root-level
|
||||
// SilenceUsage=true would be skipped without this line.
|
||||
cmd.SilenceUsage = true
|
||||
// Pass "config" as a literal — cmd.Name() would return the subcommand name.
|
||||
return f.RequireBuiltinCredentialProvider(cmd.Context(), "config")
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
cmd.AddCommand(NewCmdConfigInit(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigBind(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigRemove(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
|
||||
@@ -6,16 +6,13 @@ package config
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -343,68 +340,3 @@ func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
|
||||
t.Fatalf("error = %v, want mention of App Secret", err)
|
||||
}
|
||||
}
|
||||
|
||||
// stubConfigExtProvider simulates env/sidecar credential mode for config guard tests.
|
||||
type stubConfigExtProvider struct{ name string }
|
||||
|
||||
func (s *stubConfigExtProvider) Name() string { return s.name }
|
||||
func (s *stubConfigExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
|
||||
return &extcred.Account{AppID: "test-app"}, nil
|
||||
}
|
||||
func (s *stubConfigExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newConfigFactoryWithExternalProvider(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
stub := &stubConfigExtProvider{name: "env"}
|
||||
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
return f
|
||||
}
|
||||
|
||||
func TestConfigBlockedByExternalProvider(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
|
||||
{"remove", []string{"remove"}},
|
||||
{"show", []string{"show"}},
|
||||
{"default-as", []string{"default-as", "user"}},
|
||||
{"strict-mode", []string{"strict-mode", "off"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := NewCmdConfig(f)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(tt.args)
|
||||
|
||||
// Locate the subcommand before execution (PersistentPreRunE receives it as cmd).
|
||||
matched, _, _ := cmd.Find(tt.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
|
||||
// PersistentPreRunE sets SilenceUsage on the matched subcommand, not the parent.
|
||||
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
||||
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
|
||||
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
|
||||
// Mode 3: Create new app directly (--new)
|
||||
if opts.New {
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, core.BrandFeishu, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return notConfiguredError()
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
@@ -64,7 +64,6 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
users = strings.Join(userStrs, ", ")
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"workspace": core.CurrentWorkspace().Display(),
|
||||
"profile": app.ProfileName(),
|
||||
"appId": app.AppId,
|
||||
"appSecret": "****",
|
||||
@@ -75,18 +74,3 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
// notConfiguredError returns the "not configured" error with a hint that
|
||||
// points the user to the right next step: config init for the default local
|
||||
// workspace, config bind for an Agent workspace that has not been bound yet.
|
||||
func notConfiguredError() error {
|
||||
ws := core.CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return output.ErrWithHint(output.ExitValidation, "config",
|
||||
"not configured",
|
||||
"run: lark-cli config init")
|
||||
}
|
||||
return output.ErrWithHint(output.ExitValidation, ws.Display(),
|
||||
fmt.Sprintf("%s context detected but lark-cli not bound to %s workspace", ws.Display(), ws.Display()),
|
||||
fmt.Sprintf("run: lark-cli config bind --source %s", ws.Display()))
|
||||
}
|
||||
|
||||
@@ -253,9 +253,8 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"ok": allOK,
|
||||
"workspace": core.CurrentWorkspace().Display(),
|
||||
"checks": checks,
|
||||
"ok": allOK,
|
||||
"checks": checks,
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
if !allOK {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
|
||||
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
|
||||
|
||||
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.
|
||||
func describeAppMetaErr(err error) string {
|
||||
msg := err.Error()
|
||||
if url := authURLPattern.FindString(msg); url != "" {
|
||||
return fmt.Sprintf("bot is missing scopes needed for app-version metadata; grant at: %s", url)
|
||||
}
|
||||
const maxErrLen = 200
|
||||
if len(msg) > maxErrLen {
|
||||
return msg[:maxErrLen] + "…"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const realisticPermError = `API GET /open-apis/application/v6/applications/cli_XXXXXXXXXXXXXXXX/app_versions?lang=zh_cn&page_size=2 returned 400: {"code":99991672,"msg":"Access denied. One of the following scopes is required: [application:application:self_manage, application:application.app_version:readonly].应用尚未开通所需的应用身份权限:[application:application:self_manage, application:application.app_version:readonly],点击链接申请并开通任一权限即可:https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=application:application:self_manage,application:application.app_version:readonly&op_from=openapi&token_type=tenant","error":{"message":"Refer to the documentation...","log_id":"20260421101203E2A5F141245B6F43B3A6"}}`
|
||||
|
||||
func TestDescribeAppMetaErr_PermissionDeniedShort(t *testing.T) {
|
||||
got := describeAppMetaErr(errors.New(realisticPermError))
|
||||
if len(got) > 400 {
|
||||
t.Errorf("summary too long (%d chars): %q", len(got), got)
|
||||
}
|
||||
if !strings.Contains(got, "scope") {
|
||||
t.Errorf("summary should mention scope requirement, got: %q", got)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=application:application:self_manage,application:application.app_version:readonly&op_from=openapi&token_type=tenant"
|
||||
if !strings.Contains(got, wantURL) {
|
||||
t.Errorf("summary missing grant URL\ngot: %q\nwant: %q", got, wantURL)
|
||||
}
|
||||
for _, noise := range []string{"log_id", `"error":`, "Refer to the documentation"} {
|
||||
if strings.Contains(got, noise) {
|
||||
t.Errorf("summary leaked noise %q: %q", noise, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAppMetaErr_UnknownErrorTruncated(t *testing.T) {
|
||||
long := strings.Repeat("x", 500)
|
||||
got := describeAppMetaErr(errors.New(long))
|
||||
if len(got) > 220 {
|
||||
t.Errorf("unknown error not truncated, len=%d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAppMetaErr_ShortErrorPassesThrough(t *testing.T) {
|
||||
got := describeAppMetaErr(errors.New("network unreachable"))
|
||||
if got != "network unreachable" {
|
||||
t.Errorf("short err should pass through unchanged, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAppMetaErr_LarkOfficeDomain(t *testing.T) {
|
||||
msg := `... grant link: https://open.larksuite.com/app/cli_xyz/auth?q=application:application:self_manage&op_from=openapi&token_type=tenant ...`
|
||||
got := describeAppMetaErr(errors.New(msg))
|
||||
if !strings.Contains(got, "open.larksuite.com") {
|
||||
t.Errorf("want larksuite URL extracted, got: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/bus"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go.
|
||||
func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
var domain string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "_bus",
|
||||
Short: "Internal event bus daemon (do not call directly)",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Sanitize AppID: an unsanitized value could escape events/ via ".." or separators.
|
||||
eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID))
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger)
|
||||
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||
defer signal.Stop(sigCh)
|
||||
go func() {
|
||||
select {
|
||||
case <-sigCh:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
return b.Run(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&domain, "domain", "", "API domain")
|
||||
_ = cmd.Flags().MarkHidden("domain")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
|
||||
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
|
||||
host, appID, strings.Join(scopes, ","))
|
||||
}
|
||||
|
||||
// consoleEventSubscriptionURL points at the app's event subscription console page.
|
||||
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/event", host, appID)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
|
||||
"im:message:readonly",
|
||||
"im:message.group_at_msg",
|
||||
})
|
||||
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
|
||||
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
|
||||
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
|
||||
t.Errorf("unexpected url: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/consume"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type consumeCmdOpts struct {
|
||||
params []string
|
||||
jqExpr string
|
||||
quiet bool
|
||||
outputDir string
|
||||
|
||||
maxEvents int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewCmdConsume(f *cmdutil.Factory) *cobra.Command {
|
||||
var o consumeCmdOpts
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "consume <EventKey>",
|
||||
Short: "Start consuming events for an EventKey",
|
||||
Long: `Start consuming real-time events for the given EventKey.
|
||||
|
||||
The consume command connects to the event bus daemon (starting it if needed),
|
||||
subscribes to the specified EventKey, and streams processed events to stdout.
|
||||
|
||||
Output is one JSON object per line (NDJSON). Pipe through 'jq .' if you need
|
||||
pretty-printed formatting.
|
||||
|
||||
Use 'event list' to see all available EventKeys.
|
||||
Use 'event schema <EventKey>' for parameter details.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConsume(cmd, f, args[0], o)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringArrayVarP(&o.params, "param", "p", nil, "Key=value parameter (repeatable)")
|
||||
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
||||
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
||||
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
|
||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consumeCmdOpts) error {
|
||||
// Pipe-close (e.g. `... | head -n 1`) must reach the EPIPE error path in the loop, not SIGPIPE-kill.
|
||||
ignoreBrokenPipe()
|
||||
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
paramMap, err := parseParams(o.params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keyDef, ok := eventlib.Lookup(eventKey)
|
||||
if !ok {
|
||||
return unknownEventKeyErr(eventKey)
|
||||
}
|
||||
|
||||
identity, err := resolveIdentity(cmd, f, keyDef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
err.Error(),
|
||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
outputDir := o.outputDir
|
||||
if outputDir != "" {
|
||||
safePath, err := sanitizeOutputDir(outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir = safePath
|
||||
}
|
||||
|
||||
domain := core.ResolveEndpoints(cfg.Brand).Open
|
||||
|
||||
// Surface auth errors before forking the bus daemon.
|
||||
if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiClient, err := f.NewAPIClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime := &consumeRuntime{client: apiClient, accessIdentity: identity}
|
||||
// botRuntime pins AsBot: /app_versions rejects UAT (99991668) and /connection is app-level.
|
||||
botRuntime := &consumeRuntime{client: apiClient, accessIdentity: core.AsBot}
|
||||
|
||||
// Weak-dependency fetch: failures leave appVer==nil and downgrade preflight to a no-op.
|
||||
preflightErrOut := f.IOStreams.ErrOut
|
||||
if o.quiet {
|
||||
preflightErrOut = io.Discard
|
||||
}
|
||||
appVer, appVerErr := appmeta.FetchCurrentPublished(cmd.Context(), botRuntime, cfg.AppID)
|
||||
switch {
|
||||
case appVerErr != nil:
|
||||
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(appVerErr))
|
||||
case appVer == nil:
|
||||
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
|
||||
}
|
||||
|
||||
pf := &preflightCtx{
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := preflightScopes(cmd.Context(), pf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer signal.Stop(sigCh)
|
||||
go func() {
|
||||
select {
|
||||
case <-sigCh:
|
||||
if !o.quiet && f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "\nShutting down...")
|
||||
}
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
errOut := f.IOStreams.ErrOut
|
||||
if o.quiet {
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||
}
|
||||
|
||||
if err := consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{
|
||||
EventKey: eventKey,
|
||||
Params: paramMap,
|
||||
JQExpr: o.jqExpr,
|
||||
Quiet: o.quiet,
|
||||
OutputDir: outputDir,
|
||||
Runtime: runtime,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: errOut,
|
||||
RemoteAPIClient: botRuntime,
|
||||
MaxEvents: o.maxEvents,
|
||||
Timeout: o.timeout,
|
||||
IsTTY: f.IOStreams.IsTerminal,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist.
|
||||
func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (core.Identity, error) {
|
||||
flagAs := core.Identity(cmd.Flag("as").Value.String())
|
||||
identity := f.ResolveAs(cmd.Context(), cmd, flagAs)
|
||||
if len(keyDef.AuthTypes) > 0 {
|
||||
if err := f.CheckIdentity(identity, keyDef.AuthTypes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
type preflightCtx struct {
|
||||
factory *cmdutil.Factory
|
||||
appID string
|
||||
brand core.LarkBrand
|
||||
eventKey string
|
||||
identity core.Identity
|
||||
keyDef *eventlib.KeyDefinition
|
||||
appVer *appmeta.AppVersion
|
||||
}
|
||||
|
||||
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
|
||||
func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(pf.keyDef.Scopes) == 0 || pf.identity == "" {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
var storedScopes string
|
||||
switch {
|
||||
case pf.identity.IsBot():
|
||||
if pf.appVer == nil {
|
||||
return nil
|
||||
}
|
||||
storedScopes = strings.Join(pf.appVer.TenantScopes, " ")
|
||||
case pf.identity == core.AsUser:
|
||||
result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID))
|
||||
if err != nil || result == nil || result.Scopes == "" {
|
||||
return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error
|
||||
}
|
||||
storedScopes = result.Scopes
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
missing := auth.MissingScopes(storedScopes, pf.keyDef.Scopes)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
||||
)
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
|
||||
if identity.IsBot() {
|
||||
return fmt.Sprintf(
|
||||
"grant these scopes and publish a new app version at: %s",
|
||||
consoleScopeGrantURL(brand, appID, missing),
|
||||
)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
|
||||
strings.Join(missing, " "),
|
||||
)
|
||||
}
|
||||
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
|
||||
func preflightEventTypes(pf *preflightCtx) error {
|
||||
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
return nil
|
||||
}
|
||||
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
|
||||
for _, t := range pf.appVer.EventTypes {
|
||||
subscribed[t] = true
|
||||
}
|
||||
var missing []string
|
||||
for _, t := range pf.keyDef.RequiredConsoleEvents {
|
||||
if !subscribed[t] {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
||||
)
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
func sanitizeOutputDir(dir string) (string, error) {
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
|
||||
// resolveTenantToken fetches the app's tenant access token.
|
||||
func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
||||
)
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
errOutputDirUnsafe = errors.New("unsafe --output-dir")
|
||||
)
|
||||
|
||||
func parseParams(raw []string) (map[string]string, error) {
|
||||
m := make(map[string]string)
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// watchStdinEOF drains r until EOF, writes a diagnostic, then cancels; only safe in non-TTY mode.
|
||||
func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
||||
go func() {
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
fmt.Fprintln(errOut, "[event] stdin closed — shutting down. "+
|
||||
"consume treats stdin EOF as exit signal (wired for AI subprocess callers). "+
|
||||
"To keep running: pass --max-events/--timeout for bounded run, "+
|
||||
"or keep stdin open (e.g. `< /dev/tty` interactive, `< <(tail -f /dev/null)` script), "+
|
||||
"or stop via SIGTERM instead of closing stdin.")
|
||||
cancel()
|
||||
}()
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatchStdinEOF_CancelsOnEOF(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
watchStdinEOF(strings.NewReader(""), cancel, io.Discard)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchStdinEOF_StaysAliveWhileReaderBlocks(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
pr, _ := io.Pipe()
|
||||
defer pr.Close()
|
||||
|
||||
watchStdinEOF(pr, cancel, io.Discard)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("watchStdinEOF cancelled without EOF")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// On EOF the watcher must emit a diagnostic naming stdin close + workarounds (daemon-style callers depend on it).
|
||||
func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
watchStdinEOF(strings.NewReader(""), cancel, &buf)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
got := buf.String()
|
||||
for _, want := range []string{"stdin closed", "--max-events", "--timeout", "SIGTERM"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("diagnostic missing %q; got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseParams(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want map[string]string
|
||||
wantSentry error
|
||||
wantEcho string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
in: nil,
|
||||
want: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "single key=value",
|
||||
in: []string{"mailbox=user@example.com"},
|
||||
want: map[string]string{"mailbox": "user@example.com"},
|
||||
},
|
||||
{
|
||||
name: "multiple pairs",
|
||||
in: []string{"a=1", "b=2", "c=3"},
|
||||
want: map[string]string{"a": "1", "b": "2", "c": "3"},
|
||||
},
|
||||
{
|
||||
name: "value containing = is kept intact",
|
||||
in: []string{"filter=foo=bar"},
|
||||
want: map[string]string{"filter": "foo=bar"},
|
||||
},
|
||||
{
|
||||
name: "empty value allowed",
|
||||
in: []string{"key="},
|
||||
want: map[string]string{"key": ""},
|
||||
},
|
||||
{
|
||||
name: "duplicate key — last wins",
|
||||
in: []string{"k=1", "k=2"},
|
||||
want: map[string]string{"k": "2"},
|
||||
},
|
||||
{
|
||||
name: "missing = separator",
|
||||
in: []string{"mailbox"},
|
||||
wantSentry: errInvalidParamFormat,
|
||||
wantEcho: `"mailbox"`,
|
||||
},
|
||||
{
|
||||
name: "leading = (empty key)",
|
||||
in: []string{"=value"},
|
||||
wantSentry: errInvalidParamFormat,
|
||||
wantEcho: `"=value"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseParams(tc.in)
|
||||
if tc.wantSentry != nil {
|
||||
if err == nil {
|
||||
t.Fatalf("want error wrapping %v, got nil", tc.wantSentry)
|
||||
}
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
||||
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len = %d, want %d; got=%v", len(got), len(tc.want), got)
|
||||
}
|
||||
for k, v := range tc.want {
|
||||
if got[k] != v {
|
||||
t.Errorf("key %q: got %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutputDir(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantSentry error
|
||||
}{
|
||||
{
|
||||
name: "relative path accepted",
|
||||
in: "./output",
|
||||
},
|
||||
{
|
||||
name: "nested relative path accepted",
|
||||
in: "events/today",
|
||||
},
|
||||
{
|
||||
name: "tilde rejected explicitly",
|
||||
in: "~/events",
|
||||
wantSentry: errOutputDirTilde,
|
||||
},
|
||||
{
|
||||
name: "parent escape rejected",
|
||||
in: "../outside",
|
||||
wantSentry: errOutputDirUnsafe,
|
||||
},
|
||||
{
|
||||
name: "absolute path rejected",
|
||||
in: "/tmp/events",
|
||||
wantSentry: errOutputDirUnsafe,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := sanitizeOutputDir(tc.in)
|
||||
if tc.wantSentry != nil {
|
||||
if err == nil {
|
||||
t.Fatalf("want error wrapping %v, got nil (path=%q)", tc.wantSentry, got)
|
||||
}
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == "" {
|
||||
t.Errorf("expected non-empty safe path, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "event",
|
||||
Short: "Consume and manage real-time events",
|
||||
Long: `Unified event consumption system. Use 'event consume <EventKey>' to start consuming events.`,
|
||||
// Without SilenceUsage, RunE errors print the full flag help banner.
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
cmd.AddCommand(NewCmdConsume(f))
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdSchema(f))
|
||||
cmd.AddCommand(NewCmdStatus(f))
|
||||
cmd.AddCommand(NewCmdStop(f))
|
||||
cmd.AddCommand(NewCmdBus(f))
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWriteStopJSON_ShapeAndEmpty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStopJSON(&buf, []stopResult{
|
||||
{AppID: "cli_XXXXXXXXXXXXXXXX", Status: stopStopped, PID: 42},
|
||||
{AppID: "cli_YYYYYYYYYYYYYYYY", Status: stopRefused, PID: 43, Reason: "2 active consumer(s)"},
|
||||
}); err != nil {
|
||||
t.Fatalf("writeStopJSON: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if len(got.Results) != 2 {
|
||||
t.Fatalf("results len = %d, want 2", len(got.Results))
|
||||
}
|
||||
if got.Results[0]["status"] != "stopped" {
|
||||
t.Errorf("results[0].status = %v, want stopped", got.Results[0]["status"])
|
||||
}
|
||||
if got.Results[1]["status"] != "refused" {
|
||||
t.Errorf("results[1].status = %v, want refused", got.Results[1]["status"])
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := writeStopJSON(&buf, nil); err != nil {
|
||||
t.Fatalf("writeStopJSON(nil): %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("nil output is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if got.Results == nil || len(got.Results) != 0 {
|
||||
t.Errorf("results = %v, want []", got.Results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStopText_RoutesToStdoutOrStderr(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
writeStopText(&out, &errOut, []stopResult{
|
||||
{AppID: "cli_XXXXXXXXXXXXXXXX", Status: stopStopped, PID: 1},
|
||||
{AppID: "cli_YYYYYYYYYYYYYYYY", Status: stopNoBus},
|
||||
{AppID: "cli_ZZZZZZZZZZZZZZZZ", Status: stopRefused, Reason: "busy"},
|
||||
{AppID: "cli_WWWWWWWWWWWWWWWW", Status: stopErrored, Reason: "kill failed"},
|
||||
})
|
||||
if !strings.Contains(out.String(), "Bus stopped for cli_XXXXXXXXXXXXXXXX") {
|
||||
t.Errorf("stopped line missing from stdout: %q", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "No bus running for cli_YYYYYYYYYYYYYYYY") {
|
||||
t.Errorf("no-bus line missing from stdout: %q", out.String())
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Refused stopping cli_ZZZZZZZZZZZZZZZZ: busy") {
|
||||
t.Errorf("refused line missing from stderr: %q", errOut.String())
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Error stopping cli_WWWWWWWWWWWWWWWW: kill failed") {
|
||||
t.Errorf("error line missing from stderr: %q", errOut.String())
|
||||
}
|
||||
if strings.Contains(out.String(), "Refused") || strings.Contains(out.String(), "Error") {
|
||||
t.Errorf("failure lines leaked to stdout: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusState_String(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
s busState
|
||||
want string
|
||||
}{
|
||||
{stateNotRunning, "not_running"},
|
||||
{stateRunning, "running"},
|
||||
{stateOrphan, "orphan"},
|
||||
} {
|
||||
if got := tc.s.String(); got != tc.want {
|
||||
t.Errorf("busState(%d).String() = %q, want %q", tc.s, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanizeDuration_AllBuckets(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{30 * time.Second, "30s ago"},
|
||||
{90 * time.Second, "1m ago"},
|
||||
{2 * time.Hour, "2h ago"},
|
||||
{50 * time.Hour, "2d ago"},
|
||||
} {
|
||||
if got := humanizeDuration(tc.d); got != tc.want {
|
||||
t.Errorf("humanizeDuration(%v) = %q, want %q", tc.d, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_CoversAllStates(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{AppID: "cli_NOTRUNNINGXXXXXX", State: stateNotRunning},
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 3661,
|
||||
Active: 2,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 10, EventKey: "im.message.receive_v1", Received: 5, Dropped: 0},
|
||||
{PID: 11, EventKey: "im.message.receive_v1", Received: 3, Dropped: 1},
|
||||
},
|
||||
},
|
||||
{AppID: "cli_ORPHANXXXXXXXXXX", State: stateOrphan, PID: 5678, UptimeSec: 3600},
|
||||
})
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"── cli_NOTRUNNINGXXXXXX ──",
|
||||
"Bus: not running",
|
||||
"── cli_RUNNINGXXXXXXXXX ──",
|
||||
"running (PID 1234",
|
||||
"Active consumers: 2",
|
||||
"im.message.receive_v1",
|
||||
"── cli_ORPHANXXXXXXXXXX ──",
|
||||
"orphan (PID 5678",
|
||||
"Action: kill 5678",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("writeStatusText missing %q; full:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStatusJSON(&buf, []appStatus{
|
||||
{AppID: "cli_ORPHANXXXXXXXXXX", State: stateOrphan, PID: 99, UptimeSec: 60},
|
||||
{AppID: "cli_RUNNINGXXXXXXXXX", State: stateRunning, PID: 1, UptimeSec: 10, Active: 0},
|
||||
}); err != nil {
|
||||
t.Fatalf("writeStatusJSON: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Apps []map[string]interface{} `json:"apps"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("output is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if len(got.Apps) != 2 {
|
||||
t.Fatalf("apps len = %d", len(got.Apps))
|
||||
}
|
||||
orphan := got.Apps[0]
|
||||
if orphan["status"] != "orphan" {
|
||||
t.Errorf("orphan status = %v", orphan["status"])
|
||||
}
|
||||
if orphan["suggested_action"] != "kill 99" {
|
||||
t.Errorf("orphan suggested_action = %v, want 'kill 99'", orphan["suggested_action"])
|
||||
}
|
||||
if orphan["issue"] == nil {
|
||||
t.Error("orphan issue missing")
|
||||
}
|
||||
run := got.Apps[1]
|
||||
if run["issue"] != nil {
|
||||
t.Errorf("running entry leaked issue: %v", run["issue"])
|
||||
}
|
||||
if run["suggested_action"] != nil {
|
||||
t.Errorf("running entry leaked suggested_action: %v", run["suggested_action"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitForOrphan(t *testing.T) {
|
||||
orphan := []appStatus{{State: stateOrphan}}
|
||||
running := []appStatus{{State: stateRunning}}
|
||||
|
||||
if err := exitForOrphan(orphan, false); err != nil {
|
||||
t.Errorf("flag off + orphan → nil expected, got %v", err)
|
||||
}
|
||||
if err := exitForOrphan(running, false); err != nil {
|
||||
t.Errorf("flag off + running → nil expected, got %v", err)
|
||||
}
|
||||
|
||||
if err := exitForOrphan(running, true); err != nil {
|
||||
t.Errorf("flag on + no orphan → nil expected, got %v", err)
|
||||
}
|
||||
err := exitForOrphan(orphan, true)
|
||||
if err == nil {
|
||||
t.Fatal("flag on + orphan → expected error, got nil")
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %v, want ExitValidation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func errorAs(err error, target interface{}) bool {
|
||||
if e, ok := err.(*output.ExitError); ok {
|
||||
if t, ok := target.(**output.ExitError); ok {
|
||||
*t = e
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestNewCmdFactories_WireFlags(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
|
||||
|
||||
t.Run("consume", func(t *testing.T) {
|
||||
cmd := NewCmdConsume(f)
|
||||
for _, flag := range []string{"param", "jq", "quiet", "output-dir", "max-events", "timeout", "as"} {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("consume missing --%s flag", flag)
|
||||
}
|
||||
}
|
||||
if cmd.RunE == nil {
|
||||
t.Error("consume RunE is nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status", func(t *testing.T) {
|
||||
cmd := NewCmdStatus(f)
|
||||
for _, flag := range []string{"json", "current", "fail-on-orphan"} {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("status missing --%s flag", flag)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stop", func(t *testing.T) {
|
||||
cmd := NewCmdStop(f)
|
||||
for _, flag := range []string{"app-id", "all", "force", "json"} {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("stop missing --%s flag", flag)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
cmd := NewCmdList(f)
|
||||
if cmd.Flags().Lookup("json") == nil {
|
||||
t.Error("list missing --json flag")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bus", func(t *testing.T) {
|
||||
cmd := NewCmdBus(f)
|
||||
if !cmd.Hidden {
|
||||
t.Error("bus should be hidden (internal daemon entrypoint)")
|
||||
}
|
||||
if cmd.Flags().Lookup("domain") == nil {
|
||||
t.Error("bus missing --domain flag")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all available EventKeys",
|
||||
Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --json for machine-readable output.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runList(f, asJSON)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runList(f *cmdutil.Factory, asJSON bool) error {
|
||||
all := eventlib.ListAll()
|
||||
|
||||
if asJSON {
|
||||
return writeListJSON(f, all)
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
// stderr so `event list | jq` doesn't ingest it as a row.
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No EventKeys registered.")
|
||||
return nil
|
||||
}
|
||||
|
||||
type group struct {
|
||||
domain string
|
||||
keys []*eventlib.KeyDefinition
|
||||
}
|
||||
order := []string{}
|
||||
groups := map[string]*group{}
|
||||
|
||||
for _, def := range all {
|
||||
domain := def.Key
|
||||
if idx := strings.Index(def.Key, "."); idx > 0 {
|
||||
domain = def.Key[:idx]
|
||||
}
|
||||
g, ok := groups[domain]
|
||||
if !ok {
|
||||
g = &group{domain: domain}
|
||||
groups[domain] = g
|
||||
order = append(order, domain)
|
||||
}
|
||||
g.keys = append(g.keys, def)
|
||||
}
|
||||
|
||||
// Global widths (not per-section) keep "── domain ──" dividers aligned across groups.
|
||||
headers := []string{"KEY", "AUTH", "PARAMS", "DESCRIPTION"}
|
||||
rowsByDomain := make(map[string][][]string, len(order))
|
||||
var allRows [][]string
|
||||
for _, domain := range order {
|
||||
for _, def := range groups[domain].keys {
|
||||
auth := "-"
|
||||
if len(def.AuthTypes) > 0 {
|
||||
auth = strings.Join(def.AuthTypes, "|")
|
||||
}
|
||||
desc := def.Description
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
row := []string{
|
||||
def.Key,
|
||||
auth,
|
||||
fmt.Sprintf("%d", len(def.Params)),
|
||||
desc,
|
||||
}
|
||||
rowsByDomain[domain] = append(rowsByDomain[domain], row)
|
||||
allRows = append(allRows, row)
|
||||
}
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
const colGap = " "
|
||||
widths := tableWidths(headers, allRows)
|
||||
printTableRow(out, widths, headers, colGap)
|
||||
for _, domain := range order {
|
||||
fmt.Fprintf(out, "\n── %s ──\n", domain)
|
||||
for _, row := range rowsByDomain[domain] {
|
||||
printTableRow(out, widths, row, colGap)
|
||||
}
|
||||
}
|
||||
// stderr keeps stdout pipe-clean for `event list | jq`.
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "\nUse 'event schema <key>' for details.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeListJSON(f *cmdutil.Factory, all []*eventlib.KeyDefinition) error {
|
||||
type row struct {
|
||||
*eventlib.KeyDefinition
|
||||
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
|
||||
}
|
||||
rows := make([]row, len(all))
|
||||
for i, def := range all {
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows[i] = row{KeyDefinition: def, ResolvedSchema: resolved}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, rows)
|
||||
return nil
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestRunList_TextOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runList(f, false); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunList_JSONOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runList(f, true); err != nil {
|
||||
t.Fatalf("runList json: %v", err)
|
||||
}
|
||||
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("expected at least one EventKey in JSON output")
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
for _, field := range []string{"key", "event_type", "schema"} {
|
||||
if row[field] == nil {
|
||||
t.Errorf("row missing %q: %+v", field, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
key := ""
|
||||
if keyDef != nil {
|
||||
key = keyDef.Key
|
||||
}
|
||||
return &preflightCtx{
|
||||
appID: appID,
|
||||
brand: brand,
|
||||
eventKey: key,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_NilAppVer_SkipsCheck(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
EventType: "im.message.receive_v1",
|
||||
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
||||
}
|
||||
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, nil)); err != nil {
|
||||
t.Fatalf("nil appVer must be a weak-dependency skip, got err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_EmptyRequired_SkipsEvenIfEventTypeSet(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.message_read_v1",
|
||||
EventType: "im.message.message_read_v1",
|
||||
}
|
||||
appVer := &appmeta.AppVersion{EventTypes: []string{"im.message.receive_v1"}}
|
||||
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil {
|
||||
t.Fatalf("empty RequiredConsoleEvents must skip, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_AllSubscribed_Passes(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.reaction",
|
||||
EventType: "im.message.reaction.created_v1",
|
||||
RequiredConsoleEvents: []string{
|
||||
"im.message.reaction.created_v1",
|
||||
"im.message.reaction.deleted_v1",
|
||||
},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{EventTypes: []string{
|
||||
"im.message.reaction.created_v1",
|
||||
"im.message.reaction.deleted_v1",
|
||||
"im.message.receive_v1",
|
||||
}}
|
||||
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "mail.receive",
|
||||
EventType: "mail.user_mailbox.event.message_received_v1",
|
||||
RequiredConsoleEvents: []string{
|
||||
"mail.user_mailbox.event.message_received_v1",
|
||||
"mail.user_mailbox.event.message_read_v1",
|
||||
},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{EventTypes: []string{
|
||||
"mail.user_mailbox.event.message_received_v1",
|
||||
}}
|
||||
err := preflightEventTypes(newPreflightCtx("cli_XXXXXXXXXXXXXXXX", "feishu", "", def, appVer))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing subscription")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||
t.Errorf("error should name the missing event type, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitValidation {
|
||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint")
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("bot + nil appVer should skip, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{TenantScopes: []string{
|
||||
"im:message",
|
||||
"im:message.group_at_msg",
|
||||
"contact:user:readonly",
|
||||
}}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
|
||||
if err != nil {
|
||||
t.Fatalf("all scopes granted, unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing scope")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||
t.Errorf("error should name missing scope, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
}
|
||||
hint := exit.Detail.Hint
|
||||
wantSubstrings := []string{
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
"token_type=tenant",
|
||||
}
|
||||
for _, want := range wantSubstrings {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("hint missing %q\ngot: %s", want, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{Key: "x"}
|
||||
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil {
|
||||
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// consumeRuntime routes event.APIClient calls through the shared client.APIClient with a pinned identity.
|
||||
type consumeRuntime struct {
|
||||
client *client.APIClient
|
||||
accessIdentity core.Identity
|
||||
}
|
||||
|
||||
func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
resp, err := r.client.DoAPI(ctx, client.RawApiRequest{
|
||||
Method: method,
|
||||
URL: path,
|
||||
Data: body,
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if resp.StatusCode >= 400 && !client.IsJSONContentType(ct) && ct != "" {
|
||||
const maxBodyEcho = 256
|
||||
body := string(resp.RawBody)
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
}
|
||||
return json.RawMessage(resp.RawBody), nil
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// resolveSchemaJSON returns the final JSON Schema for an EventKey (reflected base, V2-wrapped for Native, overlay applied); orphans lists unresolved FieldOverrides pointers.
|
||||
func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string, error) {
|
||||
spec, isNative := pickSpec(def.Schema)
|
||||
if spec == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
base, err := renderSpec(spec)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if base == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
if isNative {
|
||||
base = schemas.WrapV2Envelope(base)
|
||||
}
|
||||
|
||||
if len(def.Schema.FieldOverrides) > 0 {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
|
||||
return base, nil, nil
|
||||
}
|
||||
|
||||
// pickSpec returns the non-nil spec and whether it is Native (requires V2 envelope wrap).
|
||||
func pickSpec(s eventlib.SchemaDef) (*eventlib.SchemaSpec, bool) {
|
||||
if s.Native != nil {
|
||||
return s.Native, true
|
||||
}
|
||||
if s.Custom != nil {
|
||||
return s.Custom, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// renderSpec produces a JSON Schema from Type (reflected) or Raw (copied).
|
||||
func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
if s.Type != nil {
|
||||
return schemas.FromType(s.Type), nil
|
||||
}
|
||||
if len(s.Raw) > 0 {
|
||||
buf := make(json.RawMessage, len(s.Raw))
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema <EventKey>",
|
||||
Short: "Show details for an EventKey",
|
||||
Long: "Display detailed information about an EventKey including type, events, parameters, and response schema. Use --json for machine-readable output.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runSchema(f, args[0], asJSON)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
def, ok := eventlib.Lookup(key)
|
||||
if !ok {
|
||||
return unknownEventKeyErr(key)
|
||||
}
|
||||
|
||||
if asJSON {
|
||||
return writeSchemaJSON(f, def)
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
|
||||
fmt.Fprintf(out, "Key: %s\n", def.Key)
|
||||
if def.Description != "" {
|
||||
fmt.Fprintf(out, "Description: %s\n", def.Description)
|
||||
}
|
||||
fmt.Fprintf(out, "Event: %s\n", def.EventType)
|
||||
|
||||
if def.PreConsume != nil {
|
||||
fmt.Fprintf(out, "Pre-consume: yes\n")
|
||||
}
|
||||
|
||||
if len(def.Scopes) > 0 {
|
||||
fmt.Fprintf(out, "\nRequired Scopes:\n")
|
||||
for _, s := range def.Scopes {
|
||||
fmt.Fprintf(out, " - %s\n", s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(def.RequiredConsoleEvents) > 0 {
|
||||
fmt.Fprintf(out, "\nRequired Console Events (must be enabled in developer console):\n")
|
||||
for _, e := range def.RequiredConsoleEvents {
|
||||
fmt.Fprintf(out, " - %s\n", e)
|
||||
}
|
||||
}
|
||||
|
||||
if len(def.Params) > 0 {
|
||||
fmt.Fprintf(out, "\nParameters:\n")
|
||||
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
|
||||
for _, p := range def.Params {
|
||||
required := "no"
|
||||
if p.Required {
|
||||
required = "yes"
|
||||
}
|
||||
defaultVal := p.Default
|
||||
if defaultVal == "" {
|
||||
defaultVal = "-"
|
||||
}
|
||||
desc := p.Description
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
// Inline Values below the table so AI consumers see allowed enum/multi values without --json.
|
||||
for _, p := range def.Params {
|
||||
if len(p.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, "\n %s values:\n", p.Name)
|
||||
vw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
for _, v := range p.Values {
|
||||
fmt.Fprintf(vw, " %s\t%s\n", v.Value, v.Desc)
|
||||
}
|
||||
vw.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
printIndentedJSON(out, resolved)
|
||||
} else {
|
||||
fmt.Fprintf(out, "\nOutput Schema: (schema not declared)\n")
|
||||
if def.Schema.Native != nil {
|
||||
fmt.Fprintf(out, " Consumers receive the V2 envelope: {schema, header, event}.\n")
|
||||
fmt.Fprintf(out, " Inspect real payloads via `lark-cli event consume %s`.\n", def.Key)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printIndentedJSON pretty-prints raw JSON with a 2-space leading indent.
|
||||
func printIndentedJSON(out io.Writer, raw json.RawMessage) {
|
||||
var parsed json.RawMessage
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
fmt.Fprintln(out, " <invalid JSON>")
|
||||
return
|
||||
}
|
||||
formatted, err := json.MarshalIndent(parsed, " ", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, " %s\n", string(formatted))
|
||||
}
|
||||
|
||||
// writeSchemaJSON emits the EventKey definition plus resolved schema; jq_root_path tells callers whether fields live at `.` or `.event`.
|
||||
func writeSchemaJSON(f *cmdutil.Factory, def *eventlib.KeyDefinition) error {
|
||||
type payload struct {
|
||||
*eventlib.KeyDefinition
|
||||
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
|
||||
JQRootPath string `json:"jq_root_path,omitempty"`
|
||||
}
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var jqRootPath string
|
||||
if resolved != nil {
|
||||
// Native → V2 envelope ⇒ `.event.xxx`; Custom → flat ⇒ `.`.
|
||||
_, isNative := pickSpec(def.Schema)
|
||||
jqRootPath = "."
|
||||
if isNative {
|
||||
jqRootPath = ".event"
|
||||
}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, payload{
|
||||
KeyDefinition: def,
|
||||
ResolvedSchema: resolved,
|
||||
JQRootPath: jqRootPath,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Key:", "im.message.receive_v1",
|
||||
"Event:", "im.message.receive_v1",
|
||||
"Output Schema:",
|
||||
`"message_id"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("schema output missing %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.message_read_v1", false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Output Schema:",
|
||||
`"schema"`,
|
||||
`"header"`,
|
||||
`"event"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("native schema output missing %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
err := runSchema(f, "im.message.recieve_v1", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown key")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "unknown EventKey") {
|
||||
t.Errorf("error should mention unknown EventKey: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "im.message.receive_v1") {
|
||||
t.Errorf("error should suggest the real key name (typo correction): %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
for _, field := range []string{"key", "event_type", "schema", "resolved_output_schema"} {
|
||||
if _, ok := payload[field]; !ok {
|
||||
t.Errorf("JSON output missing field %q: %+v", field, payload)
|
||||
}
|
||||
}
|
||||
if payload["key"] != "im.message.receive_v1" {
|
||||
t.Errorf("key = %v, want im.message.receive_v1", payload["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
const syntheticKey = "t.custom.overlay"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
type out struct {
|
||||
SenderID string `json:"sender_id"`
|
||||
}
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Schema: eventlib.SchemaDef{
|
||||
Custom: &eventlib.SchemaSpec{Type: reflect.TypeOf(out{})},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{
|
||||
"/sender_id": {Kind: "open_id"},
|
||||
},
|
||||
},
|
||||
Process: func(context.Context, eventlib.APIClient, *eventlib.RawEvent, map[string]string) (json.RawMessage, error) {
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
def, _ := eventlib.Lookup(syntheticKey)
|
||||
resolved, orphans, err := resolveSchemaJSON(def)
|
||||
if err != nil || len(orphans) != 0 {
|
||||
t.Fatalf("resolve: err=%v orphans=%v", err, orphans)
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(resolved, &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := parsed["properties"].(map[string]interface{})["sender_id"].(map[string]interface{})["format"]
|
||||
if got != "open_id" {
|
||||
t.Errorf("overlay format = %v, want open_id", got)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build unix
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// ignoreBrokenPipe stops Go's default SIGPIPE-on-stdout terminate behavior.
|
||||
// Subsequent stdout writes return syscall.EPIPE so consume can shut down cleanly.
|
||||
func ignoreBrokenPipe() {
|
||||
signal.Ignore(syscall.SIGPIPE)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package event
|
||||
|
||||
// ignoreBrokenPipe is a no-op on Windows (no SIGPIPE; closed-pipe writes return ERROR_BROKEN_PIPE directly).
|
||||
func ignoreBrokenPipe() {}
|
||||
@@ -1,328 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/event/busctl"
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
asJSON bool
|
||||
current bool
|
||||
failOnOrphan bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show event bus daemon status for all discovered apps",
|
||||
Long: "Connect to each bus daemon under the config-dir/events/ tree and show PID, uptime, and active consumers. Use --current for only the current profile's app. Use --json for machine-readable output. Use --fail-on-orphan to exit 2 when any orphan bus is detected (for health checks).",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runStatus(f, current, asJSON, failOnOrphan)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit status as JSON (for AI / scripts)")
|
||||
cmd.Flags().BoolVar(¤t, "current", false, "Only show status for the current profile's app")
|
||||
cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "Exit 2 when any orphan bus is detected (default: always exit 0)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type busState int
|
||||
|
||||
const (
|
||||
stateNotRunning busState = iota
|
||||
stateRunning
|
||||
stateOrphan
|
||||
)
|
||||
|
||||
func (s busState) String() string {
|
||||
switch s {
|
||||
case stateRunning:
|
||||
return "running"
|
||||
case stateOrphan:
|
||||
return "orphan"
|
||||
default:
|
||||
return "not_running"
|
||||
}
|
||||
}
|
||||
|
||||
// appStatus bundles one AppID's derived status; State picks which fields are meaningful.
|
||||
type appStatus struct {
|
||||
AppID string
|
||||
State busState
|
||||
PID int
|
||||
UptimeSec int
|
||||
Active int
|
||||
Consumers []protocol.ConsumerInfo
|
||||
}
|
||||
|
||||
type busQuerier interface {
|
||||
QueryBusStatus(appID string) (*protocol.StatusResponse, error)
|
||||
}
|
||||
|
||||
// singleAppScanner wraps a Scanner and filters to one AppID for --current queries.
|
||||
type singleAppScanner struct {
|
||||
appID string
|
||||
inner busdiscover.Scanner
|
||||
}
|
||||
|
||||
func (s singleAppScanner) ScanBusProcesses() ([]busdiscover.Process, error) {
|
||||
if s.inner == nil {
|
||||
return nil, nil
|
||||
}
|
||||
all, err := s.inner.ScanBusProcesses()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := all[:0]
|
||||
for _, p := range all {
|
||||
if p.AppID == s.appID {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type transportQuerier struct {
|
||||
tr transport.IPC
|
||||
}
|
||||
|
||||
func (q *transportQuerier) QueryBusStatus(appID string) (*protocol.StatusResponse, error) {
|
||||
return busctl.QueryStatus(q.tr, appID)
|
||||
}
|
||||
|
||||
func runStatus(f *cmdutil.Factory, current, asJSON, failOnOrphan bool) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seeds := map[string]struct{}{}
|
||||
if current {
|
||||
seeds[cfg.AppID] = struct{}{}
|
||||
} else {
|
||||
for _, id := range discoverAppIDs() {
|
||||
seeds[id] = struct{}{}
|
||||
}
|
||||
// Always include the current profile so a first-time user sees it as not_running.
|
||||
seeds[cfg.AppID] = struct{}{}
|
||||
}
|
||||
seedList := make([]string, 0, len(seeds))
|
||||
for id := range seeds {
|
||||
seedList = append(seedList, id)
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
// --current: scope the scanner to this AppID so unrelated orphans don't surface.
|
||||
var scanner busdiscover.Scanner
|
||||
if current {
|
||||
scanner = singleAppScanner{appID: cfg.AppID, inner: busdiscover.Default()}
|
||||
} else {
|
||||
scanner = busdiscover.Default()
|
||||
}
|
||||
statuses := deriveStatuses(
|
||||
seedList,
|
||||
scanner,
|
||||
&transportQuerier{tr: tr},
|
||||
time.Now(),
|
||||
)
|
||||
|
||||
if asJSON {
|
||||
if err := writeStatusJSON(f.IOStreams.Out, statuses); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
writeStatusText(f.IOStreams.Out, statuses)
|
||||
}
|
||||
return exitForOrphan(statuses, failOnOrphan)
|
||||
}
|
||||
|
||||
// deriveStatuses classifies each AppID as running/orphan/not_running from socket + process-scan inputs; scanner errors are non-fatal.
|
||||
func deriveStatuses(seedAppIDs []string, sc busdiscover.Scanner, q busQuerier, now time.Time) []appStatus {
|
||||
procByAppID := map[string]busdiscover.Process{}
|
||||
if sc != nil {
|
||||
if procs, err := sc.ScanBusProcesses(); err == nil {
|
||||
for _, p := range procs {
|
||||
procByAppID[p.AppID] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ids := map[string]struct{}{}
|
||||
for _, id := range seedAppIDs {
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
for id := range procByAppID {
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
sorted := make([]string, 0, len(ids))
|
||||
for id := range ids {
|
||||
sorted = append(sorted, id)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
|
||||
// Query in parallel so one wedged peer can't compound the per-op deadline across many apps.
|
||||
type probe struct {
|
||||
resp *protocol.StatusResponse
|
||||
err error
|
||||
}
|
||||
probes := make([]probe, len(sorted))
|
||||
var wg sync.WaitGroup
|
||||
for i, appID := range sorted {
|
||||
wg.Add(1)
|
||||
go func(i int, appID string) {
|
||||
defer wg.Done()
|
||||
probes[i].resp, probes[i].err = q.QueryBusStatus(appID)
|
||||
}(i, appID)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
result := make([]appStatus, 0, len(sorted))
|
||||
for i, appID := range sorted {
|
||||
s := appStatus{AppID: appID, State: stateNotRunning}
|
||||
if probes[i].err == nil {
|
||||
resp := probes[i].resp
|
||||
s.State = stateRunning
|
||||
s.PID = resp.PID
|
||||
s.UptimeSec = resp.UptimeSec
|
||||
s.Active = resp.ActiveConns
|
||||
s.Consumers = resp.Consumers
|
||||
} else if p, ok := procByAppID[appID]; ok {
|
||||
s.State = stateOrphan
|
||||
s.PID = p.PID
|
||||
s.UptimeSec = int(now.Sub(p.StartTime).Seconds())
|
||||
}
|
||||
result = append(result, s)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// humanizeDuration formats d as a coarse "N unit ago" string.
|
||||
func humanizeDuration(d time.Duration) string {
|
||||
s := int(d.Seconds())
|
||||
if s < 60 {
|
||||
return fmt.Sprintf("%ds ago", s)
|
||||
}
|
||||
m := s / 60
|
||||
if m < 60 {
|
||||
return fmt.Sprintf("%dm ago", m)
|
||||
}
|
||||
h := m / 60
|
||||
if h < 24 {
|
||||
return fmt.Sprintf("%dh ago", h)
|
||||
}
|
||||
return fmt.Sprintf("%dd ago", h/24)
|
||||
}
|
||||
|
||||
func writeStatusText(out io.Writer, statuses []appStatus) {
|
||||
for i, s := range statuses {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
fmt.Fprintf(out, "── %s ──\n", s.AppID)
|
||||
switch s.State {
|
||||
case stateNotRunning:
|
||||
fmt.Fprintln(out, " Bus: not running")
|
||||
case stateRunning:
|
||||
fmt.Fprintf(out, " Bus: running (PID %d, uptime %s)\n",
|
||||
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
||||
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
||||
if len(s.Consumers) > 0 {
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
|
||||
rows := make([][]string, 0, len(s.Consumers))
|
||||
for _, c := range s.Consumers {
|
||||
rows = append(rows, []string{
|
||||
fmt.Sprintf("pid=%d", c.PID),
|
||||
c.EventKey,
|
||||
fmt.Sprintf("%d", c.Received),
|
||||
fmt.Sprintf("%d", c.Dropped),
|
||||
})
|
||||
}
|
||||
widths := tableWidths(headers, rows)
|
||||
const colGap = " "
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprint(out, " ")
|
||||
printTableRow(out, widths, headers, colGap)
|
||||
for _, row := range rows {
|
||||
fmt.Fprint(out, " ")
|
||||
printTableRow(out, widths, row, colGap)
|
||||
}
|
||||
}
|
||||
case stateOrphan:
|
||||
if s.PID == 0 {
|
||||
fmt.Fprintln(out, " Bus: orphan (PID unknown — bus.pid file unreadable)")
|
||||
fmt.Fprintln(out, " Issue: live bus detected but pid file is missing or corrupt")
|
||||
fmt.Fprintln(out, " Action: inspect ~/.lark-cli/events/<app>/bus.pid and kill manually")
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(out, " Bus: orphan (PID %d, started %s)\n",
|
||||
s.PID, humanizeDuration(time.Duration(s.UptimeSec)*time.Second))
|
||||
fmt.Fprintln(out, " Issue: socket file missing — consumers cannot connect")
|
||||
fmt.Fprintf(out, " Action: kill %d\n", s.PID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeStatusJSON(w io.Writer, statuses []appStatus) error {
|
||||
type jsonStatus struct {
|
||||
AppID string `json:"app_id"`
|
||||
Status string `json:"status"`
|
||||
Running bool `json:"running"` // backward compat
|
||||
PID int `json:"pid,omitempty"`
|
||||
UptimeSec int `json:"uptime_sec,omitempty"`
|
||||
Active int `json:"active_consumers,omitempty"`
|
||||
Consumers []protocol.ConsumerInfo `json:"consumers,omitempty"`
|
||||
Issue string `json:"issue,omitempty"`
|
||||
SuggestedAction string `json:"suggested_action,omitempty"`
|
||||
}
|
||||
payload := make([]jsonStatus, 0, len(statuses))
|
||||
for _, s := range statuses {
|
||||
js := jsonStatus{
|
||||
AppID: s.AppID,
|
||||
Status: s.State.String(),
|
||||
Running: s.State == stateRunning,
|
||||
PID: s.PID,
|
||||
UptimeSec: s.UptimeSec,
|
||||
Active: s.Active,
|
||||
Consumers: s.Consumers,
|
||||
}
|
||||
if s.State == stateOrphan {
|
||||
if s.PID == 0 {
|
||||
js.Issue = "live bus detected but pid file is missing or corrupt"
|
||||
js.SuggestedAction = "inspect events dir and kill manually"
|
||||
} else {
|
||||
js.Issue = "socket file missing"
|
||||
js.SuggestedAction = fmt.Sprintf("kill %d", s.PID)
|
||||
}
|
||||
}
|
||||
payload = append(payload, js)
|
||||
}
|
||||
output.PrintJson(w, map[string]interface{}{"apps": payload})
|
||||
return nil
|
||||
}
|
||||
|
||||
// exitForOrphan returns ExitValidation iff failOnOrphan and any status is orphan; default exit 0 preserves observe-only semantics.
|
||||
func exitForOrphan(statuses []appStatus, failOnOrphan bool) error {
|
||||
if !failOnOrphan {
|
||||
return nil
|
||||
}
|
||||
for _, s := range statuses {
|
||||
if s.State == stateOrphan {
|
||||
return output.ErrBare(output.ExitValidation)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestExitForOrphan_Orphan(t *testing.T) {
|
||||
statuses := []appStatus{
|
||||
{AppID: "cli_a", State: stateRunning},
|
||||
{AppID: "cli_b", State: stateOrphan, PID: 70926},
|
||||
}
|
||||
err := exitForOrphan(statuses, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when failOnOrphan=true and orphan present")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitForOrphan_NoOrphan(t *testing.T) {
|
||||
statuses := []appStatus{
|
||||
{AppID: "cli_a", State: stateRunning},
|
||||
{AppID: "cli_b", State: stateNotRunning},
|
||||
}
|
||||
if err := exitForOrphan(statuses, true); err != nil {
|
||||
t.Errorf("expected nil error when no orphan; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitForOrphan_FlagDisabled(t *testing.T) {
|
||||
statuses := []appStatus{
|
||||
{AppID: "cli_b", State: stateOrphan, PID: 70926},
|
||||
}
|
||||
if err := exitForOrphan(statuses, false); err != nil {
|
||||
t.Errorf("flag off should never return error; got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
type fakeScanner struct {
|
||||
procs []busdiscover.Process
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeScanner) ScanBusProcesses() ([]busdiscover.Process, error) {
|
||||
return f.procs, f.err
|
||||
}
|
||||
|
||||
type fakeBusQuerier struct {
|
||||
respByAppID map[string]*protocol.StatusResponse
|
||||
}
|
||||
|
||||
func (f *fakeBusQuerier) QueryBusStatus(appID string) (*protocol.StatusResponse, error) {
|
||||
if r, ok := f.respByAppID[appID]; ok {
|
||||
return r, nil
|
||||
}
|
||||
return nil, errors.New("dial failed")
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_RunningBus(t *testing.T) {
|
||||
q := &fakeBusQuerier{
|
||||
respByAppID: map[string]*protocol.StatusResponse{
|
||||
"cli_a": protocol.NewStatusResponse(12345, 150, 1, nil),
|
||||
},
|
||||
}
|
||||
sc := &fakeScanner{procs: nil}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
s := statuses[0]
|
||||
if s.State != stateRunning {
|
||||
t.Errorf("State = %v, want stateRunning", s.State)
|
||||
}
|
||||
if s.PID != 12345 {
|
||||
t.Errorf("PID = %d, want 12345", s.PID)
|
||||
}
|
||||
if s.UptimeSec != 150 {
|
||||
t.Errorf("UptimeSec = %d, want 150", s.UptimeSec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_OrphanBus(t *testing.T) {
|
||||
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
|
||||
sc := &fakeScanner{procs: []busdiscover.Process{
|
||||
{PID: 70926, AppID: "cli_a", StartTime: time.Now().Add(-19 * time.Hour)},
|
||||
}}
|
||||
|
||||
now := time.Now()
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, now)
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
s := statuses[0]
|
||||
if s.State != stateOrphan {
|
||||
t.Errorf("State = %v, want stateOrphan", s.State)
|
||||
}
|
||||
if s.PID != 70926 {
|
||||
t.Errorf("PID = %d, want 70926", s.PID)
|
||||
}
|
||||
wantUptime := int((19 * time.Hour).Seconds())
|
||||
if s.UptimeSec < wantUptime-60 || s.UptimeSec > wantUptime+60 {
|
||||
t.Errorf("UptimeSec = %d, want ~%d", s.UptimeSec, wantUptime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_NotRunning(t *testing.T) {
|
||||
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
|
||||
sc := &fakeScanner{procs: nil}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
s := statuses[0]
|
||||
if s.State != stateNotRunning {
|
||||
t.Errorf("State = %v, want stateNotRunning", s.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_DiscoversOrphanAppIDsFromProcessScan(t *testing.T) {
|
||||
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
|
||||
sc := &fakeScanner{procs: []busdiscover.Process{
|
||||
{PID: 70926, AppID: "cli_orphan", StartTime: time.Now().Add(-1 * time.Hour)},
|
||||
}}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_known"}, sc, q, time.Now())
|
||||
if len(statuses) != 2 {
|
||||
t.Fatalf("expected 2 statuses, got %d: %+v", len(statuses), statuses)
|
||||
}
|
||||
byID := map[string]appStatus{}
|
||||
for _, s := range statuses {
|
||||
byID[s.AppID] = s
|
||||
}
|
||||
if byID["cli_known"].State != stateNotRunning {
|
||||
t.Errorf("cli_known state = %v, want stateNotRunning", byID["cli_known"].State)
|
||||
}
|
||||
if byID["cli_orphan"].State != stateOrphan {
|
||||
t.Errorf("cli_orphan state = %v, want stateOrphan", byID["cli_orphan"].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_ScannerErrorIsNotFatal(t *testing.T) {
|
||||
q := &fakeBusQuerier{
|
||||
respByAppID: map[string]*protocol.StatusResponse{
|
||||
"cli_a": protocol.NewStatusResponse(12345, 150, 1, nil),
|
||||
},
|
||||
}
|
||||
sc := &fakeScanner{err: errors.New("ps failed")}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
if statuses[0].State != stateRunning {
|
||||
t.Errorf("State = %v, want stateRunning (scanner error must not break running detection)", statuses[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_OrphanBlock(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
statuses := []appStatus{{
|
||||
AppID: "cli_XXXXXXXXXXXXXXXX",
|
||||
State: stateOrphan,
|
||||
PID: 70926,
|
||||
UptimeSec: 68400,
|
||||
}}
|
||||
writeStatusText(&buf, statuses)
|
||||
out := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"── cli_XXXXXXXXXXXXXXXX ──",
|
||||
"Bus: orphan (PID 70926, started 19h ago)",
|
||||
"Issue: socket file missing — consumers cannot connect",
|
||||
"Action: kill 70926",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\nfull output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "running (PID") {
|
||||
t.Errorf("orphan block must not contain 'running' text; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
statuses := []appStatus{{
|
||||
AppID: "cli_XXXXXXXXXXXXXXXX",
|
||||
State: stateOrphan,
|
||||
PID: 70926,
|
||||
UptimeSec: 68400,
|
||||
}}
|
||||
if err := writeStatusJSON(&buf, statuses); err != nil {
|
||||
t.Fatalf("writeStatusJSON: %v", err)
|
||||
}
|
||||
var payload struct {
|
||||
Apps []map[string]interface{} `json:"apps"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(payload.Apps) != 1 {
|
||||
t.Fatalf("apps len = %d, want 1", len(payload.Apps))
|
||||
}
|
||||
a := payload.Apps[0]
|
||||
if a["status"] != "orphan" {
|
||||
t.Errorf("status = %v, want \"orphan\"", a["status"])
|
||||
}
|
||||
if a["running"] != false {
|
||||
t.Errorf("running = %v, want false", a["running"])
|
||||
}
|
||||
if a["issue"] != "socket file missing" {
|
||||
t.Errorf("issue = %v, want \"socket file missing\"", a["issue"])
|
||||
}
|
||||
if a["suggested_action"] != "kill 70926" {
|
||||
t.Errorf("suggested_action = %v, want \"kill 70926\"", a["suggested_action"])
|
||||
}
|
||||
if pid, ok := a["pid"].(float64); !ok || int(pid) != 70926 {
|
||||
t.Errorf("pid = %v, want 70926", a["pid"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_RunningOmitsOrphanFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
statuses := []appStatus{{
|
||||
AppID: "cli_running",
|
||||
State: stateRunning,
|
||||
PID: 11111,
|
||||
UptimeSec: 60,
|
||||
Active: 0,
|
||||
}}
|
||||
if err := writeStatusJSON(&buf, statuses); err != nil {
|
||||
t.Fatalf("writeStatusJSON: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Contains(out, `"issue"`) {
|
||||
t.Errorf("running status must not include 'issue' field; got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `"suggested_action"`) {
|
||||
t.Errorf("running status must not include 'suggested_action' field; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanizeDuration(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{30 * time.Second, "30s ago"},
|
||||
{90 * time.Second, "1m ago"},
|
||||
{45 * time.Minute, "45m ago"},
|
||||
{90 * time.Minute, "1h ago"},
|
||||
{5 * time.Hour, "5h ago"},
|
||||
{30 * time.Hour, "1d ago"},
|
||||
{80 * time.Hour, "3d ago"},
|
||||
} {
|
||||
got := humanizeDuration(tt.d)
|
||||
if got != tt.want {
|
||||
t.Errorf("humanizeDuration(%v) = %q, want %q", tt.d, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/event/busctl"
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// stopStatus is the outcome tag; JSON wire format is the string form — keep values stable.
|
||||
type stopStatus string
|
||||
|
||||
const (
|
||||
stopStopped stopStatus = "stopped"
|
||||
stopNoBus stopStatus = "no_bus"
|
||||
stopRefused stopStatus = "refused"
|
||||
stopErrored stopStatus = "error"
|
||||
)
|
||||
|
||||
type stopResult struct {
|
||||
AppID string `json:"app_id"`
|
||||
Status stopStatus `json:"status"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type stopCmdOpts struct {
|
||||
appID string
|
||||
all bool
|
||||
force bool
|
||||
asJSON bool
|
||||
}
|
||||
|
||||
func NewCmdStop(f *cmdutil.Factory) *cobra.Command {
|
||||
var o stopCmdOpts
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the event bus daemon",
|
||||
Long: `Stop the event bus daemon. Target is one of:
|
||||
• the current profile's AppID (default)
|
||||
• an explicit AppID via --app-id
|
||||
• every running bus on this machine via --all
|
||||
|
||||
Exit code: 2 if any target was refused or errored, 0 otherwise.
|
||||
|
||||
--force widens two gates:
|
||||
1. Allows stopping a bus that still has active consumers.
|
||||
2. On shutdown-timeout (bus didn't exit within 5s), SIGKILLs the
|
||||
process and cleans up the stale socket instead of returning an
|
||||
error.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runStop(f, o)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&o.appID, "app-id", "", "App ID of the bus to stop (default: current profile)")
|
||||
cmd.Flags().BoolVar(&o.all, "all", false, "Stop all running bus daemons")
|
||||
cmd.Flags().BoolVar(&o.force, "force", false, "Stop even with active consumers; on shutdown-timeout also SIGKILL the bus")
|
||||
cmd.Flags().BoolVar(&o.asJSON, "json", false, "Emit results as JSON (for AI / scripts)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runStop(f *cmdutil.Factory, o stopCmdOpts) error {
|
||||
tr := transport.New()
|
||||
|
||||
var targets []string
|
||||
if o.all {
|
||||
targets = discoverAppIDs()
|
||||
} else {
|
||||
targetAppID := o.appID
|
||||
if targetAppID == "" {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetAppID = cfg.AppID
|
||||
}
|
||||
targets = []string{targetAppID}
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
if o.asJSON {
|
||||
return writeStopJSON(f.IOStreams.Out, nil)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.Out, "No event bus instances found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
results := make([]stopResult, 0, len(targets))
|
||||
for _, id := range targets {
|
||||
results = append(results, stopBusOne(tr, id, o.force))
|
||||
}
|
||||
|
||||
if o.asJSON {
|
||||
return writeStopJSON(f.IOStreams.Out, results)
|
||||
}
|
||||
writeStopText(f.IOStreams.Out, f.IOStreams.ErrOut, results)
|
||||
|
||||
// Non-zero exit for refused/errored so non-JSON callers still get a signal.
|
||||
for _, r := range results {
|
||||
if r.Status == stopRefused || r.Status == stopErrored {
|
||||
return output.ErrBare(output.ExitValidation)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopBusOne attempts to stop appID's bus; polls tr.Dial post-Shutdown until listener is gone or budget elapses.
|
||||
func stopBusOne(tr transport.IPC, appID string, force bool) stopResult {
|
||||
resp, err := busctl.QueryStatus(tr, appID)
|
||||
if err != nil {
|
||||
return stopResult{AppID: appID, Status: stopNoBus}
|
||||
}
|
||||
|
||||
if resp.ActiveConns > 0 && !force {
|
||||
pids := make([]int, len(resp.Consumers))
|
||||
for i, c := range resp.Consumers {
|
||||
pids[i] = c.PID
|
||||
}
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopRefused,
|
||||
PID: resp.PID,
|
||||
Reason: fmt.Sprintf("%d active consumer(s) (pids: %v); use --force to override", resp.ActiveConns, pids),
|
||||
}
|
||||
}
|
||||
|
||||
if err := busctl.SendShutdown(tr, appID); err != nil {
|
||||
return stopResult{AppID: appID, Status: stopErrored, PID: resp.PID, Reason: err.Error()}
|
||||
}
|
||||
|
||||
const pollInterval = 100 * time.Millisecond
|
||||
deadline := time.Now().Add(shutdownBudget)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(pollInterval)
|
||||
probe, dialErr := tr.Dial(tr.Address(appID))
|
||||
if dialErr != nil {
|
||||
return stopResult{AppID: appID, Status: stopStopped, PID: resp.PID}
|
||||
}
|
||||
probe.Close()
|
||||
}
|
||||
|
||||
if !force {
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopErrored,
|
||||
PID: resp.PID,
|
||||
Reason: fmt.Sprintf("Bus did not exit within %v (pid=%d still listening); use --force to kill", shutdownBudget, resp.PID),
|
||||
}
|
||||
}
|
||||
|
||||
// --force: SIGKILL and clean up the stale socket.
|
||||
if err := killProcess(resp.PID); err != nil {
|
||||
if errors.Is(err, os.ErrProcessDone) {
|
||||
// Bus exited between timeout and kill — treat as success.
|
||||
tr.Cleanup(tr.Address(appID))
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopStopped,
|
||||
PID: resp.PID,
|
||||
Reason: "bus exited during kill attempt",
|
||||
}
|
||||
}
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopErrored,
|
||||
PID: resp.PID,
|
||||
Reason: fmt.Sprintf("failed to kill bus process: %v", err),
|
||||
}
|
||||
}
|
||||
tr.Cleanup(tr.Address(appID))
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopStopped,
|
||||
PID: resp.PID,
|
||||
Reason: "killed (ungraceful) after shutdown timeout",
|
||||
}
|
||||
}
|
||||
|
||||
// killProcess is a var so tests can swap it without spawning sub-processes.
|
||||
var killProcess = func(pid int) error {
|
||||
p, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Kill()
|
||||
}
|
||||
|
||||
// shutdownBudget (var so tests can shrink it) bounds the post-Shutdown exit wait.
|
||||
var shutdownBudget = 5 * time.Second
|
||||
|
||||
func writeStopJSON(w io.Writer, results []stopResult) error {
|
||||
if results == nil {
|
||||
results = []stopResult{}
|
||||
}
|
||||
output.PrintJson(w, map[string]interface{}{"results": results})
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeStopText(out, errOut io.Writer, results []stopResult) {
|
||||
for _, r := range results {
|
||||
switch r.Status {
|
||||
case stopStopped:
|
||||
fmt.Fprintf(out, "Bus stopped for %s (pid=%d)\n", r.AppID, r.PID)
|
||||
case stopNoBus:
|
||||
fmt.Fprintf(out, "No bus running for %s\n", r.AppID)
|
||||
case stopRefused:
|
||||
fmt.Fprintf(errOut, "Refused stopping %s: %s\n", r.AppID, r.Reason)
|
||||
case stopErrored:
|
||||
fmt.Fprintf(errOut, "Error stopping %s: %s\n", r.AppID, r.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// discoverAppIDs returns appIDs whose bus.alive.lock is held by a live process.
|
||||
// Cross-platform via lockfile (flock on Unix, LockFileEx on Windows); ignores stale socket files.
|
||||
func discoverAppIDs() []string {
|
||||
procs, err := busdiscover.Default().ScanBusProcesses()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(procs))
|
||||
for _, p := range procs {
|
||||
ids = append(ids, p.AppID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
)
|
||||
|
||||
func TestDiscoverAppIDs_OnlyLiveLockHolders(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
|
||||
eventsDir := filepath.Join(tmp, "events")
|
||||
|
||||
// Two live buses (lock held until t.Cleanup releases it).
|
||||
for _, app := range []string{"cli_XXXXXXXXXXXXXXXX", "cli_YYYYYYYYYYYYYYYY"} {
|
||||
appDir := filepath.Join(eventsDir, app)
|
||||
h, err := busdiscover.WritePIDFile(appDir, 1234)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePIDFile %s: %v", app, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = h.Release() })
|
||||
}
|
||||
|
||||
// Dead bus: lock acquired then released → looks like a stale dir on disk.
|
||||
deadDir := filepath.Join(eventsDir, "cli_ZZZZZZZZZZZZZZZZ")
|
||||
hDead, err := busdiscover.WritePIDFile(deadDir, 9999)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePIDFile dead: %v", err)
|
||||
}
|
||||
if err := hDead.Release(); err != nil {
|
||||
t.Fatalf("Release dead: %v", err)
|
||||
}
|
||||
|
||||
// Stale bus.sock without alive.lock — old behavior would surface it; new must not.
|
||||
staleSockDir := filepath.Join(eventsDir, "cli_SSSSSSSSSSSSSSSS")
|
||||
if err := os.MkdirAll(staleSockDir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staleSockDir, "bus.sock"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Stray non-dir file under events/.
|
||||
if err := os.WriteFile(filepath.Join(eventsDir, "stray.txt"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := discoverAppIDs()
|
||||
sort.Strings(got)
|
||||
want := []string{"cli_XXXXXXXXXXXXXXXX", "cli_YYYYYYYYYYYYYYYY"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("discoverAppIDs() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("discoverAppIDs()[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverAppIDs_MissingEventsDir(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if got := discoverAppIDs(); len(got) != 0 {
|
||||
t.Errorf("discoverAppIDs() on missing events/ = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
type mockTransport struct {
|
||||
mu sync.Mutex
|
||||
addr string
|
||||
cleaned bool
|
||||
}
|
||||
|
||||
func (t *mockTransport) Listen(addr string) (net.Listener, error) {
|
||||
return net.Listen("tcp", addr)
|
||||
}
|
||||
|
||||
func (t *mockTransport) Dial(addr string) (net.Conn, error) {
|
||||
return net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||
}
|
||||
|
||||
func (t *mockTransport) Address(appID string) string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.addr
|
||||
}
|
||||
|
||||
func (t *mockTransport) Cleanup(addr string) {
|
||||
t.mu.Lock()
|
||||
t.cleaned = true
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *mockTransport) didCleanup() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.cleaned
|
||||
}
|
||||
|
||||
type fakeBus struct {
|
||||
listener net.Listener
|
||||
pid int
|
||||
exitDelay time.Duration
|
||||
unresponsive bool
|
||||
|
||||
shutdownCount int32
|
||||
wg sync.WaitGroup
|
||||
|
||||
stopOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newFakeBus(t *testing.T, pid int, exitDelay time.Duration, unresponsive bool) *fakeBus {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
b := &fakeBus{
|
||||
listener: ln,
|
||||
pid: pid,
|
||||
exitDelay: exitDelay,
|
||||
unresponsive: unresponsive,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
b.wg.Add(1)
|
||||
go b.serve()
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *fakeBus) addr() string { return b.listener.Addr().String() }
|
||||
|
||||
func (b *fakeBus) serve() {
|
||||
defer b.wg.Done()
|
||||
for {
|
||||
conn, err := b.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b.wg.Add(1)
|
||||
go b.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBus) handle(conn net.Conn) {
|
||||
defer b.wg.Done()
|
||||
defer conn.Close()
|
||||
|
||||
r := bufio.NewReader(conn)
|
||||
line, err := r.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
msg, err := protocol.Decode(line)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.(type) {
|
||||
case *protocol.StatusQuery:
|
||||
_ = protocol.Encode(conn, &protocol.StatusResponse{
|
||||
Type: protocol.MsgTypeStatusResponse,
|
||||
PID: b.pid,
|
||||
UptimeSec: 1,
|
||||
ActiveConns: 0,
|
||||
Consumers: nil,
|
||||
})
|
||||
case *protocol.Shutdown:
|
||||
atomic.AddInt32(&b.shutdownCount, 1)
|
||||
if b.unresponsive {
|
||||
return
|
||||
}
|
||||
if b.exitDelay > 0 {
|
||||
go func() {
|
||||
time.Sleep(b.exitDelay)
|
||||
b.stop()
|
||||
}()
|
||||
} else {
|
||||
go b.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBus) stop() {
|
||||
b.stopOnce.Do(func() {
|
||||
_ = b.listener.Close()
|
||||
close(b.done)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *fakeBus) wait(t *testing.T, budget time.Duration) {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(budget):
|
||||
t.Fatalf("fakeBus did not shut down within %v", budget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopReturnsStoppedOnlyAfterBusExits(t *testing.T) {
|
||||
const pid = 44441
|
||||
const exitDelay = 500 * time.Millisecond
|
||||
|
||||
bus := newFakeBus(t, pid, exitDelay, false)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Fatalf("status = %q (reason=%q); want stopped", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Fatalf("pid = %d; want %d", res.PID, pid)
|
||||
}
|
||||
if elapsed < 400*time.Millisecond {
|
||||
t.Fatalf("stopBusOne returned in %v; expected >= %v (waited for bus to exit)", elapsed, exitDelay)
|
||||
}
|
||||
if elapsed > 3*time.Second {
|
||||
t.Fatalf("stopBusOne took %v; expected well under 3s", elapsed)
|
||||
}
|
||||
|
||||
bus.wait(t, 2*time.Second)
|
||||
if got := atomic.LoadInt32(&bus.shutdownCount); got != 1 {
|
||||
t.Errorf("fakeBus received %d Shutdown messages; want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopTimesOutOnUnresponsiveBusWithoutForce(t *testing.T) {
|
||||
const pid = 44442
|
||||
|
||||
origKill := killProcess
|
||||
t.Cleanup(func() { killProcess = origKill })
|
||||
var killCalls []int
|
||||
var killMu sync.Mutex
|
||||
killProcess = func(p int) error {
|
||||
killMu.Lock()
|
||||
killCalls = append(killCalls, p)
|
||||
killMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
bus := newFakeBus(t, pid, 0, true)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
origBudget := shutdownBudget
|
||||
t.Cleanup(func() { shutdownBudget = origBudget })
|
||||
shutdownBudget = 500 * time.Millisecond
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "error" {
|
||||
t.Fatalf("status = %q (reason=%q); want error", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Errorf("pid = %d; want %d", res.PID, pid)
|
||||
}
|
||||
if elapsed < shutdownBudget || elapsed > shutdownBudget+2*time.Second {
|
||||
t.Fatalf("elapsed = %v; want >= %v and < %v", elapsed, shutdownBudget, shutdownBudget+2*time.Second)
|
||||
}
|
||||
if !strings.Contains(res.Reason, "did not exit within") {
|
||||
t.Errorf("reason %q should mention 'did not exit within'", res.Reason)
|
||||
}
|
||||
killMu.Lock()
|
||||
defer killMu.Unlock()
|
||||
if len(killCalls) != 0 {
|
||||
t.Errorf("killProcess called %v; want 0 calls without --force", killCalls)
|
||||
}
|
||||
if tr.didCleanup() {
|
||||
t.Errorf("Cleanup should not be called when --force is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopForceKillsUnresponsiveBus(t *testing.T) {
|
||||
const pid = 44443
|
||||
|
||||
origKill := killProcess
|
||||
t.Cleanup(func() { killProcess = origKill })
|
||||
var killCalls []int
|
||||
var killMu sync.Mutex
|
||||
killProcess = func(p int) error {
|
||||
killMu.Lock()
|
||||
killCalls = append(killCalls, p)
|
||||
killMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
bus := newFakeBus(t, pid, 0, true)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
origBudget := shutdownBudget
|
||||
t.Cleanup(func() { shutdownBudget = origBudget })
|
||||
shutdownBudget = 500 * time.Millisecond
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", true)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Fatalf("status = %q (reason=%q); want stopped", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Errorf("pid = %d; want %d", res.PID, pid)
|
||||
}
|
||||
if elapsed < shutdownBudget || elapsed > shutdownBudget+2*time.Second {
|
||||
t.Fatalf("elapsed = %v; want >= %v and < %v", elapsed, shutdownBudget, shutdownBudget+2*time.Second)
|
||||
}
|
||||
if !strings.Contains(res.Reason, "killed") {
|
||||
t.Errorf("reason %q should mention 'killed'", res.Reason)
|
||||
}
|
||||
|
||||
killMu.Lock()
|
||||
defer killMu.Unlock()
|
||||
if len(killCalls) != 1 || killCalls[0] != pid {
|
||||
t.Errorf("killProcess calls = %v; want [%d]", killCalls, pid)
|
||||
}
|
||||
if !tr.didCleanup() {
|
||||
t.Errorf("Cleanup was not invoked after force-kill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopReturnsStoppedFastWhenBusExitsImmediately(t *testing.T) {
|
||||
const pid = 12345
|
||||
|
||||
bus := newFakeBus(t, pid, 0, false)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Fatalf("expected stopped, got %q (reason: %s)", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Errorf("expected PID=%d, got %d", pid, res.PID)
|
||||
}
|
||||
if elapsed > 500*time.Millisecond {
|
||||
t.Errorf("expected fast return (<500ms), got %v — possibly waiting the full budget", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopForceHandlesProcessAlreadyDeadRace(t *testing.T) {
|
||||
const pid = 99999
|
||||
|
||||
origKill := killProcess
|
||||
t.Cleanup(func() { killProcess = origKill })
|
||||
var killCalls []int
|
||||
var killMu sync.Mutex
|
||||
killProcess = func(p int) error {
|
||||
killMu.Lock()
|
||||
killCalls = append(killCalls, p)
|
||||
killMu.Unlock()
|
||||
return os.ErrProcessDone
|
||||
}
|
||||
|
||||
bus := newFakeBus(t, pid, 0, true)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
res := stopBusOne(tr, "test-app", true)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Errorf("expected stopped (race treated as success), got %q (reason: %s)", res.Status, res.Reason)
|
||||
}
|
||||
killMu.Lock()
|
||||
if len(killCalls) != 1 || killCalls[0] != pid {
|
||||
t.Errorf("expected killProcess called once with pid=%d, got %v", pid, killCalls)
|
||||
}
|
||||
killMu.Unlock()
|
||||
if !tr.didCleanup() {
|
||||
t.Error("expected Cleanup to be called even when kill reported already-dead")
|
||||
}
|
||||
if !strings.Contains(res.Reason, "exited during kill attempt") {
|
||||
t.Errorf("expected reason about race, got %q", res.Reason)
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const maxSuggestions = 3
|
||||
|
||||
// suggestEventKeys returns up to maxSuggestions keys resembling input (substring match beats edit distance).
|
||||
func suggestEventKeys(input string) []string {
|
||||
type match struct {
|
||||
key string
|
||||
dist int
|
||||
}
|
||||
var hits []match
|
||||
threshold := max(2, len(input)/5)
|
||||
|
||||
for _, def := range eventlib.ListAll() {
|
||||
if strings.Contains(def.Key, input) {
|
||||
hits = append(hits, match{def.Key, 0})
|
||||
continue
|
||||
}
|
||||
if d := levenshtein(input, def.Key); d <= threshold {
|
||||
hits = append(hits, match{def.Key, d})
|
||||
}
|
||||
}
|
||||
sort.Slice(hits, func(i, j int) bool { return hits[i].dist < hits[j].dist })
|
||||
|
||||
n := min(maxSuggestions, len(hits))
|
||||
out := make([]string, n)
|
||||
for i := range out {
|
||||
out[i] = hits[i].key
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatSuggestions renders keys as a human-readable quoted tail.
|
||||
func formatSuggestions(keys []string) string {
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
quoted := make([]string, len(keys))
|
||||
for i, k := range keys {
|
||||
quoted[i] = fmt.Sprintf("%q", k)
|
||||
}
|
||||
if len(quoted) == 1 {
|
||||
return quoted[0]
|
||||
}
|
||||
return "one of: " + strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
// unknownEventKeyErr builds the shared "unknown EventKey" error with a suggestion tail when available.
|
||||
func unknownEventKeyErr(key string) error {
|
||||
msg := fmt.Sprintf("unknown EventKey: %s", key)
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
}
|
||||
|
||||
// levenshtein computes classic edit distance (two-row DP).
|
||||
func levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 {
|
||||
return len(rb)
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return len(ra)
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"a", "", 1},
|
||||
{"", "abc", 3},
|
||||
{"kitten", "kitten", 0},
|
||||
{"kitten", "sitten", 1},
|
||||
{"kitten", "sitting", 3},
|
||||
{"飞书", "飞书", 0},
|
||||
{"飞书", "飞s", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := levenshtein(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestEventKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantEmpty bool
|
||||
wantAllHavePrefix string
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "typo via Levenshtein (recieve → receive)",
|
||||
input: "im.message.recieve_v1",
|
||||
wantContains: "im.message.receive_v1",
|
||||
},
|
||||
{
|
||||
name: "substring match returns im.message.* keys",
|
||||
input: "im.message",
|
||||
wantAllHavePrefix: "im.message.",
|
||||
},
|
||||
{
|
||||
name: "completely unrelated input returns empty",
|
||||
input: "xyzzy_no_such_event_key_at_all",
|
||||
wantEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "exact key is a substring of itself",
|
||||
input: "im.message.receive_v1",
|
||||
wantContains: "im.message.receive_v1",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := suggestEventKeys(tc.input)
|
||||
if tc.wantEmpty {
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty slice, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected non-empty suggestions, got nothing")
|
||||
}
|
||||
if len(got) > maxSuggestions {
|
||||
t.Errorf("got %d suggestions, want at most %d: %v", len(got), maxSuggestions, got)
|
||||
}
|
||||
if tc.wantAllHavePrefix != "" {
|
||||
for _, k := range got {
|
||||
if !strings.HasPrefix(k, tc.wantAllHavePrefix) {
|
||||
t.Errorf("suggestion %q lacks prefix %q (full slice: %v)", k, tc.wantAllHavePrefix, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
if tc.wantContains != "" {
|
||||
found := false
|
||||
for _, k := range got {
|
||||
if k == tc.wantContains {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("want %q in suggestions, got %v", tc.wantContains, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSuggestions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{name: "empty → empty string", in: nil, want: ""},
|
||||
{name: "single key → just quoted", in: []string{"a"}, want: `"a"`},
|
||||
{name: "two keys → one of", in: []string{"a", "b"}, want: `one of: "a", "b"`},
|
||||
{name: "three keys → one of", in: []string{"a", "b", "c"}, want: `one of: "a", "b", "c"`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := formatSuggestions(tc.in); got != tc.want {
|
||||
t.Errorf("formatSuggestions(%v) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) {
|
||||
err := unknownEventKeyErr("im.message.recieve_v1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
"unknown EventKey: im.message.recieve_v1",
|
||||
"did you mean",
|
||||
"im.message.receive_v1",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("error %q missing %q", msg, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownEventKeyErr_NoSuggestion(t *testing.T) {
|
||||
err := unknownEventKeyErr("xyzzy_no_such_event_key_at_all")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "unknown EventKey") {
|
||||
t.Errorf("error should mention unknown EventKey: %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "did you mean") {
|
||||
t.Errorf("error should NOT suggest anything for nonsense input: %q", msg)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// tableWidths returns the max cell width per column across headers + rows.
|
||||
func tableWidths(headers []string, rows [][]string) []int {
|
||||
widths := make([]int, len(headers))
|
||||
for i, h := range headers {
|
||||
widths[i] = len(h)
|
||||
}
|
||||
for _, row := range rows {
|
||||
for i, cell := range row {
|
||||
if i >= len(widths) {
|
||||
break
|
||||
}
|
||||
if l := len(cell); l > widths[i] {
|
||||
widths[i] = l
|
||||
}
|
||||
}
|
||||
}
|
||||
return widths
|
||||
}
|
||||
|
||||
// printTableRow renders one padded row; final cell is unpadded to avoid trailing whitespace.
|
||||
func printTableRow(out io.Writer, widths []int, cells []string, gap string) {
|
||||
for i, cell := range cells {
|
||||
if i == len(cells)-1 {
|
||||
fmt.Fprintln(out, cell)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, "%-*s%s", widths[i], cell, gap)
|
||||
}
|
||||
}
|
||||
@@ -3,38 +3,15 @@
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
import "github.com/spf13/pflag"
|
||||
|
||||
// GlobalOptions are the root-level flags shared by bootstrap parsing and the
|
||||
// actual Cobra command tree. Profile is the parsed --profile value; HideProfile
|
||||
// is a build-time policy — when true, --profile stays parseable but is marked
|
||||
// hidden from help and shell completion.
|
||||
// actual Cobra command tree.
|
||||
type GlobalOptions struct {
|
||||
Profile string
|
||||
HideProfile bool
|
||||
Profile string
|
||||
}
|
||||
|
||||
// RegisterGlobalFlags registers the root-level persistent flags on fs and
|
||||
// applies any visibility policy encoded in opts. Pure function: no disk,
|
||||
// network, or environment reads — the caller decides HideProfile.
|
||||
// RegisterGlobalFlags registers the root-level persistent flags.
|
||||
func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
|
||||
fs.StringVar(&opts.Profile, "profile", "", "use a specific profile")
|
||||
if opts.HideProfile {
|
||||
_ = fs.MarkHidden("profile")
|
||||
}
|
||||
}
|
||||
|
||||
// isSingleAppMode reports whether the on-disk config has at most one app.
|
||||
// Missing configs are treated as single-app since --profile is meaningless
|
||||
// until at least two profiles exist. Intended for the Execute entry point —
|
||||
// buildInternal must not call this directly to stay state-free.
|
||||
func isSingleAppMode() bool {
|
||||
raw, err := core.LoadMultiAppConfig()
|
||||
if err != nil || raw == nil {
|
||||
return true
|
||||
}
|
||||
return len(raw.Apps) <= 1
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func testStreams() BuildOption { return WithIO(os.Stdin, os.Stdout, os.Stderr) }
|
||||
|
||||
func TestRegisterGlobalFlags_PolicyVisible(t *testing.T) {
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
opts := &GlobalOptions{}
|
||||
RegisterGlobalFlags(fs, opts)
|
||||
|
||||
flag := fs.Lookup("profile")
|
||||
if flag == nil {
|
||||
t.Fatal("profile flag should be registered")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("profile flag should be visible when HideProfile is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterGlobalFlags_PolicyHidden(t *testing.T) {
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
opts := &GlobalOptions{HideProfile: true}
|
||||
RegisterGlobalFlags(fs, opts)
|
||||
|
||||
flag := fs.Lookup("profile")
|
||||
if flag == nil {
|
||||
t.Fatal("profile flag should be registered")
|
||||
}
|
||||
if !flag.Hidden {
|
||||
t.Fatal("profile flag should be hidden when HideProfile is true")
|
||||
}
|
||||
if err := fs.Parse([]string{"--profile", "x"}); err != nil {
|
||||
t.Fatalf("Parse() error = %v; hidden flag should still parse", err)
|
||||
}
|
||||
if opts.Profile != "x" {
|
||||
t.Fatalf("opts.Profile = %q, want %q", opts.Profile, "x")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSingleAppMode_NoConfig(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if !isSingleAppMode() {
|
||||
t.Fatal("isSingleAppMode() = false, want true when no config exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSingleAppMode_SingleApp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
saveAppsForTest(t, []core.AppConfig{
|
||||
{Name: "default", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
|
||||
})
|
||||
if !isSingleAppMode() {
|
||||
t.Fatal("isSingleAppMode() = false, want true for single-app config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSingleAppMode_MultiApp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
saveAppsForTest(t, []core.AppConfig{
|
||||
{Name: "a", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
|
||||
{Name: "b", AppId: "cli_b", AppSecret: core.PlainSecret("y"), Brand: core.BrandFeishu},
|
||||
})
|
||||
if isSingleAppMode() {
|
||||
t.Fatal("isSingleAppMode() = true, want false for multi-app config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInternal_HideProfileOption(t *testing.T) {
|
||||
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
|
||||
|
||||
flag := root.PersistentFlags().Lookup("profile")
|
||||
if flag == nil {
|
||||
t.Fatal("profile flag should be registered")
|
||||
}
|
||||
if !flag.Hidden {
|
||||
t.Fatal("profile flag should be hidden when HideProfile(true) is applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
|
||||
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
|
||||
|
||||
flag := root.PersistentFlags().Lookup("profile")
|
||||
if flag == nil {
|
||||
t.Fatal("profile flag should be registered by default")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("profile flag should be visible by default")
|
||||
}
|
||||
}
|
||||
|
||||
func saveAppsForTest(t *testing.T, apps []core.AppConfig) {
|
||||
t.Helper()
|
||||
multi := &core.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
25
cmd/root.go
25
cmd/root.go
@@ -87,11 +87,7 @@ func Execute() int {
|
||||
}
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
f, rootCmd := buildInternal(
|
||||
context.Background(), inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
)
|
||||
f, rootCmd := buildInternal(context.Background(), inv)
|
||||
|
||||
// --- Update check (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
@@ -158,7 +154,7 @@ func isCompletionCommand(args []string) bool {
|
||||
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
|
||||
// the invocation will actually serve a __complete request.
|
||||
func configureFlagCompletions(args []string) {
|
||||
cmdutil.SetFlagCompletionsEnabled(isCompletionCommand(args))
|
||||
cmdutil.SetFlagCompletionsDisabled(!isCompletionCommand(args))
|
||||
}
|
||||
|
||||
// handleRootError dispatches a command error to the appropriate handler
|
||||
@@ -248,29 +244,16 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
||||
// flags that are normally hidden in single-app mode (currently --profile)
|
||||
// when rendering the root command's own help, so users discovering the CLI
|
||||
// still see them at `lark-cli --help`.
|
||||
// when a command has tips set via cmdutil.SetTips.
|
||||
func installTipsHelpFunc(root *cobra.Command) {
|
||||
defaultHelp := root.HelpFunc()
|
||||
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd == root {
|
||||
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
|
||||
f.Hidden = false
|
||||
defer func() { f.Hidden = true }()
|
||||
}
|
||||
}
|
||||
defaultHelp(cmd, args)
|
||||
out := cmd.OutOrStdout()
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Risk:", level)
|
||||
}
|
||||
tips := cmdutil.GetTips(cmd)
|
||||
if len(tips) == 0 {
|
||||
return
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Tips:")
|
||||
for _, tip := range tips {
|
||||
|
||||
@@ -135,12 +135,10 @@ func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictM
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f := cmdutil.NewDefault(nil, cmdutil.InvocationContext{Profile: profile})
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
f := cmdutil.NewDefault(
|
||||
cmdutil.NewIOStreams(&bytes.Buffer{}, stdout, stderr),
|
||||
cmdutil.InvocationContext{Profile: profile},
|
||||
)
|
||||
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
@@ -149,6 +147,20 @@ func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
|
||||
stderr.Reset()
|
||||
}
|
||||
|
||||
func parseDryRunJSON(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
out := stdout.String()
|
||||
const prefix = "=== Dry Run ===\n"
|
||||
if !strings.HasPrefix(out, prefix) {
|
||||
t.Fatalf("expected dry-run prefix, got:\n%s", out)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(out, prefix)), &payload); err != nil {
|
||||
t.Fatalf("failed to parse dry-run payload: %v\nstdout: %s", err, out)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// --- api command ---
|
||||
|
||||
func TestIntegration_Api_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
@@ -388,25 +400,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentity(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
@@ -414,14 +408,16 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
},
|
||||
})
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
payload := parseDryRunJSON(t, stdout)
|
||||
if got := payload["as"]; got != "bot" {
|
||||
t.Fatalf("dry-run as = %v, want bot", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
@@ -441,7 +437,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
@@ -449,14 +445,16 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
|
||||
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
},
|
||||
})
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
payload := parseDryRunJSON(t, stdout)
|
||||
if got := payload["as"]; got != "bot" {
|
||||
t.Fatalf("dry-run as = %v, want bot", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- shortcut command ---
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// rendersHelp runs the wrapped help func and returns stdout.
|
||||
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
cmd.HelpFunc()(cmd, nil)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
root.AddCommand(child)
|
||||
|
||||
out := rendersHelp(t, child)
|
||||
if !strings.Contains(out, "Risk: high-risk-write") {
|
||||
t.Errorf("expected Risk line in help output, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "list", Short: "list items"}
|
||||
root.AddCommand(child)
|
||||
|
||||
out := rendersHelp(t, child)
|
||||
if strings.Contains(out, "Risk:") {
|
||||
t.Errorf("expected no Risk line when annotation is absent, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
cmdutil.SetTips(child, []string{"use --yes to confirm"})
|
||||
root.AddCommand(child)
|
||||
|
||||
out := rendersHelp(t, child)
|
||||
riskIdx := strings.Index(out, "Risk:")
|
||||
tipsIdx := strings.Index(out, "Tips:")
|
||||
if riskIdx == -1 || tipsIdx == -1 {
|
||||
t.Fatalf("expected both Risk and Tips sections, got:\n%s", out)
|
||||
}
|
||||
if riskIdx >= tipsIdx {
|
||||
t.Errorf("expected Risk to precede Tips; got Risk@%d, Tips@%d", riskIdx, tipsIdx)
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigureFlagCompletions(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) })
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -213,10 +213,10 @@ func TestConfigureFlagCompletions(t *testing.T) {
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmdutil.SetFlagCompletionsEnabled(tc.wantDisabled)
|
||||
cmdutil.SetFlagCompletionsDisabled(!tc.wantDisabled)
|
||||
configureFlagCompletions(tc.args)
|
||||
if got := !cmdutil.FlagCompletionsEnabled(); got != tc.wantDisabled {
|
||||
t.Fatalf("FlagCompletionsEnabled() = %v, want disabled=%v", !got, tc.wantDisabled)
|
||||
if got := cmdutil.FlagCompletionsDisabled(); got != tc.wantDisabled {
|
||||
t.Fatalf("FlagCompletionsDisabled() = %v, want %v", got, tc.wantDisabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -375,7 +375,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
cmd.ValidArgsFunction = completeSchemaPath(f)
|
||||
cmd.ValidArgsFunction = completeSchemaPath
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
@@ -387,81 +387,74 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
// completeSchemaPath provides tab-completion for the schema path argument.
|
||||
// It handles dotted resource names (e.g. app.table.fields) by iterating all
|
||||
// resources and classifying each as a prefix-match or fully-matched.
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
parts := strings.Split(toComplete, ".")
|
||||
|
||||
// Level 1: complete service names
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string {
|
||||
parts := strings.Split(toComplete, ".")
|
||||
|
||||
// Level 1: complete service names
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// afterService = everything user typed after "serviceName."
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
|
||||
var completions []string
|
||||
|
||||
for resName, resVal := range resources {
|
||||
if strings.HasPrefix(resName, afterService) {
|
||||
// afterService is a prefix of this resource name → resource candidate
|
||||
completions = append(completions, serviceName+"."+resName+".")
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(afterService, resName+".") {
|
||||
continue
|
||||
}
|
||||
methodPrefix := afterService[len(resName)+1:]
|
||||
resMap, _ := resVal.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
for methodName := range methods {
|
||||
if strings.HasPrefix(methodName, methodPrefix) {
|
||||
completions = append(completions, serviceName+"."+resName+"."+methodName)
|
||||
} else if strings.HasPrefix(afterService, resName+".") {
|
||||
// This resource is fully matched; remainder is method prefix
|
||||
methodPrefix := afterService[len(resName)+1:]
|
||||
resMap, _ := resVal.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
for methodName := range methods {
|
||||
if strings.HasPrefix(methodName, methodPrefix) {
|
||||
completions = append(completions, serviceName+"."+resName+"."+methodName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(completions)
|
||||
return completions
|
||||
|
||||
// If all completions end with ".", user is still navigating resources → NoSpace
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
}
|
||||
|
||||
func schemaRun(opts *SchemaOptions) error {
|
||||
|
||||
@@ -182,49 +182,3 @@ func TestHasFileFields(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSchemaPathForSpec(t *testing.T) {
|
||||
resources := map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"create": map[string]interface{}{},
|
||||
"list": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
"record_permissions": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"get": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := completeSchemaPathForSpec("base", resources, "records.cr")
|
||||
if len(got) != 1 || got[0] != "base.records.create" {
|
||||
t.Fatalf("completions = %v, want [base.records.create]", got)
|
||||
}
|
||||
|
||||
got = completeSchemaPathForSpec("base", resources, "record")
|
||||
if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." {
|
||||
t.Fatalf("resource completions = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"resources": map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}},
|
||||
"create": map[string]interface{}{"accessTokens": []interface{}{"user"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
filtered := filterSpecByStrictMode(spec, core.StrictModeBot)
|
||||
resources, _ := filtered["resources"].(map[string]interface{})
|
||||
got := completeSchemaPathForSpec("base", resources, "records.")
|
||||
if len(got) != 1 || got[0] != "base.records.list" {
|
||||
t.Fatalf("filtered completions = %v, want [base.records.list]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,6 @@ import (
|
||||
|
||||
// RegisterServiceCommands registers all service commands from from_meta specs.
|
||||
func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
RegisterServiceCommandsWithContext(context.Background(), parent, f)
|
||||
}
|
||||
|
||||
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
spec := registry.LoadFromMeta(project)
|
||||
if spec == nil {
|
||||
@@ -42,15 +38,11 @@ func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Comma
|
||||
if resources == nil {
|
||||
continue
|
||||
}
|
||||
registerServiceWithContext(ctx, parent, spec, resources, f)
|
||||
registerService(parent, spec, resources, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
registerServiceWithContext(context.Background(), parent, spec, resources, f)
|
||||
}
|
||||
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
specDesc := registry.GetServiceDescription(specName, "en")
|
||||
if specDesc == "" {
|
||||
@@ -78,11 +70,11 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
|
||||
registerResource(svc, spec, resName, resMap, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
func registerResource(parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
res := &cobra.Command{
|
||||
Use: name,
|
||||
Short: name + " operations",
|
||||
@@ -95,7 +87,7 @@ func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spe
|
||||
if methodMap == nil {
|
||||
continue
|
||||
}
|
||||
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
|
||||
registerMethod(res, spec, methodMap, methodName, name, f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,19 +120,14 @@ func detectFileFields(method map[string]interface{}) []string {
|
||||
return cmdutil.DetectFileFields(method)
|
||||
}
|
||||
|
||||
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
|
||||
func registerMethod(parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
parent.AddCommand(NewCmdServiceMethod(f, spec, method, name, resName, nil))
|
||||
}
|
||||
|
||||
// NewCmdServiceMethod creates a command for a dynamically registered service method.
|
||||
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
|
||||
}
|
||||
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
risk := registry.GetStrFromMap(method, "risk")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
|
||||
|
||||
@@ -172,7 +159,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
}
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
@@ -180,9 +167,6 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
if risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
|
||||
// Conditionally register --file for methods with file-type fields.
|
||||
fileFields := detectFileFields(method)
|
||||
@@ -193,12 +177,14 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
|
||||
}
|
||||
}
|
||||
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
|
||||
cmdutil.SetRisk(cmd, risk)
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
||||
}
|
||||
@@ -254,12 +240,6 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
return serviceDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
|
||||
if registry.GetStrFromMap(opts.Method, "risk") == "high-risk-write" {
|
||||
if yes, _ := opts.Cmd.Flags().GetBool("yes"); !yes {
|
||||
return cmdutil.RequireConfirmation(opts.SchemaPath)
|
||||
}
|
||||
}
|
||||
|
||||
ac, err := f.NewAPIClientWithConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -283,14 +263,13 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
return output.ErrNetwork("API call failed: %s", err)
|
||||
}
|
||||
return client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
CheckError: checkErr,
|
||||
OutputPath: opts.Output,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CheckError: checkErr,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// highRiskDeleteMethod mirrors a simple DELETE API with a required path
|
||||
// parameter and risk metadata. The returned map is what service registration
|
||||
// reads; the test exercises --yes registration and the gate behavior.
|
||||
func highRiskDeleteMethod() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": "files/{file_token}",
|
||||
"httpMethod": "DELETE",
|
||||
"risk": "high-risk-write",
|
||||
"parameters": map[string]interface{}{
|
||||
"file_token": map[string]interface{}{
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeMethodNoRisk() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"path": "files/{file_token}",
|
||||
"httpMethod": "DELETE",
|
||||
"parameters": map[string]interface{}{
|
||||
"file_token": map[string]interface{}{
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_YesFlagRegisteredForHighRisk(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
|
||||
|
||||
if cmd.Flags().Lookup("yes") == nil {
|
||||
t.Error("expected --yes flag registered for risk=high-risk-write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_YesFlagNotRegisteredForWrite(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), writeMethodNoRisk(), "delete", "files", nil)
|
||||
|
||||
if cmd.Flags().Lookup("yes") != nil {
|
||||
t.Error("expected --yes flag NOT registered when risk is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_RiskAnnotationSet(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
|
||||
|
||||
level, ok := cmdutil.GetRisk(cmd)
|
||||
if !ok {
|
||||
t.Fatal("expected Risk annotation to be set")
|
||||
}
|
||||
if level != "high-risk-write" {
|
||||
t.Errorf("level = %q, want high-risk-write", level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_RiskAnnotationAbsentForUnsetRisk(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), writeMethodNoRisk(), "delete", "files", nil)
|
||||
|
||||
if _, ok := cmdutil.GetRisk(cmd); ok {
|
||||
t.Error("expected no Risk annotation when meta risk is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_GateBlocksWithoutYes(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
|
||||
// --as bot skips the scope check so we reach the gate without external creds.
|
||||
cmd.SetArgs([]string{"--as", "bot", "--params", `{"file_token":"tok_abc"}`})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Errorf("expected 'requires confirmation' in error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "drive.files.delete") {
|
||||
t.Errorf("expected schema path in error action, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_DryRunBypassesGate(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--as", "bot",
|
||||
"--params", `{"file_token":"tok_abc"}`,
|
||||
"--dry-run",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("dry-run should not hit confirmation gate; got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "files/tok_abc") {
|
||||
t.Errorf("expected dry-run output to contain URL, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -121,24 +121,6 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||
})
|
||||
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "copy", "files", nil)
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
}
|
||||
if !flag.Hidden {
|
||||
t.Fatal("expected --as flag to be hidden in strict mode")
|
||||
}
|
||||
if got := flag.DefValue; got != "bot" {
|
||||
t.Fatalf("default value = %q, want %q", got, "bot")
|
||||
}
|
||||
}
|
||||
|
||||
// ── NewCmdServiceMethod flags ──
|
||||
|
||||
func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
||||
)
|
||||
|
||||
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
|
||||
type ImMessageReceiveOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_" kind:"message_id"`
|
||||
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
|
||||
}
|
||||
|
||||
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Message struct {
|
||||
MessageID string `json:"message_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
ChatType string `json:"chat_type"`
|
||||
MessageType string `json:"message_type"`
|
||||
Content string `json:"content"`
|
||||
CreateTime string `json:"create_time"`
|
||||
Mentions []interface{} `json:"mentions"`
|
||||
} `json:"message"`
|
||||
Sender struct {
|
||||
SenderID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"sender_id"`
|
||||
} `json:"sender"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
msg := envelope.Event.Message
|
||||
content := msg.Content
|
||||
if msg.MessageType != "interactive" {
|
||||
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
|
||||
RawContent: msg.Content,
|
||||
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),
|
||||
})
|
||||
}
|
||||
|
||||
timestamp := envelope.Header.CreateTime
|
||||
if timestamp == "" {
|
||||
timestamp = msg.CreateTime
|
||||
}
|
||||
|
||||
out := &ImMessageReceiveOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: timestamp,
|
||||
ID: msg.MessageID,
|
||||
MessageID: msg.MessageID,
|
||||
CreateTime: msg.CreateTime,
|
||||
ChatID: msg.ChatID,
|
||||
ChatType: msg.ChatType,
|
||||
MessageType: msg.MessageType,
|
||||
SenderID: envelope.Event.Sender.SenderID.OpenID,
|
||||
Content: content,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
for _, k := range Keys() {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestIMKeys_ProcessedReceiveRegistered(t *testing.T) {
|
||||
def, ok := event.Lookup("im.message.receive_v1")
|
||||
if !ok {
|
||||
t.Fatal("im.message.receive_v1 should be registered via Keys()")
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for Processed key")
|
||||
}
|
||||
if len(def.Scopes) == 0 {
|
||||
t.Error("Scopes must not be empty — preflightScopes would bypass validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMKeys_NativeEventsRegistered(t *testing.T) {
|
||||
want := []string{
|
||||
"im.message.message_read_v1",
|
||||
"im.message.reaction.created_v1",
|
||||
"im.message.reaction.deleted_v1",
|
||||
"im.chat.member.bot.added_v1",
|
||||
"im.chat.member.bot.deleted_v1",
|
||||
"im.chat.member.user.added_v1",
|
||||
"im.chat.member.user.withdrawn_v1",
|
||||
"im.chat.member.user.deleted_v1",
|
||||
"im.chat.updated_v1",
|
||||
"im.chat.disbanded_v1",
|
||||
}
|
||||
for _, k := range want {
|
||||
def, ok := event.Lookup(k)
|
||||
if !ok {
|
||||
t.Errorf("%s should be registered via Keys()", k)
|
||||
continue
|
||||
}
|
||||
if def.Schema.Native == nil {
|
||||
t.Errorf("%s: Schema.Native must be set for native key", k)
|
||||
}
|
||||
if def.Schema.Custom != nil {
|
||||
t.Errorf("%s: Native key must not set Schema.Custom", k)
|
||||
}
|
||||
if def.Process != nil {
|
||||
t.Errorf("%s: Native key must not set Process", k)
|
||||
}
|
||||
if def.Schema.Native != nil && def.Schema.Native.Type == nil {
|
||||
t.Errorf("%s: Schema.Native.Type must reference an SDK type", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_Text(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_test_text",
|
||||
"event_type": "im.message.receive_v1",
|
||||
"create_time": "1776409469273",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_text_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"create_time": "1776409468987",
|
||||
"content": "{\"text\":\"hello there\"}"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runReceive(t, payload)
|
||||
|
||||
if out.Type != "im.message.receive_v1" {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.MessageID != "om_text_001" || out.ID != "om_text_001" {
|
||||
t.Errorf("MessageID/ID = %q/%q", out.MessageID, out.ID)
|
||||
}
|
||||
if out.ChatType != "p2p" || out.ChatID != "oc_chat" {
|
||||
t.Errorf("chat_id/chat_type = %q/%q", out.ChatID, out.ChatType)
|
||||
}
|
||||
if out.SenderID != "ou_sender" {
|
||||
t.Errorf("SenderID = %q", out.SenderID)
|
||||
}
|
||||
if out.Content != "hello there" {
|
||||
t.Errorf("Content = %q, want \"hello there\"", out.Content)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_Interactive(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_test_card",
|
||||
"event_type": "im.message.receive_v1",
|
||||
"create_time": "1776409469274",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_card_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "group",
|
||||
"message_type": "interactive",
|
||||
"create_time": "1776409468987",
|
||||
"content": "{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"A card\"}}}"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runReceive(t, payload)
|
||||
|
||||
if out.Type != "im.message.receive_v1" {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.MessageType != "interactive" {
|
||||
t.Errorf("MessageType = %q", out.MessageType)
|
||||
}
|
||||
if out.ChatType != "group" {
|
||||
t.Errorf("ChatType = %q", out.ChatType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_MalformedPayload(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out ImMessageReceiveOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid ImMessageReceiveOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
)
|
||||
|
||||
// nativeIMKey curates metadata for a Native IM event; fieldOverrides paths are JSON Pointer anchored at the V2-wrapped schema (start with /event/...).
|
||||
type nativeIMKey struct {
|
||||
key string
|
||||
title string
|
||||
description string
|
||||
scopes []string
|
||||
bodyType reflect.Type
|
||||
fieldOverrides map[string]schemas.FieldMeta
|
||||
}
|
||||
|
||||
// userIDOv returns open_id/union_id/user_id overrides for a UserID object at prefix.
|
||||
func userIDOv(prefix string) map[string]schemas.FieldMeta {
|
||||
return map[string]schemas.FieldMeta{
|
||||
prefix + "/open_id": {Kind: "open_id"},
|
||||
prefix + "/union_id": {Kind: "union_id"},
|
||||
prefix + "/user_id": {Kind: "user_id"},
|
||||
}
|
||||
}
|
||||
|
||||
// mergeOv merges FieldMeta maps left-to-right (later wins).
|
||||
func mergeOv(ms ...map[string]schemas.FieldMeta) map[string]schemas.FieldMeta {
|
||||
out := map[string]schemas.FieldMeta{}
|
||||
for _, m := range ms {
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var nativeIMKeys = []nativeIMKey{
|
||||
{
|
||||
key: "im.message.message_read_v1",
|
||||
title: "Message read",
|
||||
description: "Triggered after a user reads a P2P message sent by the bot",
|
||||
scopes: []string{"im:message:readonly", "im:message"},
|
||||
bodyType: reflect.TypeOf(larkim.P2MessageReadV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/reader/reader_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/reader/read_time": {Kind: "timestamp_ms"},
|
||||
"/event/message_id_list/*": {Kind: "message_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.message.reaction.created_v1",
|
||||
title: "Reaction added",
|
||||
description: "Triggered when a reaction is added to a message",
|
||||
scopes: []string{"im:message:readonly", "im:message.reactions:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2MessageReactionCreatedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/message_id": {Kind: "message_id"},
|
||||
"/event/action_time": {Kind: "timestamp_ms"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.message.reaction.deleted_v1",
|
||||
title: "Reaction removed",
|
||||
description: "Triggered when a reaction is removed from a message",
|
||||
scopes: []string{"im:message:readonly", "im:message.reactions:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2MessageReactionDeletedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/message_id": {Kind: "message_id"},
|
||||
"/event/action_time": {Kind: "timestamp_ms"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.bot.added_v1",
|
||||
title: "Bot added to chat",
|
||||
description: "Triggered when the bot is added to a chat",
|
||||
scopes: []string{"im:chat.members:bot_access"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberBotAddedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.bot.deleted_v1",
|
||||
title: "Bot removed from chat",
|
||||
description: "Triggered after the bot is removed from a chat",
|
||||
scopes: []string{"im:chat.members:bot_access"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberBotDeletedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.user.added_v1",
|
||||
title: "User added to chat",
|
||||
description: "Triggered when a new user joins a chat (including topic chats)",
|
||||
scopes: []string{"im:chat.members:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserAddedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
userIDOv("/event/users/*/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.user.withdrawn_v1",
|
||||
title: "User invite withdrawn",
|
||||
description: "Triggered after a pending user invite is withdrawn",
|
||||
scopes: []string{"im:chat.members:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserWithdrawnV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
userIDOv("/event/users/*/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.member.user.deleted_v1",
|
||||
title: "User left chat",
|
||||
description: "Triggered when a user leaves or is removed from a chat",
|
||||
scopes: []string{"im:chat.members:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserDeletedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
userIDOv("/event/users/*/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.updated_v1",
|
||||
title: "Chat updated",
|
||||
description: "Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated",
|
||||
scopes: []string{"im:chat:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatUpdatedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
userIDOv("/event/before_change/owner_id"),
|
||||
userIDOv("/event/after_change/owner_id"),
|
||||
userIDOv("/event/moderator_list/added_member_list/*/user_id"),
|
||||
userIDOv("/event/moderator_list/removed_member_list/*/user_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "im.chat.disbanded_v1",
|
||||
title: "Chat disbanded",
|
||||
description: "Triggered after a chat is disbanded",
|
||||
scopes: []string{"im:chat:read"},
|
||||
bodyType: reflect.TypeOf(larkim.P2ChatDisbandedV1Data{}),
|
||||
fieldOverrides: mergeOv(
|
||||
userIDOv("/event/operator_id"),
|
||||
map[string]schemas.FieldMeta{
|
||||
"/event/chat_id": {Kind: "chat_id"},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package im registers IM-domain EventKeys.
|
||||
package im
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// Keys returns all IM-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
out := []event.KeyDefinition{
|
||||
{
|
||||
Key: "im.message.receive_v1",
|
||||
DisplayName: "Receive message",
|
||||
Description: "Receive IM messages",
|
||||
EventType: "im.message.receive_v1",
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ImMessageReceiveOutput{})},
|
||||
},
|
||||
Process: processImMessageReceive,
|
||||
// Narrowest grant; kept single-element since MissingScopes uses AND semantics.
|
||||
Scopes: []string{"im:message.p2p_msg:readonly"},
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, rk := range nativeIMKeys {
|
||||
out = append(out, event.KeyDefinition{
|
||||
Key: rk.key,
|
||||
DisplayName: rk.title,
|
||||
Description: rk.description,
|
||||
EventType: rk.key,
|
||||
Schema: event.SchemaDef{
|
||||
Native: &event.SchemaSpec{Type: rk.bodyType},
|
||||
FieldOverrides: rk.fieldOverrides,
|
||||
},
|
||||
Scopes: rk.scopes,
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{rk.key},
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
func TestAllKeys_FieldOverridePointersResolve(t *testing.T) {
|
||||
for _, def := range event.ListAll() {
|
||||
if len(def.Schema.FieldOverrides) == 0 {
|
||||
continue
|
||||
}
|
||||
raw := renderDefSchemaForLint(t, def)
|
||||
if raw == nil {
|
||||
t.Errorf("%s: FieldOverrides set but Schema has no Native/Custom spec", def.Key)
|
||||
continue
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Errorf("%s: parse schema: %v", def.Key, err)
|
||||
continue
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
if len(orphans) > 0 {
|
||||
t.Errorf("%s: orphan FieldOverrides paths (typo or SDK drift): %v", def.Key, orphans)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderDefSchemaForLint(t *testing.T, def *event.KeyDefinition) json.RawMessage {
|
||||
t.Helper()
|
||||
spec, isNative := pickSpec(def.Schema)
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
raw := renderSpec(t, spec)
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
if isNative {
|
||||
raw = schemas.WrapV2Envelope(raw)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func pickSpec(s event.SchemaDef) (*event.SchemaSpec, bool) {
|
||||
if s.Native != nil {
|
||||
return s.Native, true
|
||||
}
|
||||
if s.Custom != nil {
|
||||
return s.Custom, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func renderSpec(t *testing.T, s *event.SchemaSpec) json.RawMessage {
|
||||
t.Helper()
|
||||
if s.Type != nil {
|
||||
return schemas.FromType(s.Type)
|
||||
}
|
||||
if len(s.Raw) > 0 {
|
||||
return append(json.RawMessage{}, s.Raw...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Proves the pipeline catches orphan FieldOverrides paths, so TestAllKeys_FieldOverridePointersResolve isn't vacuous.
|
||||
func TestOrphanDetectionMechanism(t *testing.T) {
|
||||
type synthetic struct {
|
||||
ValidField string `json:"valid_field"`
|
||||
}
|
||||
spec := &event.SchemaSpec{Type: reflect.TypeOf(synthetic{})}
|
||||
raw := renderSpec(t, spec)
|
||||
if raw == nil {
|
||||
t.Fatal("renderSpec returned nil for synthetic type")
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
overrides := map[string]schemas.FieldMeta{
|
||||
"/valid_field": {Kind: "open_id"},
|
||||
"/broken_typo": {Kind: "chat_id"},
|
||||
"/valid_field/x": {Kind: "email"},
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, overrides)
|
||||
wantOrphans := map[string]bool{"/broken_typo": true, "/valid_field/x": true}
|
||||
if len(orphans) != len(wantOrphans) {
|
||||
t.Fatalf("orphans = %v, want exactly %v", orphans, wantOrphans)
|
||||
}
|
||||
for _, o := range orphans {
|
||||
if !wantOrphans[o] {
|
||||
t.Errorf("unexpected orphan %q", o)
|
||||
}
|
||||
}
|
||||
vf := parsed["properties"].(map[string]interface{})["valid_field"].(map[string]interface{})
|
||||
if vf["format"] != "open_id" {
|
||||
t.Errorf("valid path not applied: %v", vf)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package events wires domain EventKey definitions into the global registry. Blank-import to populate.
|
||||
package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// Mail is intentionally omitted: only IM is wired up this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
im.Keys(),
|
||||
}
|
||||
for _, keys := range all {
|
||||
for _, k := range keys {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contentsafety
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
provider Provider
|
||||
)
|
||||
|
||||
// Register installs a content-safety Provider. Later registrations
|
||||
// override earlier ones (last-write-wins).
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
provider = p
|
||||
}
|
||||
|
||||
// GetProvider returns the currently registered Provider.
|
||||
// Returns nil if no provider has been registered.
|
||||
func GetProvider() Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return provider
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contentsafety
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Provider scans parsed response data for content-safety issues.
|
||||
// Implementations must be safe for concurrent use.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Scan(ctx context.Context, req ScanRequest) (*Alert, error)
|
||||
}
|
||||
|
||||
// ScanRequest carries the data to scan.
|
||||
type ScanRequest struct {
|
||||
Path string // normalized command path (e.g. "im.messages_search")
|
||||
Data any // parsed response data (generic JSON shape)
|
||||
ErrOut io.Writer // stderr for provider-level notices (e.g. lazy-config creation)
|
||||
}
|
||||
|
||||
// Alert holds the result of a content-safety scan that detected issues.
|
||||
type Alert struct {
|
||||
Provider string `json:"provider"`
|
||||
MatchedRules []string `json:"matched_rules"`
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contentsafety
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlertFields(t *testing.T) {
|
||||
a := &Alert{
|
||||
Provider: "regex",
|
||||
MatchedRules: []string{"rule_a", "rule_b"},
|
||||
}
|
||||
if a.Provider != "regex" {
|
||||
t.Errorf("Provider = %q, want %q", a.Provider, "regex")
|
||||
}
|
||||
if len(a.MatchedRules) != 2 {
|
||||
t.Errorf("MatchedRules length = %d, want 2", len(a.MatchedRules))
|
||||
}
|
||||
}
|
||||
|
||||
type stubProvider struct{}
|
||||
|
||||
func (s *stubProvider) Name() string { return "stub" }
|
||||
func (s *stubProvider) Scan(_ context.Context, _ ScanRequest) (*Alert, error) {
|
||||
return &Alert{Provider: "stub", MatchedRules: []string{"test"}}, nil
|
||||
}
|
||||
|
||||
func TestProviderInterface(t *testing.T) {
|
||||
var p Provider = &stubProvider{}
|
||||
if p.Name() != "stub" {
|
||||
t.Errorf("Name() = %q, want %q", p.Name(), "stub")
|
||||
}
|
||||
alert, err := p.Scan(context.Background(), ScanRequest{Path: "test", Data: nil, ErrOut: io.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan() error = %v", err)
|
||||
}
|
||||
if alert.Provider != "stub" {
|
||||
t.Errorf("alert.Provider = %q, want %q", alert.Provider, "stub")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryLastWriteWins(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := provider
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
provider = old
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
if GetProvider() != nil {
|
||||
t.Fatal("expected nil provider initially")
|
||||
}
|
||||
p1 := &stubProvider{}
|
||||
Register(p1)
|
||||
if GetProvider() != p1 {
|
||||
t.Fatal("expected p1 after first Register")
|
||||
}
|
||||
p2 := &stubProvider{}
|
||||
Register(p2)
|
||||
if GetProvider() != p2 {
|
||||
t.Fatal("expected p2 after second Register (last-write-wins)")
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,7 @@
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
@@ -14,28 +11,12 @@ var (
|
||||
)
|
||||
|
||||
// Register registers a credential Provider.
|
||||
// Providers are consulted in priority order (lowest value first).
|
||||
// Providers that implement Priority() int are sorted accordingly;
|
||||
// those that do not default to priority 10.
|
||||
// Providers are consulted in registration order.
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
providers = append(providers, p)
|
||||
sort.SliceStable(providers, func(i, j int) bool {
|
||||
return providerPriority(providers[i]) < providerPriority(providers[j])
|
||||
})
|
||||
}
|
||||
|
||||
// providerPriority returns the priority of a provider.
|
||||
// If the provider implements interface{ Priority() int }, that value is used;
|
||||
// otherwise 10 is returned as the default priority.
|
||||
// Lower values are consulted first.
|
||||
func providerPriority(p Provider) int {
|
||||
if pp, ok := p.(interface{ Priority() int }); ok {
|
||||
return pp.Priority()
|
||||
}
|
||||
return 10
|
||||
}
|
||||
|
||||
// Providers returns all registered providers (snapshot).
|
||||
|
||||
@@ -37,32 +37,6 @@ func TestRegisterAndProviders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type priorityProvider struct {
|
||||
stubProvider
|
||||
priority int
|
||||
}
|
||||
|
||||
func (p *priorityProvider) Priority() int { return p.priority }
|
||||
|
||||
func TestRegister_PriorityOrder(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "env"}) // priority 10 (default)
|
||||
Register(&priorityProvider{stubProvider: stubProvider{name: "sidecar"}, priority: 0}) // priority 0 (first)
|
||||
|
||||
got := Providers()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(got))
|
||||
}
|
||||
if got[0].Name() != "sidecar" || got[1].Name() != "env" {
|
||||
t.Errorf("expected sidecar before env, got %s, %s", got[0].Name(), got[1].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviders_ReturnsSnapshot(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a noop credential provider for the auth sidecar
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set, this provider supplies
|
||||
// placeholder credentials so the CLI's auth pipeline can proceed normally.
|
||||
// Real tokens are never present in the sandbox; the sidecar transport
|
||||
// interceptor routes requests to the trusted sidecar process instead.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// Provider is the noop credential provider for sidecar mode.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "sidecar" }
|
||||
func (p *Provider) Priority() int { return 0 }
|
||||
|
||||
// ResolveAccount returns a minimal Account when sidecar mode is active.
|
||||
// The account contains AppID and Brand from environment variables, a
|
||||
// placeholder secret, and SupportedIdentities derived from STRICT_MODE.
|
||||
// Returns nil, nil when sidecar mode is not active (AUTH_PROXY not set).
|
||||
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return nil, nil // not in sidecar mode, skip
|
||||
}
|
||||
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q: %v", envvars.CliAuthProxy, proxyAddr, err),
|
||||
}
|
||||
}
|
||||
|
||||
appID := os.Getenv(envvars.CliAppID)
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliAppID + " is missing",
|
||||
}
|
||||
}
|
||||
|
||||
if os.Getenv(envvars.CliProxyKey) == "" {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliProxyKey + " is missing",
|
||||
}
|
||||
}
|
||||
|
||||
brand := credential.Brand(os.Getenv(envvars.CliBrand))
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
|
||||
acct := &credential.Account{
|
||||
AppID: appID,
|
||||
AppSecret: credential.NoAppSecret,
|
||||
Brand: brand,
|
||||
}
|
||||
|
||||
// Parse DefaultAs
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
case "", credential.IdentityAuto:
|
||||
acct.DefaultAs = id
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
acct.DefaultAs = id
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse SupportedIdentities from STRICT_MODE, default to SupportsAll.
|
||||
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
|
||||
case "bot":
|
||||
acct.SupportedIdentities = credential.SupportsBot
|
||||
case "user":
|
||||
acct.SupportedIdentities = credential.SupportsUser
|
||||
case "off", "":
|
||||
acct.SupportedIdentities = credential.SupportsAll
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
}
|
||||
}
|
||||
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
// ResolveToken returns a sentinel token whose value encodes the token type.
|
||||
// The transport interceptor reads this sentinel to determine the identity
|
||||
// (user vs bot), strips it, and the sidecar injects the real token.
|
||||
// Returns nil, nil when sidecar mode is not active.
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
if os.Getenv(envvars.CliAuthProxy) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var sentinel string
|
||||
switch req.Type {
|
||||
case credential.TokenTypeUAT:
|
||||
sentinel = sidecar.SentinelUAT
|
||||
case credential.TokenTypeTAT:
|
||||
sentinel = sidecar.SentinelTAT
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &credential.Token{
|
||||
Value: sentinel,
|
||||
Scopes: "", // empty → scope pre-check is skipped
|
||||
Source: "sidecar",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
credential.Register(&Provider{})
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
func setEnv(t *testing.T, key, value string) {
|
||||
t.Helper()
|
||||
old, hadOld := os.LookupEnv(key)
|
||||
os.Setenv(key, value)
|
||||
t.Cleanup(func() {
|
||||
if hadOld {
|
||||
os.Setenv(key, old)
|
||||
} else {
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func unsetEnv(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
old, hadOld := os.LookupEnv(key)
|
||||
os.Unsetenv(key)
|
||||
t.Cleanup(func() {
|
||||
if hadOld {
|
||||
os.Setenv(key, old)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveAccount_NotActive(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliAuthProxy)
|
||||
|
||||
p := &Provider{}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatal("expected nil account when AUTH_PROXY not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_Active(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
setEnv(t, envvars.CliAppID, "cli_test123")
|
||||
setEnv(t, envvars.CliBrand, "lark")
|
||||
unsetEnv(t, envvars.CliDefaultAs)
|
||||
unsetEnv(t, envvars.CliStrictMode)
|
||||
|
||||
p := &Provider{}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("expected non-nil account")
|
||||
}
|
||||
if acct.AppID != "cli_test123" {
|
||||
t.Errorf("AppID = %q, want %q", acct.AppID, "cli_test123")
|
||||
}
|
||||
if acct.Brand != credential.BrandLark {
|
||||
t.Errorf("Brand = %q, want %q", acct.Brand, credential.BrandLark)
|
||||
}
|
||||
if acct.AppSecret != credential.NoAppSecret {
|
||||
t.Errorf("AppSecret should be NoAppSecret, got %q", acct.AppSecret)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("SupportedIdentities = %d, want %d (SupportsAll)", acct.SupportedIdentities, credential.SupportsAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_MissingProxyKey(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
unsetEnv(t, envvars.CliProxyKey)
|
||||
setEnv(t, envvars.CliAppID, "cli_test")
|
||||
|
||||
p := &Provider{}
|
||||
_, err := p.ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when PROXY_KEY is missing")
|
||||
}
|
||||
if _, ok := err.(*credential.BlockError); !ok {
|
||||
t.Fatalf("expected BlockError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_MissingAppID(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
unsetEnv(t, envvars.CliAppID)
|
||||
|
||||
p := &Provider{}
|
||||
_, err := p.ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when APP_ID is missing")
|
||||
}
|
||||
if _, ok := err.(*credential.BlockError); !ok {
|
||||
t.Fatalf("expected BlockError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictMode(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
setEnv(t, envvars.CliAppID, "cli_test")
|
||||
|
||||
tests := []struct {
|
||||
mode string
|
||||
want credential.IdentitySupport
|
||||
}{
|
||||
{"bot", credential.SupportsBot},
|
||||
{"user", credential.SupportsUser},
|
||||
{"off", credential.SupportsAll},
|
||||
{"", credential.SupportsAll},
|
||||
}
|
||||
|
||||
p := &Provider{}
|
||||
for _, tt := range tests {
|
||||
t.Run("strict_"+tt.mode, func(t *testing.T) {
|
||||
if tt.mode == "" {
|
||||
unsetEnv(t, envvars.CliStrictMode)
|
||||
} else {
|
||||
setEnv(t, envvars.CliStrictMode, tt.mode)
|
||||
}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct.SupportedIdentities != tt.want {
|
||||
t.Errorf("SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_NotActive(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliAuthProxy)
|
||||
|
||||
p := &Provider{}
|
||||
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tok != nil {
|
||||
t.Fatal("expected nil token when AUTH_PROXY not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_Sentinels(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
|
||||
p := &Provider{}
|
||||
|
||||
// UAT
|
||||
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("UAT: unexpected error: %v", err)
|
||||
}
|
||||
if tok.Value != sidecar.SentinelUAT {
|
||||
t.Errorf("UAT value = %q, want %q", tok.Value, sidecar.SentinelUAT)
|
||||
}
|
||||
if tok.Scopes != "" {
|
||||
t.Errorf("UAT scopes should be empty, got %q", tok.Scopes)
|
||||
}
|
||||
|
||||
// TAT
|
||||
tok, err = p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
|
||||
if err != nil {
|
||||
t.Fatalf("TAT: unexpected error: %v", err)
|
||||
}
|
||||
if tok.Value != sidecar.SentinelTAT {
|
||||
t.Errorf("TAT value = %q, want %q", tok.Value, sidecar.SentinelTAT)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrAborted is a sentinel matched by errors.Is on any extension-triggered
|
||||
// round-trip abort. Callers that only need to know whether an error was
|
||||
// caused by an extension interception should use:
|
||||
//
|
||||
// if errors.Is(err, transport.ErrAborted) { ... }
|
||||
var ErrAborted = errors.New("round trip aborted by extension")
|
||||
|
||||
// AbortError is returned by the built-in middleware when an AbortableInterceptor
|
||||
// short-circuits a request via PreRoundTripE. It wraps the extension's original
|
||||
// reason and carries the extension's Provider.Name() for traceability.
|
||||
//
|
||||
// Use errors.As to recover the typed error:
|
||||
//
|
||||
// var aErr *transport.AbortError
|
||||
// if errors.As(err, &aErr) {
|
||||
// log.Printf("blocked by %s: %v", aErr.Extension, aErr.Reason)
|
||||
// }
|
||||
//
|
||||
// errors.Is(err, transport.ErrAborted) also works, and errors.Is against the
|
||||
// inner reason still works via Unwrap.
|
||||
type AbortError struct {
|
||||
// Extension is the name of the Provider whose interceptor aborted the
|
||||
// request (from Provider.Name()). May be empty if the provider did not
|
||||
// supply a name.
|
||||
Extension string
|
||||
// Reason is the original non-nil error returned by PreRoundTripE.
|
||||
Reason error
|
||||
}
|
||||
|
||||
func (e *AbortError) Error() string {
|
||||
if e.Extension != "" {
|
||||
return fmt.Sprintf("extension %q aborted round trip: %v", e.Extension, e.Reason)
|
||||
}
|
||||
return fmt.Sprintf("extension aborted round trip: %v", e.Reason)
|
||||
}
|
||||
|
||||
// Unwrap lets errors.Is / errors.As traverse to the underlying Reason.
|
||||
func (e *AbortError) Unwrap() error { return e.Reason }
|
||||
|
||||
// Is enables errors.Is(err, ErrAborted) at any nesting depth.
|
||||
func (e *AbortError) Is(target error) bool { return target == ErrAborted }
|
||||
@@ -1,103 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAbortError_Error(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *AbortError
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "with extension name",
|
||||
err: &AbortError{Extension: "audit", Reason: errors.New("bad")},
|
||||
want: `extension "audit" aborted round trip: bad`,
|
||||
},
|
||||
{
|
||||
name: "without extension name",
|
||||
err: &AbortError{Reason: errors.New("bad")},
|
||||
want: "extension aborted round trip: bad",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.err.Error(); got != tt.want {
|
||||
t.Fatalf("Error() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortError_Unwrap(t *testing.T) {
|
||||
reason := errors.New("bad")
|
||||
e := &AbortError{Reason: reason}
|
||||
if got := e.Unwrap(); got != reason {
|
||||
t.Fatalf("Unwrap() = %v, want %v", got, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortError_IsErrAborted(t *testing.T) {
|
||||
e := &AbortError{Reason: errors.New("bad")}
|
||||
if !errors.Is(e, ErrAborted) {
|
||||
t.Fatal("errors.Is(e, ErrAborted) = false, want true")
|
||||
}
|
||||
// Sanity: not matched by unrelated sentinels.
|
||||
if errors.Is(e, errors.New("other")) {
|
||||
t.Fatal("errors.Is matched unrelated sentinel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortError_UnwrapReachesInnerSentinel(t *testing.T) {
|
||||
// Extensions often return typed/sentinel errors; callers should still be
|
||||
// able to errors.Is against those after the middleware wraps them.
|
||||
innerSentinel := errors.New("policy-deny-42")
|
||||
e := &AbortError{Reason: fmt.Errorf("wrapped: %w", innerSentinel)}
|
||||
if !errors.Is(e, innerSentinel) {
|
||||
t.Fatal("errors.Is(e, innerSentinel) = false, want true (Unwrap chain broken)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortError_As(t *testing.T) {
|
||||
reason := errors.New("bad")
|
||||
base := &AbortError{Extension: "audit", Reason: reason}
|
||||
|
||||
// Direct As.
|
||||
var aErr *AbortError
|
||||
if !errors.As(base, &aErr) {
|
||||
t.Fatal("errors.As(base, *AbortError) = false")
|
||||
}
|
||||
if aErr.Extension != "audit" || aErr.Reason != reason {
|
||||
t.Fatalf("aErr = %+v, want {audit, bad}", aErr)
|
||||
}
|
||||
|
||||
// Nested As: even when the *AbortError is wrapped in another error,
|
||||
// errors.As must still find it via Unwrap chain.
|
||||
wrapped := fmt.Errorf("outer: %w", base)
|
||||
var aErr2 *AbortError
|
||||
if !errors.As(wrapped, &aErr2) {
|
||||
t.Fatal("errors.As(wrapped, *AbortError) = false")
|
||||
}
|
||||
if aErr2 != base {
|
||||
t.Fatalf("aErr2 = %p, want %p", aErr2, base)
|
||||
}
|
||||
|
||||
// errors.Is still matches the sentinel through the outer wrapper.
|
||||
if !errors.Is(wrapped, ErrAborted) {
|
||||
t.Fatal("errors.Is(wrapped, ErrAborted) = false via nested wrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrAborted_IsItselfSentinel(t *testing.T) {
|
||||
// Guard against accidental re-assignment of ErrAborted: a bare ErrAborted
|
||||
// value should still satisfy errors.Is(err, ErrAborted) for symmetry.
|
||||
if !errors.Is(ErrAborted, ErrAborted) {
|
||||
t.Fatal("errors.Is(ErrAborted, ErrAborted) = false")
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a transport interceptor for the auth sidecar
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
|
||||
// outgoing requests are rewritten to the sidecar address. The interceptor
|
||||
// strips placeholder credentials, injects proxy headers, and signs each
|
||||
// request with HMAC-SHA256. No custom DialContext is needed — Go's
|
||||
// standard http.Transport connects to the sidecar via plain HTTP.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// Provider implements transport.Provider for the sidecar mode.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "sidecar" }
|
||||
|
||||
// ResolveInterceptor returns a SidecarInterceptor when sidecar mode is active.
|
||||
// Returns nil when sidecar mode is disabled or the proxy address is invalid;
|
||||
// in the latter case a warning is emitted to stderr and requests fall back to
|
||||
// the non-sidecar transport path (where the credential layer will typically
|
||||
// block them for lack of a valid account).
|
||||
func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return nil
|
||||
}
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envvars.CliAuthProxy, err)
|
||||
return nil
|
||||
}
|
||||
key := os.Getenv(envvars.CliProxyKey)
|
||||
return &Interceptor{
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
}
|
||||
}
|
||||
|
||||
// Interceptor rewrites requests for the sidecar proxy.
|
||||
type Interceptor struct {
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host:port for URL rewriting
|
||||
}
|
||||
|
||||
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
||||
// sentinel token. Requests without a sentinel token (e.g. pre-signed download
|
||||
// URLs) are passed through unmodified.
|
||||
//
|
||||
// Supports two auth patterns:
|
||||
// - Standard OpenAPI: Authorization: Bearer <sentinel>
|
||||
// - MCP protocol: X-Lark-MCP-UAT/TAT: <sentinel>
|
||||
func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response, err error) {
|
||||
identity, authHeader := detectSentinel(req)
|
||||
if identity == "" {
|
||||
return nil // not a sidecar-managed request, pass through
|
||||
}
|
||||
|
||||
// 1. Buffer the body first, before mutating any request state. A partial
|
||||
// read would sign a truncated body and cause a misleading HMAC mismatch
|
||||
// on the sidecar side; bail out early and let the request fall through
|
||||
// unmodified so the credential layer can surface an actionable error.
|
||||
var bodyBytes []byte
|
||||
if req.Body != nil {
|
||||
var err error
|
||||
bodyBytes, err = io.ReadAll(req.Body)
|
||||
_ = req.Body.Close() // release original body (fd/pipe/etc.) after buffering
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: sidecar interceptor failed to read request body: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
if req.GetBody != nil {
|
||||
req.GetBody = func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Save original target (scheme://host)
|
||||
originalScheme := "https"
|
||||
if req.URL.Scheme != "" {
|
||||
originalScheme = req.URL.Scheme
|
||||
}
|
||||
originalHost := req.URL.Host
|
||||
req.Header.Set(sidecar.HeaderProxyTarget, originalScheme+"://"+originalHost)
|
||||
|
||||
// 3. Set identity and tell sidecar which header to inject real token into
|
||||
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
|
||||
|
||||
// 4. Strip placeholder auth header(s)
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Del(sidecar.HeaderMCPUAT)
|
||||
req.Header.Del(sidecar.HeaderMCPTAT)
|
||||
|
||||
bodySHA := sidecar.BodySHA256(bodyBytes)
|
||||
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
|
||||
|
||||
pathAndQuery := req.URL.RequestURI()
|
||||
ts := sidecar.Timestamp()
|
||||
// Cover identity and authHeader in the signature so an on-path attacker
|
||||
// within the replay window cannot flip the injected token's identity or
|
||||
// redirect the token into a different header.
|
||||
sig := sidecar.Sign(i.key, sidecar.CanonicalRequest{
|
||||
Version: sidecar.ProtocolV1,
|
||||
Method: req.Method,
|
||||
Host: originalHost,
|
||||
PathAndQuery: pathAndQuery,
|
||||
BodySHA256: bodySHA,
|
||||
Timestamp: ts,
|
||||
Identity: identity,
|
||||
AuthHeader: authHeader,
|
||||
})
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||
|
||||
// 5. Rewrite URL to route through sidecar
|
||||
req.URL.Scheme = "http"
|
||||
req.URL.Host = i.sidecarHost
|
||||
|
||||
return nil // no post-hook needed
|
||||
}
|
||||
|
||||
// detectSentinel checks both standard Authorization and MCP auth headers for
|
||||
// sentinel tokens. Returns the identity ("user"/"bot") and the header name
|
||||
// that carried the sentinel.
|
||||
//
|
||||
// Returns ("", "") when the request carries no sentinel token — typically
|
||||
// requests that require no auth (e.g. pre-signed download URLs where the
|
||||
// token is embedded in the URL query parameters).
|
||||
func detectSentinel(req *http.Request) (identity, authHeader string) {
|
||||
// Check standard Authorization: Bearer <sentinel>
|
||||
if auth := req.Header.Get("Authorization"); auth != "" {
|
||||
token := strings.TrimPrefix(auth, "Bearer ")
|
||||
switch token {
|
||||
case sidecar.SentinelUAT:
|
||||
return sidecar.IdentityUser, "Authorization"
|
||||
case sidecar.SentinelTAT:
|
||||
return sidecar.IdentityBot, "Authorization"
|
||||
}
|
||||
}
|
||||
// Check MCP headers: X-Lark-MCP-UAT/TAT: <sentinel>
|
||||
if v := req.Header.Get(sidecar.HeaderMCPUAT); v == sidecar.SentinelUAT {
|
||||
return sidecar.IdentityUser, sidecar.HeaderMCPUAT
|
||||
}
|
||||
if v := req.Header.Get(sidecar.HeaderMCPTAT); v == sidecar.SentinelTAT {
|
||||
return sidecar.IdentityBot, sidecar.HeaderMCPTAT
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return
|
||||
}
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envvars.CliAuthProxy, err)
|
||||
return
|
||||
}
|
||||
transport.Register(&Provider{})
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
|
||||
type failingBody struct {
|
||||
err error
|
||||
closed bool
|
||||
readCall bool
|
||||
}
|
||||
|
||||
func (b *failingBody) Read(p []byte) (int, error) {
|
||||
b.readCall = true
|
||||
return 0, b.err
|
||||
}
|
||||
|
||||
func (b *failingBody) Close() error {
|
||||
b.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInterceptor_PreRoundTrip(t *testing.T) {
|
||||
key := []byte("test-key-for-hmac-signing-32byte!")
|
||||
interceptor := &Interceptor{key: key, sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
body := []byte(`{"msg":"hello"}`)
|
||||
req, _ := http.NewRequest("POST", "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", io.NopCloser(bytes.NewReader(body)))
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
req.Header.Set("X-Cli-Source", "lark-cli")
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook")
|
||||
}
|
||||
|
||||
// URL should be rewritten to sidecar
|
||||
if req.URL.Scheme != "http" {
|
||||
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "http")
|
||||
}
|
||||
if req.URL.Host != "127.0.0.1:16384" {
|
||||
t.Errorf("host = %q, want %q", req.URL.Host, "127.0.0.1:16384")
|
||||
}
|
||||
|
||||
// Original target should be preserved
|
||||
target := req.Header.Get(sidecar.HeaderProxyTarget)
|
||||
if target != "https://open.feishu.cn" {
|
||||
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
|
||||
}
|
||||
|
||||
// Identity should be user (from SentinelUAT)
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
|
||||
}
|
||||
|
||||
// Authorization should be stripped
|
||||
if auth := req.Header.Get("Authorization"); auth != "" {
|
||||
t.Errorf("Authorization header should be stripped, got %q", auth)
|
||||
}
|
||||
|
||||
// HMAC headers should be set
|
||||
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
|
||||
t.Error("signature header should be set")
|
||||
}
|
||||
if ts := req.Header.Get(sidecar.HeaderProxyTimestamp); ts == "" {
|
||||
t.Error("timestamp header should be set")
|
||||
}
|
||||
if sha := req.Header.Get(sidecar.HeaderBodySHA256); sha == "" {
|
||||
t.Error("body SHA256 header should be set")
|
||||
}
|
||||
if v := req.Header.Get(sidecar.HeaderProxyVersion); v != sidecar.ProtocolV1 {
|
||||
t.Errorf("version header = %q, want %q", v, sidecar.ProtocolV1)
|
||||
}
|
||||
|
||||
// Non-proxy headers should be preserved
|
||||
if src := req.Header.Get("X-Cli-Source"); src != "lark-cli" {
|
||||
t.Errorf("X-Cli-Source should be preserved, got %q", src)
|
||||
}
|
||||
|
||||
// Body should still be readable
|
||||
readBody, _ := io.ReadAll(req.Body)
|
||||
if !bytes.Equal(readBody, body) {
|
||||
t.Errorf("body should be preserved after PreRoundTrip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_BotIdentity(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/calendar/v4/events", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_NonSentinelToken_PassThrough(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
origURL := "https://some-cdn.example.com/presigned-download?token=abc"
|
||||
req, _ := http.NewRequest("GET", origURL, nil)
|
||||
req.Header.Set("Authorization", "Bearer some-real-token")
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
// Should NOT be rewritten — no sentinel token
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook for pass-through")
|
||||
}
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged, got %q", req.URL.String())
|
||||
}
|
||||
if req.Header.Get(sidecar.HeaderProxyTarget) != "" {
|
||||
t.Error("proxy target header should not be set for pass-through")
|
||||
}
|
||||
if req.Header.Get("Authorization") != "Bearer some-real-token" {
|
||||
t.Error("Authorization should be preserved for pass-through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_NoAuth_PassThrough(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
origURL := "https://cdn.feishu.cn/download/file"
|
||||
req, _ := http.NewRequest("GET", origURL, nil)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
// No Authorization header at all — should pass through
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged for no-auth request, got %q", req.URL.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_MCP_UAT(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{"jsonrpc":"2.0"}`)))
|
||||
req.Header.Set(sidecar.HeaderMCPUAT, sidecar.SentinelUAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
// Should be intercepted and rewritten
|
||||
if req.URL.Host != "127.0.0.1:16384" {
|
||||
t.Errorf("host = %q, want sidecar host", req.URL.Host)
|
||||
}
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
|
||||
}
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPUAT {
|
||||
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPUAT)
|
||||
}
|
||||
// MCP sentinel should be stripped
|
||||
if v := req.Header.Get(sidecar.HeaderMCPUAT); v != "" {
|
||||
t.Errorf("MCP-UAT should be stripped, got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_MCP_TAT(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{}`)))
|
||||
req.Header.Set(sidecar.HeaderMCPTAT, sidecar.SentinelTAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
|
||||
}
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPTAT {
|
||||
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPTAT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_StandardAuth_SetsAuthorizationHeader(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != "Authorization" {
|
||||
t.Errorf("auth header = %q, want %q", ah, "Authorization")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptor_BodyReadError verifies that when io.ReadAll on the request
|
||||
// body fails partway, PreRoundTrip skips the rewrite entirely rather than
|
||||
// signing a truncated body (which would produce a misleading HMAC mismatch on
|
||||
// the sidecar side) and releases the original body.
|
||||
func TestInterceptor_BodyReadError(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
const origURL = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||
body := &failingBody{err: errors.New("disk gremlin")}
|
||||
|
||||
req, _ := http.NewRequest("POST", origURL, body)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook on body read failure")
|
||||
}
|
||||
|
||||
// Original body must be closed to avoid leaking fd/pipe-like resources.
|
||||
if !body.readCall {
|
||||
t.Error("expected ReadAll to have attempted reading from the body")
|
||||
}
|
||||
if !body.closed {
|
||||
t.Error("expected original body to be Close()'d after read failure")
|
||||
}
|
||||
|
||||
// URL must NOT be rewritten — request should fall through to the next
|
||||
// layer (credential) which can surface a meaningful error.
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged on read failure, got %q", req.URL.String())
|
||||
}
|
||||
|
||||
// No proxy/HMAC headers should leak onto the request.
|
||||
for _, h := range []string{
|
||||
sidecar.HeaderProxyVersion,
|
||||
sidecar.HeaderProxyTarget,
|
||||
sidecar.HeaderProxySignature,
|
||||
sidecar.HeaderProxyTimestamp,
|
||||
sidecar.HeaderBodySHA256,
|
||||
sidecar.HeaderProxyIdentity,
|
||||
sidecar.HeaderProxyAuthHeader,
|
||||
} {
|
||||
if v := req.Header.Get(h); v != "" {
|
||||
t.Errorf("%s should not be set on read failure, got %q", h, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_EmptyBody(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/path", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
sha := req.Header.Get(sidecar.HeaderBodySHA256)
|
||||
expectedEmpty := sidecar.BodySHA256(nil)
|
||||
if sha != expectedEmpty {
|
||||
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
|
||||
}
|
||||
}
|
||||
@@ -27,31 +27,6 @@ type Provider interface {
|
||||
//
|
||||
// The returned function (if non-nil) is called after the built-in chain
|
||||
// completes. Use it for logging, ending trace spans, or recording metrics.
|
||||
//
|
||||
// Body note: the middleware Clones the caller's request before invoking the
|
||||
// interceptor, which copies headers/URL/etc. but shares the underlying
|
||||
// io.ReadCloser. Extensions that read req.Body are responsible for restoring
|
||||
// a replayable body (e.g. via req.GetBody) before returning, otherwise the
|
||||
// built-in chain will see an exhausted stream.
|
||||
type Interceptor interface {
|
||||
PreRoundTrip(req *http.Request) func(resp *http.Response, err error)
|
||||
}
|
||||
|
||||
// AbortableInterceptor is an optional extension of Interceptor that lets an
|
||||
// extension reject a request before the built-in chain runs. Extensions that
|
||||
// implement this interface are detected by the built-in middleware via a
|
||||
// type assertion; both methods must be present, but when an extension
|
||||
// implements PreRoundTripE the middleware will NOT call PreRoundTrip.
|
||||
//
|
||||
// Returning a non-nil error from PreRoundTripE aborts the request: the
|
||||
// built-in chain is not executed and the middleware returns an *AbortError
|
||||
// wrapping the reason. The returned post function (if non-nil) is still
|
||||
// invoked with (nil, reason) so that extensions can unwind any state they
|
||||
// created in the pre hook (spans, metrics, audit records).
|
||||
//
|
||||
// Extensions that only care about the abortable variant can provide a no-op
|
||||
// PreRoundTrip method alongside PreRoundTripE to satisfy Interceptor.
|
||||
type AbortableInterceptor interface {
|
||||
Interceptor
|
||||
PreRoundTripE(req *http.Request) (post func(resp *http.Response, err error), err error)
|
||||
}
|
||||
|
||||
3
go.mod
3
go.mod
@@ -3,13 +3,12 @@ module github.com/larksuite/cli
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/gofrs/flock v0.8.1
|
||||
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/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
|
||||
6
go.sum
6
go.sum
@@ -1,7 +1,5 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
@@ -71,8 +69,8 @@ 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/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/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package appmeta exposes read-only views of a Feishu app's published version, subscribed event types, and scopes.
|
||||
package appmeta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// APIClient aliases event.APIClient so one concrete adapter satisfies event, appmeta, and consume.
|
||||
type APIClient = event.APIClient
|
||||
|
||||
// AppVersion is the projected subset of one /app_versions item preflight cares about.
|
||||
type AppVersion struct {
|
||||
VersionID string
|
||||
Version string
|
||||
EventTypes []string
|
||||
TenantScopes []string
|
||||
}
|
||||
|
||||
const appVersionStatusPublished = 1
|
||||
|
||||
// FetchCurrentPublished returns the most recently published version of appID, or (nil, nil) if never published.
|
||||
// page_size=2 suffices: Feishu disallows a new version while an in-progress one exists, so the first status==1 item with publish_time is the live one.
|
||||
func FetchCurrentPublished(ctx context.Context, client APIClient, appID string) (*AppVersion, error) {
|
||||
path := fmt.Sprintf(
|
||||
"/open-apis/application/v6/applications/%s/app_versions?lang=zh_cn&page_size=2",
|
||||
appID,
|
||||
)
|
||||
raw, err := client.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
VersionID string `json:"version_id"`
|
||||
Version string `json:"version"`
|
||||
Status int `json:"status"`
|
||||
PublishTime json.RawMessage `json:"publish_time"`
|
||||
EventInfos []struct {
|
||||
EventType string `json:"event_type"`
|
||||
} `json:"event_infos"`
|
||||
Scopes []struct {
|
||||
Scope string `json:"scope"`
|
||||
TokenTypes []string `json:"token_types"`
|
||||
} `json:"scopes"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("decode app_versions response: %w", err)
|
||||
}
|
||||
|
||||
for _, it := range envelope.Data.Items {
|
||||
if it.Status != appVersionStatusPublished || !publishTimeSet(it.PublishTime) {
|
||||
continue
|
||||
}
|
||||
v := &AppVersion{
|
||||
VersionID: it.VersionID,
|
||||
Version: it.Version,
|
||||
}
|
||||
for _, e := range it.EventInfos {
|
||||
if e.EventType != "" {
|
||||
v.EventTypes = append(v.EventTypes, e.EventType)
|
||||
}
|
||||
}
|
||||
for _, s := range it.Scopes {
|
||||
if s.Scope != "" && containsString(s.TokenTypes, "tenant") {
|
||||
v.TenantScopes = append(v.TenantScopes, s.Scope)
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// publishTimeSet rejects null and empty-string; any other value is a real publish_time.
|
||||
func publishTimeSet(raw json.RawMessage) bool {
|
||||
s := string(raw)
|
||||
return s != "" && s != "null" && s != `""`
|
||||
}
|
||||
|
||||
func containsString(haystack []string, needle string) bool {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package appmeta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/testutil"
|
||||
)
|
||||
|
||||
const respFourVersions = `{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"has_more": false,
|
||||
"items": [
|
||||
{"version_id": "oav_draft", "version": "1.0.3", "status": 4, "publish_time": null,
|
||||
"event_infos": [{"event_type": "im.message.receive_v1"}, {"event_type": "mail.user_mailbox.event.message_received_v1"}],
|
||||
"scopes": [{"scope": "draft:only", "token_types": ["tenant"]}]
|
||||
},
|
||||
{"version_id": "oav_latest", "version": "1.0.2", "status": 1, "publish_time": "1776684746",
|
||||
"event_infos": [
|
||||
{"event_type": "im.message.receive_v1"},
|
||||
{"event_type": "im.message.message_read_v1"}
|
||||
],
|
||||
"scopes": [
|
||||
{"scope": "im:message", "token_types": ["tenant", "user"]},
|
||||
{"scope": "im:message.group_at_msg", "token_types": ["tenant"]},
|
||||
{"scope": "contact:user:readonly", "token_types": ["user"]}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
func TestFetchCurrentPublished_SelectsLatestPublished(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: respFourVersions}
|
||||
|
||||
v, err := FetchCurrentPublished(context.Background(), c, "cli_test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v == nil {
|
||||
t.Fatal("expected a version, got nil")
|
||||
}
|
||||
if v.VersionID != "oav_latest" {
|
||||
t.Errorf("VersionID = %q, want oav_latest", v.VersionID)
|
||||
}
|
||||
if v.Version != "1.0.2" {
|
||||
t.Errorf("Version = %q, want 1.0.2", v.Version)
|
||||
}
|
||||
|
||||
wantEvents := map[string]bool{"im.message.receive_v1": true, "im.message.message_read_v1": true}
|
||||
if len(v.EventTypes) != len(wantEvents) {
|
||||
t.Fatalf("EventTypes = %v, want %v", v.EventTypes, wantEvents)
|
||||
}
|
||||
for _, e := range v.EventTypes {
|
||||
if !wantEvents[e] {
|
||||
t.Errorf("unexpected event type %q in %v", e, v.EventTypes)
|
||||
}
|
||||
}
|
||||
|
||||
wantTenant := map[string]bool{"im:message": true, "im:message.group_at_msg": true}
|
||||
if len(v.TenantScopes) != len(wantTenant) {
|
||||
t.Fatalf("TenantScopes = %v, want %v", v.TenantScopes, wantTenant)
|
||||
}
|
||||
for _, s := range v.TenantScopes {
|
||||
if !wantTenant[s] {
|
||||
t.Errorf("unexpected tenant scope %q in %v", s, v.TenantScopes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCurrentPublished_PathContainsQuery(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: respFourVersions}
|
||||
_, _ = FetchCurrentPublished(context.Background(), c, "cli_x")
|
||||
for _, want := range []string{
|
||||
"/open-apis/application/v6/applications/cli_x/app_versions",
|
||||
"lang=zh_cn",
|
||||
"page_size=2",
|
||||
} {
|
||||
if !strings.Contains(c.GotPath, want) {
|
||||
t.Errorf("path %q missing %q", c.GotPath, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCurrentPublished_NoPublishedYet(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"items":[
|
||||
{"version_id":"oav_draft","status":4,"publish_time":null,"event_infos":[],"scopes":[]}
|
||||
]}}`}
|
||||
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v != nil {
|
||||
t.Errorf("want nil (app never published), got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCurrentPublished_EmptyItems(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"items":[]}}`}
|
||||
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v != nil {
|
||||
t.Errorf("want nil for empty items, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCurrentPublished_APIErrorPropagated(t *testing.T) {
|
||||
want := errors.New("insufficient permission level")
|
||||
c := &testutil.StubAPIClient{Err: want}
|
||||
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
|
||||
if !errors.Is(err, want) {
|
||||
t.Errorf("err = %v, want wrapping %v", err, want)
|
||||
}
|
||||
if v != nil {
|
||||
t.Errorf("want nil version on error, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCurrentPublished_PublishTimeEmptyStringTreatedAsUnpublished(t *testing.T) {
|
||||
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"items":[
|
||||
{"version_id":"oav_x","status":1,"publish_time":"","event_infos":[],"scopes":[]}
|
||||
]}}`}
|
||||
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v != nil {
|
||||
t.Errorf("want nil (empty publish_time), got %+v", v)
|
||||
}
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
|
||||
errStr := getStr(data, "error")
|
||||
|
||||
if errStr == "" && getStr(data, "access_token") != "" {
|
||||
fmt.Fprintf(errOut, "[lark-cli] device-flow: token response received\n")
|
||||
fmt.Fprintf(errOut, "[lark-cli] device-flow: token obtained successfully\n")
|
||||
refreshToken := getStr(data, "refresh_token")
|
||||
tokenExpiresIn := getInt(data, "expires_in", 7200)
|
||||
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 604800)
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// AuditParams holds parameters for AssertSecurePath.
|
||||
type AuditParams struct {
|
||||
TargetPath string
|
||||
Label string // e.g. "secrets.providers.vault.command"
|
||||
TrustedDirs []string
|
||||
AllowInsecurePath bool
|
||||
AllowReadableByOthers bool
|
||||
AllowSymlinkPath bool
|
||||
}
|
||||
|
||||
// AssertSecurePath verifies that a file/command path is safe for use with
|
||||
// OpenClaw SecretRef resolution. On success it returns the effective path
|
||||
// (the symlink target, if the input was a symlink and allowed).
|
||||
//
|
||||
// The check is a short, ordered pipeline — each step below is both a read of
|
||||
// the contract and a pointer to the helper that enforces it.
|
||||
func AssertSecurePath(params AuditParams) (string, error) {
|
||||
target := params.TargetPath
|
||||
label := params.Label
|
||||
|
||||
if err := requireAbsolutePath(target, label); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
linfo, err := lstatNonDir(target, label)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
effectivePath, err := resolveSymlinkIfAllowed(target, linfo, params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := requireInTrustedDirs(effectivePath, params.TrustedDirs, label); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if params.AllowInsecurePath {
|
||||
return effectivePath, nil
|
||||
}
|
||||
|
||||
if err := auditFilePermissions(effectivePath, params.AllowReadableByOthers, label); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := checkOwnerUID(effectivePath, label); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return effectivePath, nil
|
||||
}
|
||||
|
||||
// requireAbsolutePath rejects relative paths; relative paths would depend on
|
||||
// the process cwd and defeat the point of a static audit.
|
||||
func requireAbsolutePath(target, label string) error {
|
||||
if !filepath.IsAbs(target) {
|
||||
return fmt.Errorf("%s: path must be absolute, got %q", label, target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lstatNonDir stats the path without following symlinks, rejecting
|
||||
// directories. Returns the stat info for downstream steps to reuse.
|
||||
func lstatNonDir(target, label string) (fs.FileInfo, error) {
|
||||
info, err := vfs.Lstat(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: cannot stat %q: %w", label, target, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("%s: path %q is a directory, not a file", label, target)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// resolveSymlinkIfAllowed resolves a symlink to its target when
|
||||
// params.AllowSymlinkPath is true, or rejects it otherwise. When the input
|
||||
// is not a symlink, target is returned unchanged. A symlink that points to
|
||||
// another symlink is rejected so callers only deal with a single hop.
|
||||
func resolveSymlinkIfAllowed(target string, linfo fs.FileInfo, params AuditParams) (string, error) {
|
||||
if linfo.Mode()&os.ModeSymlink == 0 {
|
||||
return target, nil
|
||||
}
|
||||
if !params.AllowSymlinkPath {
|
||||
return "", fmt.Errorf("%s: path %q is a symlink (not allowed)", params.Label, target)
|
||||
}
|
||||
resolved, err := vfs.EvalSymlinks(target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: cannot resolve symlink %q: %w", params.Label, target, err)
|
||||
}
|
||||
rinfo, err := vfs.Lstat(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: cannot stat resolved path %q: %w", params.Label, resolved, err)
|
||||
}
|
||||
if rinfo.Mode()&os.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("%s: resolved path %q is still a symlink", params.Label, resolved)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// requireInTrustedDirs enforces that effectivePath lives under one of the
|
||||
// caller-declared trusted directories, if any were declared. An empty
|
||||
// trustedDirs list disables the check.
|
||||
func requireInTrustedDirs(effectivePath string, trustedDirs []string, label string) error {
|
||||
if len(trustedDirs) == 0 {
|
||||
return nil
|
||||
}
|
||||
cleaned := filepath.Clean(effectivePath)
|
||||
for _, dir := range trustedDirs {
|
||||
cleanDir := filepath.Clean(dir)
|
||||
if cleaned == cleanDir || strings.HasPrefix(cleaned, cleanDir+"/") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
|
||||
}
|
||||
|
||||
// auditFilePermissions rejects world/group-writable modes (always) and
|
||||
// world/group-readable modes (unless allowReadableByOthers is true, which
|
||||
// exec commands typically need for their usual 755 mode).
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
info, err := vfs.Stat(effectivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
|
||||
if mode&0o002 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o020 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if allowReadableByOthers {
|
||||
return nil
|
||||
}
|
||||
if mode&0o004 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o040 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssertSecurePath_NonAbsolutePath(t *testing.T) {
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: "relative/path.txt",
|
||||
Label: "test",
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-absolute path, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("test: path must be absolute, got %q", "relative/path.txt")
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_FileDoesNotExist(t *testing.T) {
|
||||
nonexistent := filepath.Join(t.TempDir(), "nonexistent.txt")
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: nonexistent,
|
||||
Label: "test",
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent file, got nil")
|
||||
}
|
||||
wantPrefix := fmt.Sprintf("test: cannot stat %q: ", nonexistent)
|
||||
if !strings.HasPrefix(err.Error(), wantPrefix) {
|
||||
t.Errorf("error = %q, want prefix %q", err.Error(), wantPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_ValidAbsolutePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "valid.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_WorldWritable_Rejected(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission tests not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "insecure.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
if err := os.Chmod(p, 0o666); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: true, // only test writable check
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for world-writable file, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("test: path %q is world-writable (mode 0666)", p)
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_AllowInsecurePath_Bypasses(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission tests not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "insecure.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
if err := os.Chmod(p, 0o666); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_DirectoryRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: dir,
|
||||
Label: "test",
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for directory path, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("test: path %q is a directory, not a file", dir)
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_GroupWritable_Rejected(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission tests not applicable on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "groupw.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.Chmod(p, 0o620); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for group-writable file, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("test: path %q is group-writable (mode 0620)", p)
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_WorldReadable_Rejected(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission tests not applicable on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "worldr.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.Chmod(p, 0o604); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: false,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for world-readable file, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("test: path %q is world-readable (mode 0604)", p)
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_AllowReadableByOthers_Passes(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission tests not applicable on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "readable.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.Chmod(p, 0o644); err != nil {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_OwnerUID_Valid(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("owner UID tests not applicable on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "owned.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_Symlink_Rejected(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink tests not applicable on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "real.txt")
|
||||
if err := os.WriteFile(target, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "link.txt")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: link,
|
||||
Label: "test",
|
||||
AllowSymlinkPath: false,
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for symlink with AllowSymlinkPath=false, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("test: path %q is a symlink (not allowed)", link)
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_Symlink_Allowed(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink tests not applicable on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "real.txt")
|
||||
if err := os.WriteFile(target, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "link.txt")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: link,
|
||||
Label: "test",
|
||||
AllowSymlinkPath: true,
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// On macOS /var → /private/var, so compare resolved paths
|
||||
wantResolved, err := filepath.EvalSymlinks(target)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks(target): %v", err)
|
||||
}
|
||||
if got != wantResolved {
|
||||
t.Errorf("got %q, want resolved %q", got, wantResolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_TrustedDirs_ExactMatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "file.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "test",
|
||||
TrustedDirs: []string{p},
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertSecurePath_TrustedDirs(t *testing.T) {
|
||||
trustedDir := t.TempDir()
|
||||
untrustedDir := t.TempDir()
|
||||
|
||||
trustedFile := filepath.Join(trustedDir, "secret.txt")
|
||||
if err := os.WriteFile(trustedFile, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
untrustedFile := filepath.Join(untrustedDir, "secret.txt")
|
||||
if err := os.WriteFile(untrustedFile, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
// File outside trusted dir should fail
|
||||
_, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: untrustedFile,
|
||||
Label: "test",
|
||||
TrustedDirs: []string{trustedDir},
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for file outside trusted dir, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("test: path %q is not inside any trusted directory", untrustedFile)
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
|
||||
// File inside trusted dir should pass
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: trustedFile,
|
||||
Label: "test",
|
||||
TrustedDirs: []string{trustedDir},
|
||||
AllowInsecurePath: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != trustedFile {
|
||||
t.Errorf("got %q, want %q", got, trustedFile)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// checkOwnerUID verifies the file is owned by the current user.
|
||||
func checkOwnerUID(path, label string) error {
|
||||
stat, err := vfs.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, path, err)
|
||||
}
|
||||
sysStat, ok := stat.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: cannot retrieve file owner for %q", label, path)
|
||||
}
|
||||
if sysStat.Uid != uint32(os.Getuid()) {
|
||||
return fmt.Errorf("%s: path %q is owned by uid %d, expected %d",
|
||||
label, path, sysStat.Uid, os.Getuid())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package binding
|
||||
|
||||
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
|
||||
func checkOwnerUID(path, label string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReadJSONPointer navigates a parsed JSON value (typically the result of
|
||||
// json.Unmarshal into interface{}) using an RFC 6901 JSON Pointer string.
|
||||
//
|
||||
// Supported pointer format: "/key/subkey/subsubkey".
|
||||
// An empty pointer ("") returns data as-is.
|
||||
// RFC 6901 escape sequences: ~1 → /, ~0 → ~.
|
||||
//
|
||||
// Limitation: only object (map) traversal is supported. Array index segments
|
||||
// (e.g., "/channels/0/appId") are not implemented because OpenClaw's
|
||||
// SecretRef file provider uses object-only paths in practice.
|
||||
func ReadJSONPointer(data interface{}, pointer string) (interface{}, error) {
|
||||
if pointer == "" {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(pointer, "/") {
|
||||
return nil, fmt.Errorf("json pointer must start with '/' or be empty, got %q", pointer)
|
||||
}
|
||||
|
||||
// Split after the leading "/" and decode each segment.
|
||||
segments := strings.Split(pointer[1:], "/")
|
||||
current := data
|
||||
|
||||
for i, raw := range segments {
|
||||
// RFC 6901 unescaping: ~1 → /, ~0 → ~ (order matters).
|
||||
key := strings.ReplaceAll(raw, "~1", "/")
|
||||
key = strings.ReplaceAll(key, "~0", "~")
|
||||
|
||||
m, ok := current.(map[string]interface{})
|
||||
if !ok {
|
||||
traversed := "/" + strings.Join(segments[:i], "/")
|
||||
return nil, fmt.Errorf("json pointer %q: value at %q is %T, not an object",
|
||||
pointer, traversed, current)
|
||||
}
|
||||
|
||||
val, exists := m[key]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("json pointer %q: key %q not found", pointer, key)
|
||||
}
|
||||
|
||||
current = val
|
||||
}
|
||||
|
||||
return current, nil
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadJSONPointer_EmptyPointer(t *testing.T) {
|
||||
data := map[string]interface{}{"key": "value"}
|
||||
got, err := ReadJSONPointer(data, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", got)
|
||||
}
|
||||
if m["key"] != "value" {
|
||||
t.Errorf("got %v, want map with key=value", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_OneLevel(t *testing.T) {
|
||||
data := map[string]interface{}{"key": "hello"}
|
||||
got, err := ReadJSONPointer(data, "/key")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "hello" {
|
||||
t.Errorf("got %v, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_TwoLevels(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"key": map[string]interface{}{
|
||||
"subkey": "deep_value",
|
||||
},
|
||||
}
|
||||
got, err := ReadJSONPointer(data, "/key/subkey")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "deep_value" {
|
||||
t.Errorf("got %v, want %q", got, "deep_value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_MissingKey(t *testing.T) {
|
||||
data := map[string]interface{}{"key": "value"}
|
||||
_, err := ReadJSONPointer(data, "/nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing key, got nil")
|
||||
}
|
||||
want := `json pointer "/nonexistent": key "nonexistent" not found`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_NonMapIntermediate(t *testing.T) {
|
||||
data := map[string]interface{}{"key": "scalar_string"}
|
||||
_, err := ReadJSONPointer(data, "/key/subkey")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-map intermediate, got nil")
|
||||
}
|
||||
want := `json pointer "/key/subkey": value at "/key" is string, not an object`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_RFC6901_Escaping(t *testing.T) {
|
||||
// ~1 decodes to / and ~0 decodes to ~
|
||||
data := map[string]interface{}{
|
||||
"a/b": "slash_value",
|
||||
"c~d": "tilde_value",
|
||||
}
|
||||
|
||||
// ~1 -> /
|
||||
got, err := ReadJSONPointer(data, "/a~1b")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for ~1 escape: %v", err)
|
||||
}
|
||||
if got != "slash_value" {
|
||||
t.Errorf("got %v, want %q", got, "slash_value")
|
||||
}
|
||||
|
||||
// ~0 -> ~
|
||||
got, err = ReadJSONPointer(data, "/c~0d")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for ~0 escape: %v", err)
|
||||
}
|
||||
if got != "tilde_value" {
|
||||
t.Errorf("got %v, want %q", got, "tilde_value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadJSONPointer_InvalidFormat(t *testing.T) {
|
||||
data := map[string]interface{}{"key": "val"}
|
||||
_, err := ReadJSONPointer(data, "no-leading-slash")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for pointer without leading /")
|
||||
}
|
||||
want := `json pointer must start with '/' or be empty, got "no-leading-slash"`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// ReadOpenClawConfig reads and parses an openclaw.json file at the given path.
|
||||
func ReadOpenClawConfig(path string) (*OpenClawRoot, error) {
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err // caller (bind.go) formats user-facing message with path context
|
||||
}
|
||||
|
||||
var root OpenClawRoot
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON in %s: %w", path, err)
|
||||
}
|
||||
|
||||
return &root, nil
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadOpenClawConfig_ValidSingleAccount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "openclaw.json")
|
||||
data := `{"channels":{"feishu":{"appId":"cli_abc","appSecret":"plain_secret","domain":"feishu"}}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
root, err := ReadOpenClawConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if root.Channels.Feishu == nil {
|
||||
t.Fatal("expected Channels.Feishu to be non-nil")
|
||||
}
|
||||
if got := root.Channels.Feishu.AppID; got != "cli_abc" {
|
||||
t.Errorf("AppID = %q, want %q", got, "cli_abc")
|
||||
}
|
||||
if got := root.Channels.Feishu.AppSecret.Plain; got != "plain_secret" {
|
||||
t.Errorf("AppSecret.Plain = %q, want %q", got, "plain_secret")
|
||||
}
|
||||
if root.Channels.Feishu.AppSecret.Ref != nil {
|
||||
t.Error("AppSecret.Ref should be nil for a plain string")
|
||||
}
|
||||
if got := root.Channels.Feishu.Brand; got != "feishu" {
|
||||
t.Errorf("Brand = %q, want %q", got, "feishu")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOpenClawConfig_ValidMultiAccount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "openclaw.json")
|
||||
data := `{
|
||||
"channels": {
|
||||
"feishu": {
|
||||
"domain": "feishu",
|
||||
"accounts": {
|
||||
"work": {"appId": "cli_work", "appSecret": "secret_work", "domain": "feishu"},
|
||||
"personal": {"appId": "cli_personal", "appSecret": "secret_personal", "domain": "lark"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
root, err := ReadOpenClawConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if root.Channels.Feishu == nil {
|
||||
t.Fatal("expected Channels.Feishu to be non-nil")
|
||||
}
|
||||
|
||||
apps := ListCandidateApps(root.Channels.Feishu)
|
||||
if len(apps) != 2 {
|
||||
t.Fatalf("ListCandidateApps returned %d apps, want 2", len(apps))
|
||||
}
|
||||
|
||||
byLabel := make(map[string]CandidateApp, len(apps))
|
||||
for _, a := range apps {
|
||||
byLabel[a.Label] = a
|
||||
}
|
||||
|
||||
work, ok := byLabel["work"]
|
||||
if !ok {
|
||||
t.Fatal("missing account label 'work'")
|
||||
}
|
||||
if work.AppID != "cli_work" {
|
||||
t.Errorf("work.AppID = %q, want %q", work.AppID, "cli_work")
|
||||
}
|
||||
|
||||
personal, ok := byLabel["personal"]
|
||||
if !ok {
|
||||
t.Fatal("missing account label 'personal'")
|
||||
}
|
||||
if personal.AppID != "cli_personal" {
|
||||
t.Errorf("personal.AppID = %q, want %q", personal.AppID, "cli_personal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOpenClawConfig_MissingFeishu(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "openclaw.json")
|
||||
data := `{"channels":{}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
root, err := ReadOpenClawConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if root.Channels.Feishu != nil {
|
||||
t.Error("expected Channels.Feishu to be nil when not present in JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOpenClawConfig_InvalidJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "openclaw.json")
|
||||
if err := os.WriteFile(p, []byte(`{not valid json`), 0o644); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
_, err := ReadOpenClawConfig(p)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOpenClawConfig_FileNotFound(t *testing.T) {
|
||||
_, err := ReadOpenClawConfig(filepath.Join(t.TempDir(), "nonexistent.json"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOpenClawConfig_EnvTemplate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "openclaw.json")
|
||||
data := `{"channels":{"feishu":{"appId":"cli_env","appSecret":"${FEISHU_APP_SECRET}","domain":"feishu"}}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
root, err := ReadOpenClawConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
secret := root.Channels.Feishu.AppSecret
|
||||
if secret.Plain != "${FEISHU_APP_SECRET}" {
|
||||
t.Errorf("SecretInput.Plain = %q, want %q", secret.Plain, "${FEISHU_APP_SECRET}")
|
||||
}
|
||||
if secret.Ref != nil {
|
||||
t.Error("SecretInput.Ref should be nil for env template string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOpenClawConfig_SecretRefObject(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "openclaw.json")
|
||||
data := `{"channels":{"feishu":{"appId":"cli_ref","appSecret":{"source":"file","provider":"fp","id":"/path"},"domain":"feishu"}}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
root, err := ReadOpenClawConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
secret := root.Channels.Feishu.AppSecret
|
||||
if secret.Plain != "" {
|
||||
t.Errorf("SecretInput.Plain = %q, want empty for object form", secret.Plain)
|
||||
}
|
||||
if secret.Ref == nil {
|
||||
t.Fatal("SecretInput.Ref should be non-nil for object form")
|
||||
}
|
||||
if secret.Ref.Source != "file" {
|
||||
t.Errorf("Ref.Source = %q, want %q", secret.Ref.Source, "file")
|
||||
}
|
||||
if secret.Ref.Provider != "fp" {
|
||||
t.Errorf("Ref.Provider = %q, want %q", secret.Ref.Provider, "fp")
|
||||
}
|
||||
if secret.Ref.ID != "/path" {
|
||||
t.Errorf("Ref.ID = %q, want %q", secret.Ref.ID, "/path")
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ResolveSecretInput resolves a SecretInput to a plain-text secret string.
|
||||
// This is the main dispatcher that handles all SecretInput forms:
|
||||
// - Plain string passthrough
|
||||
// - "${VAR_NAME}" env template expansion
|
||||
// - SecretRef object routing to env/file/exec sub-resolvers
|
||||
//
|
||||
// The getenv parameter allows injection for testing (typically os.Getenv).
|
||||
// This function is only called during config bind (cold path).
|
||||
func ResolveSecretInput(input SecretInput, cfg *SecretsConfig, getenv func(string) string) (string, error) {
|
||||
if getenv == nil {
|
||||
getenv = os.Getenv
|
||||
}
|
||||
|
||||
if input.IsZero() {
|
||||
return "", fmt.Errorf("appSecret is missing or empty")
|
||||
}
|
||||
|
||||
// Plain string form (includes env templates)
|
||||
if input.IsPlain() {
|
||||
return resolvePlainOrTemplate(input.Plain, getenv)
|
||||
}
|
||||
|
||||
// SecretRef object form
|
||||
return resolveSecretRef(input.Ref, cfg, getenv)
|
||||
}
|
||||
|
||||
// resolvePlainOrTemplate handles plain strings and "${VAR}" templates.
|
||||
func resolvePlainOrTemplate(value string, getenv func(string) string) (string, error) {
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("appSecret is empty string")
|
||||
}
|
||||
|
||||
// Check for env template pattern: "${VAR_NAME}"
|
||||
matches := EnvTemplateRe.FindStringSubmatch(value)
|
||||
if matches != nil {
|
||||
varName := matches[1]
|
||||
envValue := getenv(varName)
|
||||
if envValue == "" {
|
||||
return "", fmt.Errorf("env variable %q referenced in openclaw.json is not set or empty", varName)
|
||||
}
|
||||
return envValue, nil
|
||||
}
|
||||
|
||||
// Plain string: use as-is
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// resolveSecretRef dispatches a SecretRef to the appropriate sub-resolver.
|
||||
func resolveSecretRef(ref *SecretRef, cfg *SecretsConfig, getenv func(string) string) (string, error) {
|
||||
// Lookup provider configuration
|
||||
providerConfig, err := LookupProvider(ref, cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Resolve the effective provider name once so downstream resolvers
|
||||
// (notably the exec JSON payload) see the config-defaulted value instead
|
||||
// of the unset literal on ref.Provider.
|
||||
providerName := ResolveDefaultProvider(ref, cfg)
|
||||
|
||||
switch ref.Source {
|
||||
case "env":
|
||||
return resolveEnvRef(ref, providerConfig, getenv)
|
||||
case "file":
|
||||
return resolveFileRef(ref, providerConfig)
|
||||
case "exec":
|
||||
return resolveExecRef(ref, providerName, providerConfig, getenv)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported secret source %q", ref.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveEnvRef handles {source:"env"} SecretRef.
|
||||
func resolveEnvRef(ref *SecretRef, pc *ProviderConfig, getenv func(string) string) (string, error) {
|
||||
// Check allowlist if configured
|
||||
if len(pc.Allowlist) > 0 {
|
||||
allowed := false
|
||||
for _, name := range pc.Allowlist {
|
||||
if name == ref.ID {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return "", fmt.Errorf("environment variable %q is not allowlisted in provider", ref.ID)
|
||||
}
|
||||
}
|
||||
|
||||
value := getenv(ref.ID)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("environment variable %q is missing or empty", ref.ID)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// execRequest is the JSON payload sent to exec provider's stdin.
|
||||
type execRequest struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
Provider string `json:"provider"`
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
// execResponse is the JSON payload expected from exec provider's stdout.
|
||||
type execResponse struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
Values map[string]interface{} `json:"values"`
|
||||
Errors map[string]execRefError `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// execRefError is an optional per-id error in exec provider response.
|
||||
type execRefError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// execRun bundles everything runExecCommand needs to spawn the child process.
|
||||
// It is populated once by prepareExecRun and consumed exactly once by
|
||||
// runExecCommand; keeping the two stages pure data + pure side effect makes
|
||||
// each independently testable.
|
||||
type execRun struct {
|
||||
Path string // absolute, already-audited path to the command
|
||||
Args []string // command arguments (from pc.Args)
|
||||
Env []string // minimal child env (passEnv + explicit env only)
|
||||
Request []byte // JSON payload to feed on the child's stdin
|
||||
Timeout time.Duration // spawn deadline
|
||||
MaxOut int // hard cap on stdout size, enforced post-Run
|
||||
}
|
||||
|
||||
// resolveExecRef handles {source:"exec"} SecretRef resolution. It audits the
|
||||
// command path, runs the child under a timeout with a hard stdout cap, and
|
||||
// extracts the secret from the JSON response. providerName is the caller-
|
||||
// resolved effective alias (honours secrets.defaults.exec from openclaw.json).
|
||||
func resolveExecRef(ref *SecretRef, providerName string, pc *ProviderConfig, getenv func(string) string) (string, error) {
|
||||
prep, err := prepareExecRun(ref, providerName, pc, getenv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
stdout, err := runExecCommand(prep)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return extractExecSecret(stdout, ref.ID, effectiveJSONOnly(pc))
|
||||
}
|
||||
|
||||
// prepareExecRun audits the command path, marshals the JSON request,
|
||||
// assembles the minimal child env, and resolves timeout / output limits.
|
||||
// Never spawns a process — the returned execRun is pure data.
|
||||
func prepareExecRun(ref *SecretRef, providerName string, pc *ProviderConfig, getenv func(string) string) (*execRun, error) {
|
||||
if pc.Command == "" {
|
||||
return nil, fmt.Errorf("exec provider command is empty")
|
||||
}
|
||||
|
||||
securePath, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: pc.Command,
|
||||
Label: "exec provider command",
|
||||
TrustedDirs: pc.TrustedDirs,
|
||||
AllowInsecurePath: pc.AllowInsecurePath,
|
||||
AllowReadableByOthers: true, // exec commands are typically 755
|
||||
AllowSymlinkPath: pc.AllowSymlinkCommand,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exec provider security audit failed: %w", err)
|
||||
}
|
||||
|
||||
reqJSON, err := marshalExecRequest(ref, providerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeoutMs, maxOut := effectiveExecLimits(pc)
|
||||
return &execRun{
|
||||
Path: securePath,
|
||||
Args: pc.Args,
|
||||
Env: buildExecEnv(pc, getenv),
|
||||
Request: reqJSON,
|
||||
Timeout: time.Duration(timeoutMs) * time.Millisecond,
|
||||
MaxOut: maxOut,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// marshalExecRequest encodes the JSON protocol request sent to the child.
|
||||
// providerName is supplied by resolveSecretRef after consulting
|
||||
// secrets.defaults.exec; an empty value falls back to DefaultProviderAlias
|
||||
// so the function can still be reasoned about in isolation.
|
||||
func marshalExecRequest(ref *SecretRef, providerName string) ([]byte, error) {
|
||||
if providerName == "" {
|
||||
providerName = DefaultProviderAlias
|
||||
}
|
||||
data, err := json.Marshal(execRequest{
|
||||
ProtocolVersion: 1,
|
||||
Provider: providerName,
|
||||
IDs: []string{ref.ID},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exec provider: failed to marshal request: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// buildExecEnv assembles the child's environment: only variables listed in
|
||||
// pc.PassEnv (and non-empty in the parent) plus pc.Env entries. The child
|
||||
// never inherits the full parent env — always set cmd.Env explicitly.
|
||||
func buildExecEnv(pc *ProviderConfig, getenv func(string) string) []string {
|
||||
env := make([]string, 0, len(pc.PassEnv)+len(pc.Env))
|
||||
for _, key := range pc.PassEnv {
|
||||
if val := getenv(key); val != "" {
|
||||
env = append(env, key+"="+val)
|
||||
}
|
||||
}
|
||||
for key, val := range pc.Env {
|
||||
env = append(env, key+"="+val)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// effectiveExecLimits returns (timeoutMs, maxOutputBytes), falling back to
|
||||
// package defaults for any non-positive value. The exec provider uses its
|
||||
// own NoOutputTimeoutMs field (pc.TimeoutMs is the file-provider field and
|
||||
// should not be consulted here); the value is applied as the overall
|
||||
// deadline for the child process.
|
||||
func effectiveExecLimits(pc *ProviderConfig) (timeoutMs, maxOutputBytes int) {
|
||||
timeoutMs = pc.NoOutputTimeoutMs
|
||||
if timeoutMs <= 0 {
|
||||
timeoutMs = DefaultExecTimeoutMs
|
||||
}
|
||||
maxOutputBytes = pc.MaxOutputBytes
|
||||
if maxOutputBytes <= 0 {
|
||||
maxOutputBytes = DefaultExecMaxOutputBytes
|
||||
}
|
||||
return timeoutMs, maxOutputBytes
|
||||
}
|
||||
|
||||
// effectiveJSONOnly returns pc.JSONOnly or its documented default (true).
|
||||
func effectiveJSONOnly(pc *ProviderConfig) bool {
|
||||
if pc.JSONOnly != nil {
|
||||
return *pc.JSONOnly
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// runExecCommand spawns the child per prep, feeds prep.Request on stdin, and
|
||||
// returns trimmed stdout on success. Failure modes:
|
||||
// - timeout → typed error with the configured limit
|
||||
// - non-zero exit → wrapped *exec.ExitError
|
||||
// - stdout exceeds prep.MaxOut → typed error (size enforced post-Run)
|
||||
// - empty trimmed stdout → typed error
|
||||
func runExecCommand(prep *execRun) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), prep.Timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, prep.Path, prep.Args...)
|
||||
cmd.Dir = filepath.Dir(prep.Path)
|
||||
cmd.Env = prep.Env // always set — leaving nil would inherit the parent env
|
||||
cmd.Stdin = bytes.NewReader(prep.Request)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, fmt.Errorf("exec provider timed out after %dms", int(prep.Timeout/time.Millisecond))
|
||||
}
|
||||
return nil, fmt.Errorf("exec provider exited with error: %w", err)
|
||||
}
|
||||
|
||||
if stdout.Len() > prep.MaxOut {
|
||||
return nil, fmt.Errorf("exec provider output exceeded maxOutputBytes (%d)", prep.MaxOut)
|
||||
}
|
||||
|
||||
trimmed := bytes.TrimSpace(stdout.Bytes())
|
||||
if len(trimmed) == 0 {
|
||||
return nil, fmt.Errorf("exec provider returned empty stdout")
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
// extractExecSecret parses stdout as a JSON execResponse and returns the
|
||||
// string value at refID. When jsonOnly is false and the response is not valid
|
||||
// JSON (or the value is not a string), it falls back to the raw stdout or the
|
||||
// JSON encoding of the value respectively — mirroring OpenClaw's resolve.ts.
|
||||
func extractExecSecret(stdout []byte, refID string, jsonOnly bool) (string, error) {
|
||||
var resp execResponse
|
||||
if err := json.Unmarshal(stdout, &resp); err != nil {
|
||||
if !jsonOnly {
|
||||
return string(stdout), nil
|
||||
}
|
||||
return "", fmt.Errorf("exec provider returned invalid JSON: %w", err)
|
||||
}
|
||||
|
||||
if resp.ProtocolVersion != 1 {
|
||||
return "", fmt.Errorf("exec provider protocolVersion must be 1, got %d", resp.ProtocolVersion)
|
||||
}
|
||||
|
||||
if refErr, ok := resp.Errors[refID]; ok {
|
||||
msg := refErr.Message
|
||||
if msg == "" {
|
||||
msg = "unknown error"
|
||||
}
|
||||
return "", fmt.Errorf("exec provider failed for id %q: %s", refID, msg)
|
||||
}
|
||||
|
||||
if resp.Values == nil {
|
||||
return "", fmt.Errorf("exec provider response missing 'values'")
|
||||
}
|
||||
value, ok := resp.Values[refID]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("exec provider response missing id %q", refID)
|
||||
}
|
||||
|
||||
if str, ok := value.(string); ok {
|
||||
return str, nil
|
||||
}
|
||||
if !jsonOnly {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("exec provider value for id %q is not JSON-serializable: %w", refID, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
return "", fmt.Errorf("exec provider value for id %q is not a string", refID)
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeExecHelper writes a small shell script that mimics an exec provider.
|
||||
// The script reads stdin (the JSON request) and writes a JSON response to stdout.
|
||||
func writeExecHelper(t *testing.T, dir, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, "helper.sh")
|
||||
script := "#!/bin/sh\n" + body
|
||||
if err := os.WriteFile(p, []byte(script), 0o700); err != nil {
|
||||
t.Fatalf("write helper script: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestResolveExecRef_EmptyCommand(t *testing.T) {
|
||||
ref := &SecretRef{Source: "exec", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{Source: "exec", Command: ""}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty command, got nil")
|
||||
}
|
||||
want := "exec provider command is empty"
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_CommandNotFound(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("path audit not applicable on Windows")
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "exec", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: "/nonexistent/command",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent command, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_JSONResponse(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
// Script reads stdin (ignores), writes valid JSON response
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1,"values":{"MY_KEY":"exec_secret_123"}}'
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
got, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "exec_secret_123" {
|
||||
t.Errorf("got %q, want %q", got, "exec_secret_123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_PerRefError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1,"values":{},"errors":{"MY_KEY":{"message":"secret not found"}}}'
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for per-ref error, got nil")
|
||||
}
|
||||
want := `exec provider failed for id "MY_KEY": secret not found`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_WrongProtocolVersion(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":99,"values":{"MY_KEY":"v"}}'
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong protocol version, got nil")
|
||||
}
|
||||
want := "exec provider protocolVersion must be 1, got 99"
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_MissingValues(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1}'
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing values, got nil")
|
||||
}
|
||||
want := "exec provider response missing 'values'"
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_MissingID(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1,"values":{"OTHER":"val"}}'
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing ID, got nil")
|
||||
}
|
||||
want := `exec provider response missing id "MY_KEY"`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_EmptyStdout(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty stdout, got nil")
|
||||
}
|
||||
want := "exec provider returned empty stdout"
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_InvalidJSON_JSONOnly(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
echo "not json"
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
// JSONOnly defaults to true (nil)
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_NonJSON_RawString(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
echo "raw_secret_value"
|
||||
`)
|
||||
|
||||
jsonOnly := false
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
JSONOnly: &jsonOnly,
|
||||
}
|
||||
|
||||
got, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "raw_secret_value" {
|
||||
t.Errorf("got %q, want %q", got, "raw_secret_value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_NonStringValue_JSONOnly(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1,"values":{"MY_KEY":42}}'
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-string value with jsonOnly=true, got nil")
|
||||
}
|
||||
want := `exec provider value for id "MY_KEY" is not a string`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_NonStringValue_NoJSONOnly(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1,"values":{"MY_KEY":42}}'
|
||||
`)
|
||||
|
||||
jsonOnly := false
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
JSONOnly: &jsonOnly,
|
||||
}
|
||||
|
||||
got, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "42" {
|
||||
t.Errorf("got %q, want %q", got, "42")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_CommandExitError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `exit 1
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for command exit error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_PassEnv(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
// Script uses TEST_SECRET env to produce value
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1,"values":{"MY_KEY":"%s"}}' "$TEST_SECRET"
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
PassEnv: []string{"TEST_SECRET"},
|
||||
}
|
||||
|
||||
getenv := func(key string) string {
|
||||
if key == "TEST_SECRET" {
|
||||
return "passed_env_value"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
got, err := resolveExecRef(ref, "", pc, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "passed_env_value" {
|
||||
t.Errorf("got %q, want %q", got, "passed_env_value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_ExplicitEnv(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
printf '{"protocolVersion":1,"values":{"MY_KEY":"%s"}}' "$CUSTOM_VAR"
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
Env: map[string]string{"CUSTOM_VAR": "explicit_value"},
|
||||
}
|
||||
|
||||
got, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "explicit_value" {
|
||||
t.Errorf("got %q, want %q", got, "explicit_value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecRef_OutputExceedsMax(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell scripts not applicable on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
// Script outputs more than maxOutputBytes
|
||||
helper := writeExecHelper(t, dir, `cat > /dev/null
|
||||
python3 -c "print('x' * 200)"
|
||||
`)
|
||||
|
||||
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "exec",
|
||||
Command: helper,
|
||||
AllowInsecurePath: true,
|
||||
MaxOutputBytes: 10,
|
||||
}
|
||||
|
||||
_, err := resolveExecRef(ref, "", pc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for output exceeding maxOutputBytes, got nil")
|
||||
}
|
||||
want := fmt.Sprintf("exec provider output exceeded maxOutputBytes (%d)", 10)
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// SingleValueFileRefID is the required ref.ID for singleValue file mode
|
||||
// (aligned with OpenClaw ref-contract.ts SINGLE_VALUE_FILE_REF_ID).
|
||||
const SingleValueFileRefID = "$SINGLE_VALUE"
|
||||
|
||||
// resolveFileRef handles {source:"file"} SecretRef resolution.
|
||||
// Reads the file via assertSecurePath audit, then extracts the secret value
|
||||
// based on the provider's mode (singleValue or json with JSON Pointer).
|
||||
func resolveFileRef(ref *SecretRef, pc *ProviderConfig) (string, error) {
|
||||
if pc.Path == "" {
|
||||
return "", fmt.Errorf("file provider path is empty")
|
||||
}
|
||||
|
||||
// Security audit on file path
|
||||
securePath, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: pc.Path,
|
||||
Label: "secrets.providers file path",
|
||||
TrustedDirs: pc.TrustedDirs,
|
||||
AllowInsecurePath: pc.AllowInsecurePath,
|
||||
AllowReadableByOthers: false, // file provider: strict by default
|
||||
AllowSymlinkPath: false,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file provider security audit failed: %w", err)
|
||||
}
|
||||
|
||||
// Read file content
|
||||
maxBytes := pc.MaxBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = DefaultFileMaxBytes
|
||||
}
|
||||
|
||||
// Note: vfs.ReadFile loads the entire file. maxBytes is enforced post-read
|
||||
// because vfs does not expose a size-limited reader. For secret files this
|
||||
// is acceptable (default limit 1 MiB; secrets are typically < 1 KB).
|
||||
data, err := vfs.ReadFile(securePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read secret file %s: %w", securePath, err)
|
||||
}
|
||||
|
||||
if len(data) > maxBytes {
|
||||
return "", fmt.Errorf("file provider exceeded maxBytes (%d)", maxBytes)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
mode := pc.Mode
|
||||
if mode == "" {
|
||||
mode = "json" // default mode per OpenClaw
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "singleValue":
|
||||
// OpenClaw requires ref.id == SINGLE_VALUE_FILE_REF_ID for singleValue mode
|
||||
if ref.ID != SingleValueFileRefID {
|
||||
return "", fmt.Errorf("singleValue file provider expects ref id %q, got %q",
|
||||
SingleValueFileRefID, ref.ID)
|
||||
}
|
||||
// Entire file content is the secret; trim trailing newline
|
||||
return strings.TrimRight(content, "\r\n"), nil
|
||||
|
||||
case "json":
|
||||
// Parse as JSON, then navigate via JSON Pointer (ref.ID)
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
return "", fmt.Errorf("file provider JSON parse error: %w", err)
|
||||
}
|
||||
|
||||
value, err := ReadJSONPointer(parsed, ref.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file provider JSON Pointer %q: %w", ref.ID, err)
|
||||
}
|
||||
|
||||
// Value must be a string
|
||||
strValue, ok := value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("file provider JSON Pointer %q resolved to non-string value", ref.ID)
|
||||
}
|
||||
return strValue, nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported file provider mode %q", mode)
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveFileRef_SingleValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secret.txt")
|
||||
if err := os.WriteFile(p, []byte("my_secret\n"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: p,
|
||||
Mode: "singleValue",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
got, err := resolveFileRef(ref, pc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "my_secret" {
|
||||
t.Errorf("got %q, want %q", got, "my_secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_SingleValue_WrongRefID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secret.txt")
|
||||
if err := os.WriteFile(p, []byte("my_secret\n"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "file", ID: "WRONG_ID"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: p,
|
||||
Mode: "singleValue",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong ref ID, got nil")
|
||||
}
|
||||
want := `singleValue file provider expects ref id "$SINGLE_VALUE", got "WRONG_ID"`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_JSONMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secrets.json")
|
||||
content := `{"providers":{"feishu":{"key":"secret123"}}}`
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "file", ID: "/providers/feishu/key"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: p,
|
||||
Mode: "json",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
got, err := resolveFileRef(ref, pc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "secret123" {
|
||||
t.Errorf("got %q, want %q", got, "secret123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_JSONMode_MissingPointer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secrets.json")
|
||||
content := `{"providers":{"feishu":{"key":"secret123"}}}`
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "file", ID: "/providers/nonexistent/key"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: p,
|
||||
Mode: "json",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing JSON pointer, got nil")
|
||||
}
|
||||
want := `file provider JSON Pointer "/providers/nonexistent/key": json pointer "/providers/nonexistent/key": key "nonexistent" not found`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_FileNotFound(t *testing.T) {
|
||||
nonexistent := filepath.Join(t.TempDir(), "no_such_file.txt")
|
||||
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: nonexistent,
|
||||
Mode: "singleValue",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_EmptyProviderPath(t *testing.T) {
|
||||
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
|
||||
pc := &ProviderConfig{Source: "file", Path: "", Mode: "singleValue", AllowInsecurePath: true}
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty provider path, got nil")
|
||||
}
|
||||
want := "file provider path is empty"
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_JSONMode_NonStringValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secrets.json")
|
||||
if err := os.WriteFile(p, []byte(`{"count":42}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
ref := &SecretRef{Source: "file", ID: "/count"}
|
||||
pc := &ProviderConfig{Source: "file", Path: p, Mode: "json", AllowInsecurePath: true}
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-string JSON value, got nil")
|
||||
}
|
||||
want := `file provider JSON Pointer "/count" resolved to non-string value`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_UnsupportedMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secret.txt")
|
||||
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
|
||||
pc := &ProviderConfig{Source: "file", Path: p, Mode: "yaml", AllowInsecurePath: true}
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported mode, got nil")
|
||||
}
|
||||
want := `unsupported file provider mode "yaml"`
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_DefaultMode_IsJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secrets.json")
|
||||
if err := os.WriteFile(p, []byte(`{"key":"value123"}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
ref := &SecretRef{Source: "file", ID: "/key"}
|
||||
pc := &ProviderConfig{Source: "file", Path: p, Mode: "", AllowInsecurePath: true}
|
||||
got, err := resolveFileRef(ref, pc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "value123" {
|
||||
t.Errorf("got %q, want %q", got, "value123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_JSONMode_InvalidJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "bad.json")
|
||||
if err := os.WriteFile(p, []byte("not json"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
ref := &SecretRef{Source: "file", ID: "/key"}
|
||||
pc := &ProviderConfig{Source: "file", Path: p, Mode: "json", AllowInsecurePath: true}
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFileRef_ExceedsMaxBytes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "big.txt")
|
||||
if err := os.WriteFile(p, []byte("this content is longer than 5 bytes"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: p,
|
||||
Mode: "singleValue",
|
||||
MaxBytes: 5,
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for file exceeding maxBytes, got nil")
|
||||
}
|
||||
want := "file provider exceeded maxBytes (5)"
|
||||
if err.Error() != want {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user