mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
4 Commits
feat/sidec
...
feat/sec_p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
054ff9339b | ||
|
|
bdb0cd14d1 | ||
|
|
6c41d12792 | ||
|
|
2286937366 |
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -9,7 +9,7 @@
|
|||||||
## Test Plan
|
## Test Plan
|
||||||
<!-- Describe how this change was verified. -->
|
<!-- Describe how this change was verified. -->
|
||||||
- [ ] Unit tests pass
|
- [ ] Unit tests pass
|
||||||
- [ ] Manual local verification confirms the `lark-cli <domain> <command>` flow works as expected
|
- [ ] Manual local verification confirms the `lark xxx` command works as expected
|
||||||
|
|
||||||
## Related Issues
|
## Related Issues
|
||||||
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->
|
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->
|
||||||
|
|||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -82,8 +82,6 @@ jobs:
|
|||||||
run: python3 scripts/fetch_meta.py
|
run: python3 scripts/fetch_meta.py
|
||||||
- name: Run golangci-lint
|
- name: Run golangci-lint
|
||||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||||
- name: Run errs/ lint guards (lintcheck)
|
|
||||||
run: go run -C lint . ..
|
|
||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
needs: fast-gate
|
needs: fast-gate
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
# Build output
|
# Build output
|
||||||
/lark-cli*
|
/lark-cli
|
||||||
.cache/
|
.cache/
|
||||||
dist/
|
dist/
|
||||||
bin/
|
bin/
|
||||||
@@ -42,5 +42,3 @@ app.log
|
|||||||
/server-demo
|
/server-demo
|
||||||
.tmp/
|
.tmp/
|
||||||
cover*.out
|
cover*.out
|
||||||
|
|
||||||
lark-env.sh
|
|
||||||
|
|||||||
@@ -49,26 +49,18 @@ linters:
|
|||||||
- gocritic
|
- gocritic
|
||||||
- depguard
|
- depguard
|
||||||
- forbidigo
|
- forbidigo
|
||||||
# Paths that run forbidigo. Add an entry when a path joins one of
|
- path-except: (shortcuts/|internal/)
|
||||||
# the rules below.
|
|
||||||
- path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/)
|
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
- path: internal/vfs/
|
- path: internal/vfs/
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
|
# The shortcuts-no-raw-http forbidigo rule below is shortcuts-only;
|
||||||
# for the client / credential layer.
|
# internal/ legitimately wraps raw HTTP for the client / credential layer.
|
||||||
- path-except: shortcuts/
|
- path-except: shortcuts/
|
||||||
text: shortcuts-no-raw-http
|
text: shortcuts-no-raw-http
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
|
||||||
# Add a path when its migration is complete.
|
|
||||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
|
|
||||||
text: errs-typed-only
|
|
||||||
linters:
|
|
||||||
- forbidigo
|
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
depguard:
|
depguard:
|
||||||
@@ -87,13 +79,6 @@ linters:
|
|||||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||||
forbidigo:
|
forbidigo:
|
||||||
forbid:
|
forbid:
|
||||||
# ── legacy output.Err* helpers banned on migrated paths ──
|
|
||||||
# output.ErrBare is intentionally not listed — it is the predicate-
|
|
||||||
# command silent-exit signal, outside the typed envelope contract.
|
|
||||||
- pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b
|
|
||||||
msg: >-
|
|
||||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
|
||||||
(see errs/types.go).
|
|
||||||
# ── http: shortcuts must not construct raw HTTP requests ──
|
# ── http: shortcuts must not construct raw HTTP requests ──
|
||||||
# Bans request / client construction; constants (http.MethodPost,
|
# Bans request / client construction; constants (http.MethodPost,
|
||||||
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
||||||
|
|||||||
201
CHANGELOG.md
201
CHANGELOG.md
@@ -2,196 +2,6 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [v1.0.45] - 2026-06-01
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **errors**: Add typed envelope contract for auth-domain errors (#1135)
|
|
||||||
- **platform**: Support multiple policy rules per plugin (#1182)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **vc**: Add domain boundaries and enrich `+notes` (#1172)
|
|
||||||
- **whiteboard**: Fix whiteboard skill (#1180)
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
|
|
||||||
- **auth**: Update login hint and split-flow docs (#1201)
|
|
||||||
|
|
||||||
## [v1.0.44] - 2026-05-29
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **base**: Add dashboard block data shortcut and workflow docs (#1067)
|
|
||||||
- **im**: Support `--types` flag for listing p2p single chats in `chat-list` (#1077)
|
|
||||||
- **agent**: Add agent header support (#1158)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **im**: Correct 64-bit MP4 box size handling to prevent panic on crafted media (#1165)
|
|
||||||
- **install**: Detect curl version before using `--ssl-revoke-best-effort` (#1124)
|
|
||||||
- **vc**: Correct `--minute-token` to `--minute-tokens` in recording reference (#1170)
|
|
||||||
- **whiteboard**: Fix whiteboard skill (#1166)
|
|
||||||
|
|
||||||
## [v1.0.43] - 2026-05-28
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **event**: Support `note` generated event (#1159)
|
|
||||||
- **config**: Decouple `--lang` preference from TUI display language (#1132)
|
|
||||||
- **mail**: Add HTML lint library with Larksuite-native autofix for `lark-mail` (#1019)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **config**: Propagate `Lang` across credential boundary; respect `CurrentApp` in priorLang (#1157)
|
|
||||||
- **config**: Allow lark-channel bind source override (#1154)
|
|
||||||
- **im**: Clarify `messages-send` dry-run chat membership (#1150)
|
|
||||||
- **base**: Include `log_id` in attachment media errors (#1133)
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
- **im**: Parallelize reactions, thread_replies, and merge_forward fetches (#1146)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **im**: Update IM skill urgent APIs (#1153)
|
|
||||||
|
|
||||||
## [v1.0.42] - 2026-05-27
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **mail**: Add `+draft-send` shortcut for batch draft sending (#1017)
|
|
||||||
- **im**: Enrich messages with reactions and output `update_time` (#1095)
|
|
||||||
- **schema**: Output JSON spec envelope for all API commands (#1048)
|
|
||||||
- **event**: Support `vc` / `note` / `minute` events (#1113)
|
|
||||||
- **drive**: Add secure label shortcuts (#985)
|
|
||||||
- **affordance**: Use description and command in affordance example schema (#1126)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **docs**: Remove unsupported `fetch` text format (#1109)
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
|
|
||||||
- **auth**: Drop duplicate top-level user fields in `status` (#1128)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **doc**: Document block anchor URLs in `lark-doc` skill (#1120)
|
|
||||||
- **whiteboard**: Improve SVG/Mermaid instructions (#1097)
|
|
||||||
|
|
||||||
## [v1.0.41] - 2026-05-26
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **minutes**: Add minutes edit shortcuts (#1036)
|
|
||||||
- **minutes**: Get minutes keywords (#1079)
|
|
||||||
- **slides**: Support importing pptx as slides (#1068)
|
|
||||||
- **config**: Add `keychain-downgrade` subcommand (macOS) (#1085)
|
|
||||||
- **errors**: Add structured CLI error contract (#984)
|
|
||||||
- **apps**: Replace `+html-publish` cwd hard-reject with credential-file scan (#1072)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **drive**: Support doubao drive inspect URL variants (#1106)
|
|
||||||
- **skills**: Sync skills incrementally during update (#1042)
|
|
||||||
- **apps**: Read app object from `data.app` for `+create` and `+update` (#1087)
|
|
||||||
- **common**: Escape special chars in multipart form filenames (#1037)
|
|
||||||
- **auth**: Remove fenced code block guidance from auth URL output hints (#1088)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **skills**: Fix agent routing for doubao.com URLs (#1082)
|
|
||||||
- **task**: Require `--complete=false` for pending standup summaries (#1101)
|
|
||||||
- **base**: Document UI-only field settings (#1078)
|
|
||||||
- **contributing**: Clarify contributor guidance (#1096)
|
|
||||||
|
|
||||||
## [v1.0.40] - 2026-05-25
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **wiki**: Add exponential backoff retry for `+node-create` lock contention (#1012)
|
|
||||||
- **auth**: Add `auth qrcode` subcommand and update auth docs/hints (#968)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **wiki**: Rename `+node-get --token` to `--node-token`, keep alias (#1074)
|
|
||||||
- **output**: Classify wiki lock-contention error (131009) with retry hint (#1014)
|
|
||||||
- **contact**: Add actionable hint when fanout search all-fail with no API code (#1054)
|
|
||||||
- **permission**: Annotate auto-grant permission failures with `required_scope` and `console_url` (#1045)
|
|
||||||
- **validation**: Use `ErrValidation` instead of `fmt.Errorf` in `Validate` paths (#1001)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **skills**: Add 云盘/云存储 alias alongside 云空间 for agent clarity (#1073)
|
|
||||||
- **task**: Refresh `lark-task` shortcut docs (#1057)
|
|
||||||
|
|
||||||
## [v1.0.39] - 2026-05-22
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **slides**: Add `+export` shortcut to export slides (#988)
|
|
||||||
- **sidecar**: Support multi-client identity isolation in `server-demo` via per-client HMAC keys, preventing UAT cross-contamination when multiple CLI sandboxes share one sidecar (#934)
|
|
||||||
- **im**: Support Markdown image rendering in post content (#893)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **scope**: Add 22 new scope entries to scope priorities (#1050)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **base**: Update location `full_address` guidance (#754)
|
|
||||||
- **apps**: Refine `lark-apps` skill description and surface, document `index.html` / `--path` hard constraints (#1040)
|
|
||||||
|
|
||||||
## [v1.0.38] - 2026-05-22
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **apps**: Gate the Miaoda apps domain off on the Lark brand — the `apps` shortcut subtree returns a structured brand-restriction error, `auth login --domain apps` is rejected, `--domain all` skips it, and `spark:*` scopes are no longer requested (#1025)
|
|
||||||
|
|
||||||
## [v1.0.37] - 2026-05-21
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **apps**: Add miaoda apps domain with 6 shortcuts covering `+create` / `+update` / `+list` / `+access-scope-get` / `+access-scope-set` / `+html-publish` (#1002)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **permission**: Surface auto-grant skipped/failed cases via stderr warnings and a `hint` field in the `permission_grant` JSON output (#1015)
|
|
||||||
- **sheets**: Use `FileIO` for `+write-image` input so stdin / `-` works consistently (#996)
|
|
||||||
|
|
||||||
## [v1.0.36] - 2026-05-21
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **drive/markdown**: Return real tenant URLs for `drive +upload` and `markdown +create` (#992)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- **auth**: Return validation error when `--scope` is empty in `auth check` (#999)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **lark-drive**: Improve search evidence guidance (#864)
|
|
||||||
|
|
||||||
## [v1.0.35] - 2026-05-20
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **markdown**: Support wiki node target in `+create` (#883)
|
|
||||||
- **markdown**: Add `+diff` shortcut (#876)
|
|
||||||
- **base**: Add form `+detail` / `+submit` shortcuts (#759)
|
|
||||||
- **skills**: Add incremental skills sync (#965)
|
|
||||||
- **doc**: Warn before overwrite when document contains whiteboard or file blocks (#825)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **im**: Clarify media key formats for message media flags (#991)
|
|
||||||
- **im**: Add media-preview reference (#990)
|
|
||||||
- **drive**: Migrate `docs +search` to `drive +search` and fix `creator_ids` owner semantic (#951)
|
|
||||||
- **drive**: Prefer local comments for drive reviews (#981)
|
|
||||||
- **wiki**: Add wiki base fast path (#982)
|
|
||||||
|
|
||||||
## [v1.0.34] - 2026-05-19
|
## [v1.0.34] - 2026-05-19
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -964,17 +774,6 @@ Bundled AI agent skills for intelligent assistance:
|
|||||||
- Bilingual documentation (English & Chinese).
|
- Bilingual documentation (English & Chinese).
|
||||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||||
|
|
||||||
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
|
|
||||||
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
|
|
||||||
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
|
|
||||||
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
|
|
||||||
[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41
|
|
||||||
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
|
|
||||||
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
|
|
||||||
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38
|
|
||||||
[v1.0.37]: https://github.com/larksuite/cli/releases/tag/v1.0.37
|
|
||||||
[v1.0.36]: https://github.com/larksuite/cli/releases/tag/v1.0.36
|
|
||||||
[v1.0.35]: https://github.com/larksuite/cli/releases/tag/v1.0.35
|
|
||||||
[v1.0.34]: https://github.com/larksuite/cli/releases/tag/v1.0.34
|
[v1.0.34]: https://github.com/larksuite/cli/releases/tag/v1.0.34
|
||||||
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33
|
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33
|
||||||
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
|
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
|
|
||||||
[中文版](./README.zh.md) | [English](./README.md)
|
[中文版](./README.zh.md) | [English](./README.md)
|
||||||
|
|
||||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 26 AI Agent [Skills](./skills/).
|
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
|
||||||
|
|
||||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
[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?
|
## Why lark-cli?
|
||||||
|
|
||||||
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||||
- **Wide Coverage** — 18 business domains, 200+ curated commands, 26 AI Agent [Skills](./skills/)
|
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/)
|
||||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
- **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`
|
- **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
|
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||||
@@ -41,7 +41,6 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
|||||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
| ✍️ 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. |
|
| 🎯 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) |
|
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
|
||||||
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
|
|
||||||
|
|
||||||
## Installation & Quick Start
|
## Installation & Quick Start
|
||||||
|
|
||||||
@@ -279,8 +278,6 @@ Community contributions are welcome! If you find a bug or have feature suggestio
|
|||||||
|
|
||||||
For major changes, we recommend discussing with us first via an Issue.
|
For major changes, we recommend discussing with us first via an Issue.
|
||||||
|
|
||||||
Before opening a PR, see [AGENTS.md](./AGENTS.md) for the local build, test, and PR checklist used by contributors and AI agents.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the **MIT License**.
|
This project is licensed under the **MIT License**.
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
|
|
||||||
[中文版](./README.zh.md) | [English](./README.md)
|
[中文版](./README.zh.md) | [English](./README.md)
|
||||||
|
|
||||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 26 个 AI Agent [Skills](./skills/)。
|
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
|
||||||
|
|
||||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||||
|
|
||||||
## 为什么选 lark-cli?
|
## 为什么选 lark-cli?
|
||||||
|
|
||||||
- **为 Agent 原生设计** — 26 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||||
- **覆盖面广** — 18 大业务域、200+ 精选命令、26 个 AI Agent [Skills](./skills/)
|
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
|
||||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||||
@@ -41,7 +41,6 @@
|
|||||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
|
||||||
|
|
||||||
## 安装与快速开始
|
## 安装与快速开始
|
||||||
|
|
||||||
@@ -280,8 +279,6 @@ lark-cli schema im.messages.delete
|
|||||||
|
|
||||||
对于较大的改动,建议先通过 Issue 与我们讨论。
|
对于较大的改动,建议先通过 Issue 与我们讨论。
|
||||||
|
|
||||||
提交 PR 前,请先阅读 [AGENTS.md](./AGENTS.md),其中列出了贡献者和 AI Agent 使用的本地构建、测试和 PR 检查清单。
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
本项目基于 **MIT 许可证** 开源。
|
本项目基于 **MIT 许可证** 开源。
|
||||||
|
|||||||
@@ -238,11 +238,7 @@ func apiRun(opts *APIOptions) error {
|
|||||||
|
|
||||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// MarkRaw tells the dispatcher to skip the legacy enrichPermissionError
|
return output.MarkRaw(client.WrapDoAPIError(err))
|
||||||
// pass on *output.ExitError values. Typed *errs.* errors that flow
|
|
||||||
// through here keep their canonical message / hint from BuildAPIError;
|
|
||||||
// MarkRaw is a no-op on those (it only flips a flag on *ExitError).
|
|
||||||
return output.MarkRaw(err)
|
|
||||||
}
|
}
|
||||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||||
OutputPath: opts.Output,
|
OutputPath: opts.Output,
|
||||||
@@ -252,15 +248,9 @@ func apiRun(opts *APIOptions) error {
|
|||||||
ErrOut: f.IOStreams.ErrOut,
|
ErrOut: f.IOStreams.ErrOut,
|
||||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||||
CommandPath: opts.Cmd.CommandPath(),
|
CommandPath: opts.Cmd.CommandPath(),
|
||||||
Identity: opts.As,
|
|
||||||
// CheckResponse routes through errclass.BuildAPIError for known Lark
|
|
||||||
// codes (typed PermissionError / AuthenticationError / ...). For
|
|
||||||
// unknown codes it falls back to *errs.APIError. The Brand+AppID on
|
|
||||||
// the client populate identity-aware fields (ConsoleURL etc.).
|
|
||||||
CheckError: ac.CheckResponse,
|
|
||||||
})
|
})
|
||||||
// MarkRaw: see comment above on the DoAPI path. Skips legacy
|
// MarkRaw tells root error handler to skip enrichPermissionError,
|
||||||
// *ExitError enrichment; typed errors flow through unchanged.
|
// preserving the original API error detail (log_id, troubleshooter, etc.).
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.MarkRaw(err)
|
return output.MarkRaw(err)
|
||||||
}
|
}
|
||||||
@@ -272,12 +262,9 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
||||||
if pagOpts.Identity == "" {
|
|
||||||
pagOpts.Identity = request.As
|
|
||||||
}
|
|
||||||
// When jq is set, always aggregate all pages then filter.
|
// When jq is set, always aggregate all pages then filter.
|
||||||
if jqExpr != "" {
|
if jqExpr != "" {
|
||||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
|
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil {
|
||||||
return output.MarkRaw(err)
|
return output.MarkRaw(err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -290,9 +277,9 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
|||||||
pf.FormatPage(items)
|
pf.FormatPage(items)
|
||||||
}, pagOpts)
|
}, pagOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.MarkRaw(err)
|
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
|
||||||
}
|
}
|
||||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||||
output.FormatValue(out, result, output.FormatJSON)
|
output.FormatValue(out, result, output.FormatJSON)
|
||||||
return output.MarkRaw(apiErr)
|
return output.MarkRaw(apiErr)
|
||||||
}
|
}
|
||||||
@@ -304,9 +291,9 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
|||||||
default:
|
default:
|
||||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.MarkRaw(err)
|
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
|
||||||
}
|
}
|
||||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||||
output.FormatValue(out, result, output.FormatJSON)
|
output.FormatValue(out, result, output.FormatJSON)
|
||||||
return output.MarkRaw(apiErr)
|
return output.MarkRaw(apiErr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -399,6 +399,154 @@ func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_APIError_IsRaw(t *testing.T) {
|
||||||
|
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return a permission error from the API
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/test/perm",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 99991672,
|
||||||
|
"msg": "scope not enabled for this app",
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"permission_violations": []interface{}{
|
||||||
|
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewCmdApi(f, nil)
|
||||||
|
cmd.SetArgs([]string{"GET", "/open-apis/test/perm", "--as", "bot"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for permission denied API response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error should be marked Raw
|
||||||
|
var exitErr *output.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||||
|
}
|
||||||
|
if !exitErr.Raw {
|
||||||
|
t.Error("expected API error from api command to be marked Raw")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: stderr envelope output is tested at the root level (TestHandleRootError_*)
|
||||||
|
// since WriteErrorEnvelope is called by handleRootError, not by cobra's Execute.
|
||||||
|
_ = stderr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/test/origmsg",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 99991672,
|
||||||
|
"msg": "scope not enabled for this app",
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"permission_violations": []interface{}{
|
||||||
|
map[string]interface{}{"subject": "im:message:readonly"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewCmdApi(f, nil)
|
||||||
|
cmd.SetArgs([]string{"GET", "/open-apis/test/origmsg", "--as", "bot"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitErr *output.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||||
|
}
|
||||||
|
// The message should NOT have been enriched (no "App scope not enabled" replacement)
|
||||||
|
if strings.Contains(exitErr.Error(), "App scope not enabled") {
|
||||||
|
t.Error("expected original message, not enriched message")
|
||||||
|
}
|
||||||
|
// Detail should still contain the raw API error detail
|
||||||
|
if exitErr.Detail == nil {
|
||||||
|
t.Fatal("expected non-nil Detail")
|
||||||
|
}
|
||||||
|
if exitErr.Detail.Detail == nil {
|
||||||
|
t.Error("expected raw Detail.Detail to be preserved (not cleared by enrichment)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/test/invalidjson",
|
||||||
|
RawBody: []byte{},
|
||||||
|
ContentType: "application/json",
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewCmdApi(f, nil)
|
||||||
|
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitErr *output.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||||
|
}
|
||||||
|
if exitErr.Code != output.ExitAPI {
|
||||||
|
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||||
|
}
|
||||||
|
if exitErr.Detail == nil {
|
||||||
|
t.Fatal("expected detail on exit error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
|
||||||
|
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
|
||||||
|
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(exitErr.Detail.Hint, "--output") {
|
||||||
|
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/test/rawpage",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 99991672,
|
||||||
|
"msg": "scope not enabled",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewCmdApi(f, nil)
|
||||||
|
cmd.SetArgs([]string{"GET", "/open-apis/test/rawpage", "--as", "bot", "--page-all"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitErr *output.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||||
|
}
|
||||||
|
if !exitErr.Raw {
|
||||||
|
t.Error("expected paginated API error to be marked Raw")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
@@ -672,49 +820,3 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
|||||||
t.Errorf("expected dry-run header, got: %s", out)
|
t.Errorf("expected dry-run header, got: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestApiCmd_PermissionError_DerivesFirstClassFields pins that when a Lark
|
|
||||||
// API returns a missing-scope failure, the typed *errs.PermissionError
|
|
||||||
// surfaced by `lark-cli api` lifts the diagnostic signals BuildAPIError
|
|
||||||
// consumed during classification into first-class wire fields
|
|
||||||
// (MissingScopes, LogID, ConsoleURL). The wire shape is the typed envelope
|
|
||||||
// — there is no raw-payload passthrough; new Lark diagnostic fields require
|
|
||||||
// a CLI release.
|
|
||||||
func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
|
||||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
AppID: "cli_test_perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
|
|
||||||
reg.Register(&httpmock.Stub{
|
|
||||||
URL: "/open-apis/docx/v1/documents/test",
|
|
||||||
Body: map[string]interface{}{
|
|
||||||
"code": 99991679,
|
|
||||||
"msg": "scope missing",
|
|
||||||
"log_id": "20260527-test-log",
|
|
||||||
"error": map[string]interface{}{
|
|
||||||
"permission_violations": []interface{}{
|
|
||||||
map[string]interface{}{"subject": "docx:document"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
|
||||||
err := cmd.Execute()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for non-zero code")
|
|
||||||
}
|
|
||||||
|
|
||||||
var pe *errs.PermissionError
|
|
||||||
if !errors.As(err, &pe) {
|
|
||||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "docx:document" {
|
|
||||||
t.Errorf("MissingScopes = %v, want [docx:document]", pe.MissingScopes)
|
|
||||||
}
|
|
||||||
if pe.LogID != "20260527-test-log" {
|
|
||||||
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/errclass"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewCmdAuth creates the auth command with subcommands.
|
// NewCmdAuth creates the auth command with subcommands.
|
||||||
@@ -44,7 +43,6 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
|||||||
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
||||||
cmd.AddCommand(NewCmdAuthList(f, nil))
|
cmd.AddCommand(NewCmdAuthList(f, nil))
|
||||||
cmd.AddCommand(NewCmdAuthCheck(f, nil))
|
cmd.AddCommand(NewCmdAuthCheck(f, nil))
|
||||||
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +69,7 @@ func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (ope
|
|||||||
|
|
||||||
var resp userInfoResponse
|
var resp userInfoResponse
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||||
return "", "", fmt.Errorf("failed to parse user info: %w", err)
|
return "", "", fmt.Errorf("failed to parse user info: %v", err)
|
||||||
}
|
}
|
||||||
if resp.Code != 0 {
|
if resp.Code != 0 {
|
||||||
return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
|
return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
|
||||||
@@ -111,11 +109,6 @@ type appInfoResponse struct {
|
|||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// getAppInfoFn is the package-level seam used by callers (scopes.go) so tests
|
|
||||||
// can substitute a fake without standing up a full SDK + httpmock pipeline.
|
|
||||||
// Mirrors the pollDeviceToken pattern in login.go.
|
|
||||||
var getAppInfoFn = getAppInfo
|
|
||||||
|
|
||||||
// getAppInfo queries app info from the Lark API.
|
// getAppInfo queries app info from the Lark API.
|
||||||
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
||||||
ac, err := f.NewAPIClient()
|
ac, err := f.NewAPIClient()
|
||||||
@@ -137,10 +130,10 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
|||||||
|
|
||||||
var resp appInfoResponse
|
var resp appInfoResponse
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
return nil, fmt.Errorf("failed to parse response: %v", err)
|
||||||
}
|
}
|
||||||
if resp.Code != 0 {
|
if resp.Code != 0 {
|
||||||
return nil, classifyAppInfoErr(apiResp.RawBody, resp.Code, resp.Msg, f, appId)
|
return nil, fmt.Errorf("API error [%d]: %s", resp.Code, resp.Msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
app := resp.Data.App
|
app := resp.Data.App
|
||||||
@@ -159,21 +152,3 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
|||||||
|
|
||||||
return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil
|
return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// classifyAppInfoErr re-decodes the raw body so BuildAPIError sees the
|
|
||||||
// upstream `error` block — the typed appInfoResponse shape drops it.
|
|
||||||
func classifyAppInfoErr(rawBody []byte, code int, msg string, f *cmdutil.Factory, appId string) error {
|
|
||||||
var raw map[string]any
|
|
||||||
_ = json.Unmarshal(rawBody, &raw)
|
|
||||||
if raw == nil {
|
|
||||||
raw = map[string]any{}
|
|
||||||
}
|
|
||||||
raw["code"] = code
|
|
||||||
raw["msg"] = msg
|
|
||||||
cc := errclass.ClassifyContext{Identity: string(core.AsBot)}
|
|
||||||
if cfg, _ := f.Config(); cfg != nil {
|
|
||||||
cc.Brand = string(cfg.Brand)
|
|
||||||
cc.AppID = appId
|
|
||||||
}
|
|
||||||
return errclass.BuildAPIError(raw, cc)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
extcred "github.com/larksuite/cli/extension/credential"
|
extcred "github.com/larksuite/cli/extension/credential"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -62,7 +61,7 @@ func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
|
|||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"only delivers final turn messages",
|
"only delivers final turn messages",
|
||||||
"--no-wait --json",
|
"--no-wait --json",
|
||||||
"send the verification URL (or QR code) to the user as your final message",
|
"send the verification URL to the user as your final message",
|
||||||
"run --device-code in a later step",
|
"run --device-code in a later step",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
@@ -319,54 +318,6 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError pins that when
|
|
||||||
// the Lark API returns a permission code (99991679 with permission_violations),
|
|
||||||
// getAppInfo classifies it as *errs.PermissionError carrying the server-
|
|
||||||
// supplied MissingScopes — not a bare error wrapped as InternalError.
|
|
||||||
func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T) {
|
|
||||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
tokenResolver := &authScopesTokenResolver{}
|
|
||||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
|
||||||
|
|
||||||
reg.Register(&httpmock.Stub{
|
|
||||||
Method: http.MethodGet,
|
|
||||||
URL: "/open-apis/application/v6/applications/test-app",
|
|
||||||
Body: map[string]interface{}{
|
|
||||||
"code": 99991679,
|
|
||||||
"msg": "scope missing",
|
|
||||||
"error": map[string]interface{}{
|
|
||||||
"permission_violations": []interface{}{
|
|
||||||
map[string]interface{}{"subject": "application:application:self_manage"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
err := authScopesRun(&ScopesOptions{
|
|
||||||
Factory: f,
|
|
||||||
Ctx: context.Background(),
|
|
||||||
Format: "json",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
var pe *errs.PermissionError
|
|
||||||
if !errors.As(err, &pe) {
|
|
||||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "application:application:self_manage" {
|
|
||||||
t.Errorf("MissingScopes = %v, want server-supplied [application:application:self_manage]", pe.MissingScopes)
|
|
||||||
}
|
|
||||||
|
|
||||||
var intErr *errs.InternalError
|
|
||||||
if errors.As(err, &intErr) {
|
|
||||||
t.Error("Lark business error must not be wrapped as InternalError; permission semantics lost")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type authScopesTokenResolver struct {
|
type authScopesTokenResolver struct {
|
||||||
requests []credential.TokenSpec
|
requests []credential.TokenSpec
|
||||||
}
|
}
|
||||||
@@ -438,8 +389,15 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
|
|||||||
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
||||||
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
||||||
}
|
}
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
var exitErr *output.ExitError
|
||||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
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")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
@@ -48,7 +47,8 @@ func authCheckRun(opts *CheckOptions) error {
|
|||||||
|
|
||||||
required := strings.Fields(opts.Scope)
|
required := strings.Fields(opts.Scope)
|
||||||
if len(required) == 0 {
|
if len(required) == 0 {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be empty").WithParam("--scope")
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": true, "granted": []string{}, "missing": []string{}})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := f.Config()
|
config, err := f.Config()
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/zalando/go-keyring"
|
|
||||||
)
|
|
||||||
|
|
||||||
// `lark-cli auth check` is a predicate command: its README contract is
|
|
||||||
// `exit 0 = ok, 1 = missing`. The JSON answer goes to stdout; stderr stays
|
|
||||||
// empty so callers can write `if lark-cli auth check ...; then ... fi`
|
|
||||||
// without their logs getting polluted by an error envelope on the negative
|
|
||||||
// branch. These tests pin that contract end-to-end through the dispatcher.
|
|
||||||
|
|
||||||
func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
|
|
||||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
||||||
// UserOpenId left empty: triggers the not_logged_in branch.
|
|
||||||
})
|
|
||||||
|
|
||||||
err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"})
|
|
||||||
|
|
||||||
if got := output.ExitCodeOf(err); got != 1 {
|
|
||||||
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
|
|
||||||
}
|
|
||||||
var bare *output.ExitError
|
|
||||||
if !errors.As(err, &bare) {
|
|
||||||
t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if bare.Detail != nil {
|
|
||||||
t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
if stderr.Len() != 0 {
|
|
||||||
t.Errorf("stderr must stay empty for predicate negative answer, got:\n%s", stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload map[string]any
|
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
||||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
|
||||||
}
|
|
||||||
if payload["ok"] != false {
|
|
||||||
t.Errorf("stdout.ok = %v, want false", payload["ok"])
|
|
||||||
}
|
|
||||||
if payload["error"] != "not_logged_in" {
|
|
||||||
t.Errorf("stdout.error = %v, want 'not_logged_in'", payload["error"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) {
|
|
||||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
||||||
UserOpenId: "ou_user", UserName: "tester",
|
|
||||||
})
|
|
||||||
|
|
||||||
err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"})
|
|
||||||
|
|
||||||
if got := output.ExitCodeOf(err); got != 1 {
|
|
||||||
t.Errorf("exit code = %d, want 1", got)
|
|
||||||
}
|
|
||||||
if stderr.Len() != 0 {
|
|
||||||
t.Errorf("stderr must stay empty, got:\n%s", stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload map[string]any
|
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
||||||
t.Fatalf("stdout must be valid JSON: %v", err)
|
|
||||||
}
|
|
||||||
if payload["ok"] != false {
|
|
||||||
t.Errorf("stdout.ok = %v, want false", payload["ok"])
|
|
||||||
}
|
|
||||||
if payload["error"] != "no_token" {
|
|
||||||
t.Errorf("stdout.error = %v, want 'no_token'", payload["error"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
|
|
||||||
// Predicate command happy path: stored token covers every required
|
|
||||||
// scope. Exit must be 0 (nil error, not ErrBare), stdout carries the
|
|
||||||
// `{"ok":true,...}` JSON answer, and stderr stays empty so shell
|
|
||||||
// callers can rely on `if lark-cli auth check ...; then` without log
|
|
||||||
// pollution. Pairs with the two exit-1 negatives above so both
|
|
||||||
// branches of the predicate contract are pinned.
|
|
||||||
keyring.MockInit()
|
|
||||||
t.Setenv("HOME", t.TempDir())
|
|
||||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
|
||||||
|
|
||||||
cfg := &core.CliConfig{
|
|
||||||
AppID: "test-app",
|
|
||||||
AppSecret: "test-secret",
|
|
||||||
Brand: core.BrandFeishu,
|
|
||||||
UserOpenId: "ou_user",
|
|
||||||
UserName: "tester",
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
|
||||||
AppId: cfg.AppID,
|
|
||||||
UserOpenId: cfg.UserOpenId,
|
|
||||||
AccessToken: "user-access-token",
|
|
||||||
RefreshToken: "refresh-token",
|
|
||||||
ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
|
||||||
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
|
||||||
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
|
||||||
Scope: "im:message docx:document",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("SetStoredToken() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
|
|
||||||
|
|
||||||
err := authCheckRun(&CheckOptions{Factory: f, Scope: "im:message"})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected nil error for happy path (exit 0), got %v", err)
|
|
||||||
}
|
|
||||||
if got := output.ExitCodeOf(err); got != 0 {
|
|
||||||
t.Errorf("exit code = %d, want 0", got)
|
|
||||||
}
|
|
||||||
if stderr.Len() != 0 {
|
|
||||||
t.Errorf("stderr must stay empty for predicate exit-0 answer, got:\n%s", stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload map[string]any
|
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
||||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
|
||||||
}
|
|
||||||
if payload["ok"] != true {
|
|
||||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
|
||||||
}
|
|
||||||
granted, ok := payload["granted"].([]any)
|
|
||||||
if !ok || len(granted) != 1 || granted[0] != "im:message" {
|
|
||||||
t.Errorf("stdout.granted = %v, want [im:message]", payload["granted"])
|
|
||||||
}
|
|
||||||
if payload["missing"] != nil {
|
|
||||||
t.Errorf("stdout.missing = %v, want nil/absent on happy path", payload["missing"])
|
|
||||||
}
|
|
||||||
if _, has := payload["suggestion"]; has {
|
|
||||||
t.Errorf("stdout.suggestion must be absent on happy path; got %v", payload["suggestion"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
|
|
||||||
// Scope validation is a real input error, not a predicate negative
|
|
||||||
// answer — it must surface as a typed ValidationError with the normal
|
|
||||||
// stderr envelope, distinct from the silent ErrBare predicate path.
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
|
|
||||||
err := authCheckRun(&CheckOptions{Factory: f, Scope: " "})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected validation error for empty --scope")
|
|
||||||
}
|
|
||||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,12 +13,9 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
|
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/i18n"
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
"github.com/larksuite/cli/shortcuts"
|
"github.com/larksuite/cli/shortcuts"
|
||||||
@@ -50,15 +47,14 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
|
|||||||
Long: `Device Flow authorization login.
|
Long: `Device Flow authorization login.
|
||||||
|
|
||||||
For AI agents: this command blocks until the user completes authorization in the
|
For AI agents: this command blocks until the user completes authorization in the
|
||||||
browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json,
|
browser. If your harness only delivers final turn messages, use --no-wait --json,
|
||||||
send the verification URL (or QR code) to the user as your final message, end the turn, then
|
send the verification URL to the user as your final message, end the turn, then
|
||||||
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
|
run --device-code in a later step after the user confirms authorization.`,
|
||||||
to generate QR codes (supports ASCII and PNG formats).`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
return output.ErrWithHint(output.ExitValidation, "command_denied",
|
||||||
"strict mode is %q, user login is disabled in this profile", mode).
|
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
|
||||||
WithHint("if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
"if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||||
}
|
}
|
||||||
opts.Ctx = cmd.Context()
|
opts.Ctx = cmd.Context()
|
||||||
if runF != nil {
|
if runF != nil {
|
||||||
@@ -72,13 +68,7 @@ to generate QR codes (supports ASCII and PNG formats).`,
|
|||||||
|
|
||||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||||
var helpBrand core.LarkBrand
|
available := sortedKnownDomains()
|
||||||
if f != nil && f.Config != nil {
|
|
||||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
|
||||||
helpBrand = cfg.Brand
|
|
||||||
}
|
|
||||||
}
|
|
||||||
available := sortedKnownDomains(helpBrand)
|
|
||||||
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
|
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
|
||||||
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
|
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
|
||||||
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
|
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
|
||||||
@@ -124,7 +114,7 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine UI language from saved config
|
// Determine UI language from saved config
|
||||||
var lang i18n.Lang
|
lang := "zh"
|
||||||
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
|
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
|
||||||
if app := multi.FindApp(config.ProfileName); app != nil {
|
if app := multi.FindApp(config.ProfileName); app != nil {
|
||||||
lang = app.Lang
|
lang = app.Lang
|
||||||
@@ -149,25 +139,25 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
// Expand --domain all to all available domains (from_meta projects + shortcut services)
|
// Expand --domain all to all available domains (from_meta projects + shortcut services)
|
||||||
for _, d := range selectedDomains {
|
for _, d := range selectedDomains {
|
||||||
if strings.EqualFold(d, "all") {
|
if strings.EqualFold(d, "all") {
|
||||||
selectedDomains = sortedKnownDomains(config.Brand)
|
selectedDomains = sortedKnownDomains()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate domain names and suggest corrections for unknown ones
|
// Validate domain names and suggest corrections for unknown ones
|
||||||
if len(selectedDomains) > 0 {
|
if len(selectedDomains) > 0 {
|
||||||
knownDomains := allKnownDomains(config.Brand)
|
knownDomains := allKnownDomains()
|
||||||
for _, d := range selectedDomains {
|
for _, d := range selectedDomains {
|
||||||
if !knownDomains[d] {
|
if !knownDomains[d] {
|
||||||
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
|
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain")
|
return output.ErrValidation("unknown domain %q, did you mean %q?", d, suggestion)
|
||||||
}
|
}
|
||||||
available := make([]string, 0, len(knownDomains))
|
available := make([]string, 0, len(knownDomains))
|
||||||
for k := range knownDomains {
|
for k := range knownDomains {
|
||||||
available = append(available, k)
|
available = append(available, k)
|
||||||
}
|
}
|
||||||
sort.Strings(available)
|
sort.Strings(available)
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain")
|
return output.ErrValidation("unknown domain %q, available domains: %s", d, strings.Join(available, ", "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,17 +165,17 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
|
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
|
||||||
|
|
||||||
if len(opts.Exclude) > 0 && !hasAnyOption {
|
if len(opts.Exclude) > 0 && !hasAnyOption {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--exclude requires --scope, --domain, or --recommend to be specified").WithParam("--exclude")
|
return output.ErrValidation("--exclude requires --scope, --domain, or --recommend to be specified")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasAnyOption {
|
if !hasAnyOption {
|
||||||
if !opts.JSON && f.IOStreams.IsTerminal {
|
if !opts.JSON && f.IOStreams.IsTerminal {
|
||||||
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
|
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected")
|
return output.ErrValidation("no login options selected")
|
||||||
}
|
}
|
||||||
selectedDomains = result.Domains
|
selectedDomains = result.Domains
|
||||||
scopeLevel = result.ScopeLevel
|
scopeLevel = result.ScopeLevel
|
||||||
@@ -201,7 +191,7 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
log(msg.HintFooter)
|
log(msg.HintFooter)
|
||||||
log("")
|
log("")
|
||||||
log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.")
|
log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.")
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope")
|
return output.ErrValidation("please specify the scopes to authorize")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,10 +208,10 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
if len(selectedDomains) > 0 || opts.Recommend {
|
if len(selectedDomains) > 0 || opts.Recommend {
|
||||||
var candidateScopes []string
|
var candidateScopes []string
|
||||||
if len(selectedDomains) > 0 {
|
if len(selectedDomains) > 0 {
|
||||||
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
|
candidateScopes = collectScopesForDomains(selectedDomains, "user")
|
||||||
} else {
|
} else {
|
||||||
// --recommend without --domain: all domains
|
// --recommend without --domain: all domains
|
||||||
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
|
candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to auto-approve scopes if --recommend or interactive "common"
|
// Filter to auto-approve scopes if --recommend or interactive "common"
|
||||||
@@ -230,7 +220,7 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(candidateScopes) == 0 && opts.Scope == "" {
|
if len(candidateScopes) == 0 && opts.Scope == "" {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options")
|
return output.ErrValidation("no matching scopes found, check domain/scope options")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge --scope additively with the resolved domain scopes.
|
// Merge --scope additively with the resolved domain scopes.
|
||||||
@@ -250,13 +240,13 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
if len(opts.Exclude) > 0 {
|
if len(opts.Exclude) > 0 {
|
||||||
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
|
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
|
||||||
if len(unknown) > 0 {
|
if len(unknown) > 0 {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
return output.ErrValidation(
|
||||||
"these --exclude scopes are not present in the requested set: %s",
|
"these --exclude scopes are not present in the requested set: %s",
|
||||||
strings.Join(unknown, ", ")).WithParam("--exclude")
|
strings.Join(unknown, ", "))
|
||||||
}
|
}
|
||||||
finalScope = excluded
|
finalScope = excluded
|
||||||
if strings.TrimSpace(finalScope) == "" {
|
if strings.TrimSpace(finalScope) == "" {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no scopes left after applying --exclude; nothing to authorize").WithParam("--exclude")
|
return output.ErrValidation("no scopes left after applying --exclude; nothing to authorize")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +257,7 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
}
|
}
|
||||||
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
|
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err)
|
return output.ErrAuth("device authorization failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --no-wait: return immediately with device code and URL
|
// --no-wait: return immediately with device code and URL
|
||||||
@@ -279,18 +269,12 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
"verification_url": authResp.VerificationUriComplete,
|
"verification_url": authResp.VerificationUriComplete,
|
||||||
"device_code": authResp.DeviceCode,
|
"device_code": authResp.DeviceCode,
|
||||||
"expires_in": authResp.ExpiresIn,
|
"expires_in": authResp.ExpiresIn,
|
||||||
"hint": "**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it." +
|
"hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the URL the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
|
||||||
"**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it." +
|
|
||||||
"**Display order:** Output the URL first, then place the QR code image below the URL." +
|
|
||||||
"**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation." +
|
|
||||||
"For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. **Before ending the turn, tell the user to come back and notify you after completing authorization.**" +
|
|
||||||
"**After the user confirms authorization:** YOU must execute `lark-cli auth login --device-code <device_code>` yourself." +
|
|
||||||
"**Do NOT cache verification_url or device_code for future use.** Always run `lark-cli auth login --no-wait --json` fresh when authorization is needed.",
|
|
||||||
}
|
}
|
||||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||||
encoder.SetEscapeHTML(false)
|
encoder.SetEscapeHTML(false)
|
||||||
if err := encoder.Encode(data); err != nil {
|
if err := encoder.Encode(data); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -312,7 +296,7 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||||
encoder.SetEscapeHTML(false)
|
encoder.SetEscapeHTML(false)
|
||||||
if err := encoder.Encode(data); err != nil {
|
if err := encoder.Encode(data); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||||
@@ -333,25 +317,25 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
"event": "authorization_failed",
|
"event": "authorization_failed",
|
||||||
"error": result.Message,
|
"error": result.Message,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||||
}
|
}
|
||||||
return output.ErrBare(output.ExitAuth)
|
return output.ErrBare(output.ExitAuth)
|
||||||
}
|
}
|
||||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message)
|
return output.ErrAuth("authorization failed: %s", result.Message)
|
||||||
}
|
}
|
||||||
if result.Token == nil {
|
if result.Token == nil {
|
||||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned")
|
return output.ErrAuth("authorization succeeded but no token returned")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Get user info
|
// Step 6: Get user info
|
||||||
log(msg.AuthSuccess)
|
log(msg.AuthSuccess)
|
||||||
sdk, err := f.LarkClient()
|
sdk, err := f.LarkClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err)
|
return output.ErrAuth("failed to get SDK: %v", err)
|
||||||
}
|
}
|
||||||
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
|
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err)
|
return output.ErrAuth("failed to get user info: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
|
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
|
||||||
@@ -369,13 +353,13 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
GrantedAt: now,
|
GrantedAt: now,
|
||||||
}
|
}
|
||||||
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save token: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 8: Update config — overwrite Users to single user, clean old tokens
|
// Step 8: Update config — overwrite Users to single user, clean old tokens
|
||||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||||
return err
|
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||||
@@ -418,22 +402,22 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
|||||||
if shouldRemoveLoginRequestedScope(result) {
|
if shouldRemoveLoginRequestedScope(result) {
|
||||||
cleanupRequestedScope()
|
cleanupRequestedScope()
|
||||||
}
|
}
|
||||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message)
|
return output.ErrAuth("authorization failed: %s", result.Message)
|
||||||
}
|
}
|
||||||
defer cleanupRequestedScope()
|
defer cleanupRequestedScope()
|
||||||
if result.Token == nil {
|
if result.Token == nil {
|
||||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned")
|
return output.ErrAuth("authorization succeeded but no token returned")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user info
|
// Get user info
|
||||||
log(msg.AuthSuccess)
|
log(msg.AuthSuccess)
|
||||||
sdk, err := f.LarkClient()
|
sdk, err := f.LarkClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err)
|
return output.ErrAuth("failed to get SDK: %v", err)
|
||||||
}
|
}
|
||||||
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
|
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err)
|
return output.ErrAuth("failed to get user info: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
|
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
|
||||||
@@ -451,13 +435,13 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
|||||||
GrantedAt: now,
|
GrantedAt: now,
|
||||||
}
|
}
|
||||||
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save token: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update config — overwrite Users to single user, clean old tokens
|
// Update config — overwrite Users to single user, clean old tokens
|
||||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to update login profile: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||||
@@ -468,22 +452,21 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncLoginUserToProfile persists the logged-in user info into the named profile.
|
|
||||||
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||||
multi, err := core.LoadMultiAppConfig()
|
multi, err := core.LoadMultiAppConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err)
|
return fmt.Errorf("load config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
app := findProfileByName(multi, profileName)
|
app := findProfileByName(multi, profileName)
|
||||||
if app == nil {
|
if app == nil {
|
||||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName)
|
return fmt.Errorf("profile %q not found in config", profileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
oldUsers := append([]core.AppUser(nil), app.Users...)
|
oldUsers := append([]core.AppUser(nil), app.Users...)
|
||||||
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
|
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err)
|
return fmt.Errorf("save config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, oldUser := range oldUsers {
|
for _, oldUser := range oldUsers {
|
||||||
@@ -494,7 +477,6 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findProfileByName returns the AppConfig matching profileName, or nil.
|
|
||||||
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
|
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
|
||||||
for i := range multi.Apps {
|
for i := range multi.Apps {
|
||||||
if multi.Apps[i].ProfileName() == profileName {
|
if multi.Apps[i].ProfileName() == profileName {
|
||||||
@@ -508,7 +490,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
|
|||||||
// shortcut scopes for the given domain names.
|
// shortcut scopes for the given domain names.
|
||||||
// Domains with auth_domain children are automatically expanded to include
|
// Domains with auth_domain children are automatically expanded to include
|
||||||
// their children's scopes.
|
// their children's scopes.
|
||||||
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
|
func collectScopesForDomains(domains []string, identity string) []string {
|
||||||
scopeSet := make(map[string]bool)
|
scopeSet := make(map[string]bool)
|
||||||
|
|
||||||
// 1. API scopes from from_meta projects
|
// 1. API scopes from from_meta projects
|
||||||
@@ -527,9 +509,6 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
|
|||||||
|
|
||||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||||
for _, sc := range shortcuts.AllShortcuts() {
|
for _, sc := range shortcuts.AllShortcuts() {
|
||||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||||
for _, s := range sc.DeclaredScopesForIdentity(identity) {
|
for _, s := range sc.DeclaredScopesForIdentity(identity) {
|
||||||
scopeSet[s] = true
|
scopeSet[s] = true
|
||||||
@@ -549,7 +528,7 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
|
|||||||
// allKnownDomains returns all valid auth domain names (from_meta projects +
|
// allKnownDomains returns all valid auth domain names (from_meta projects +
|
||||||
// shortcut services), excluding domains that have auth_domain set (they are
|
// shortcut services), excluding domains that have auth_domain set (they are
|
||||||
// folded into their parent domain).
|
// folded into their parent domain).
|
||||||
func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
func allKnownDomains() map[string]bool {
|
||||||
domains := make(map[string]bool)
|
domains := make(map[string]bool)
|
||||||
for _, p := range registry.ListFromMetaProjects() {
|
for _, p := range registry.ListFromMetaProjects() {
|
||||||
if !registry.HasAuthDomain(p) {
|
if !registry.HasAuthDomain(p) {
|
||||||
@@ -557,9 +536,6 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, sc := range shortcuts.AllShortcuts() {
|
for _, sc := range shortcuts.AllShortcuts() {
|
||||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !registry.HasAuthDomain(sc.Service) {
|
if !registry.HasAuthDomain(sc.Service) {
|
||||||
domains[sc.Service] = true
|
domains[sc.Service] = true
|
||||||
}
|
}
|
||||||
@@ -568,8 +544,8 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sortedKnownDomains returns all valid domain names sorted alphabetically.
|
// sortedKnownDomains returns all valid domain names sorted alphabetically.
|
||||||
func sortedKnownDomains(brand core.LarkBrand) []string {
|
func sortedKnownDomains() []string {
|
||||||
m := allKnownDomains(brand)
|
m := allKnownDomains()
|
||||||
domains := make([]string, 0, len(m))
|
domains := make([]string, 0, len(m))
|
||||||
for d := range m {
|
for d := range m {
|
||||||
domains = append(domains, d)
|
domains = append(domains, d)
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
|
|
||||||
feishuDomains := allKnownDomains(core.BrandFeishu)
|
|
||||||
if !feishuDomains["apps"] {
|
|
||||||
t.Errorf("expected apps domain to be known on Feishu brand")
|
|
||||||
}
|
|
||||||
|
|
||||||
larkDomains := allKnownDomains(core.BrandLark)
|
|
||||||
if larkDomains["apps"] {
|
|
||||||
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
|
|
||||||
}
|
|
||||||
|
|
||||||
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
|
|
||||||
if len(feishuScopes) == 0 {
|
|
||||||
t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes))
|
|
||||||
}
|
|
||||||
|
|
||||||
larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark)
|
|
||||||
if len(larkScopes) != 0 {
|
|
||||||
t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,9 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
"github.com/larksuite/cli/shortcuts"
|
"github.com/larksuite/cli/shortcuts"
|
||||||
@@ -107,7 +105,7 @@ func buildDomainMeta(name, lang string) domainMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
|
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
|
||||||
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
|
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*interactiveResult, error) {
|
||||||
allDomains := getDomainMetadata(lang)
|
allDomains := getDomainMetadata(lang)
|
||||||
|
|
||||||
// Build multi-select options
|
// Build multi-select options
|
||||||
@@ -163,11 +161,11 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(selectedDomains) == 0 {
|
if len(selectedDomains) == 0 {
|
||||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no domains selected").WithParam("--domain")
|
return nil, output.ErrValidation("no domains selected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute scope summary
|
// Compute scope summary
|
||||||
scopes := collectScopesForDomains(selectedDomains, "user", brand)
|
scopes := collectScopesForDomains(selectedDomains, "user")
|
||||||
if permLevel == "common" {
|
if permLevel == "common" {
|
||||||
scopes = registry.FilterAutoApproveScopes(scopes)
|
scopes = registry.FilterAutoApproveScopes(scopes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
|
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import "github.com/larksuite/cli/internal/i18n"
|
|
||||||
|
|
||||||
type loginMsg struct {
|
type loginMsg struct {
|
||||||
// Interactive UI (login_interactive.go)
|
// Interactive UI (login_interactive.go)
|
||||||
SelectDomains string
|
SelectDomains string
|
||||||
@@ -61,7 +59,7 @@ var loginMsgZh = &loginMsg{
|
|||||||
|
|
||||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||||
WaitingAuth: "等待用户授权...",
|
WaitingAuth: "等待用户授权...",
|
||||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url,把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code <code>\" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output);仅当用户明确要求时才使用 ASCII(--ascii)。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL,再将二维码图片置于 URL 下方完整展示。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string,不要做任何修改(包括 URL 编码/解码、添加空格或标点)。",
|
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 只会把最终回复发给用户,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 和 verification_url,把 verification_url 作为本轮最终消息原样发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 `lark-cli auth login --device-code <code>` 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL,把它视为不可修改的 opaque string;不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。",
|
||||||
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
||||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||||
@@ -97,7 +95,7 @@ var loginMsgEn = &loginMsg{
|
|||||||
|
|
||||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||||
WaitingAuth: "Waiting for user authorization...",
|
WaitingAuth: "Waiting for user authorization...",
|
||||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code <code>\" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation.",
|
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness only delivers final turn messages, use `lark-cli auth login --no-wait --json` to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run `lark-cli auth login --device-code <code>` in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
|
||||||
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
||||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||||
AuthorizedUser: "Authorized account: %s (%s)",
|
AuthorizedUser: "Authorized account: %s (%s)",
|
||||||
@@ -116,9 +114,8 @@ var loginMsgEn = &loginMsg{
|
|||||||
HintFooter: " lark-cli auth login --help",
|
HintFooter: " lark-cli auth login --help",
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLoginMsg returns the login message bundle for the given language.
|
func getLoginMsg(lang string) *loginMsg {
|
||||||
func getLoginMsg(lang i18n.Lang) *loginMsg {
|
if lang == "en" {
|
||||||
if lang.IsEnglish() {
|
|
||||||
return loginMsgEn
|
return loginMsgEn
|
||||||
}
|
}
|
||||||
return loginMsgZh
|
return loginMsgZh
|
||||||
@@ -128,5 +125,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
|||||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||||
// service_descriptions.json.
|
// service_descriptions.json.
|
||||||
func getShortcutOnlyDomainNames() []string {
|
func getShortcutOnlyDomainNames() []string {
|
||||||
return []string{"base", "contact", "docs", "markdown", "apps"}
|
return []string{"base", "contact", "docs", "markdown"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/i18n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetLoginMsg_Zh(t *testing.T) {
|
func TestGetLoginMsg_Zh(t *testing.T) {
|
||||||
@@ -33,7 +31,7 @@ func TestGetLoginMsg_En(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetLoginMsg_DefaultsToZh(t *testing.T) {
|
func TestGetLoginMsg_DefaultsToZh(t *testing.T) {
|
||||||
for _, lang := range []i18n.Lang{"", "fr_fr", "ja_jp", "unknown"} {
|
for _, lang := range []string{"", "fr", "ja", "unknown"} {
|
||||||
msg := getLoginMsg(lang)
|
msg := getLoginMsg(lang)
|
||||||
if msg != loginMsgZh {
|
if msg != loginMsgZh {
|
||||||
t.Errorf("getLoginMsg(%q) should default to zh", lang)
|
t.Errorf("getLoginMsg(%q) should default to zh", lang)
|
||||||
@@ -63,7 +61,7 @@ func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginMsg_FormatStrings(t *testing.T) {
|
func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||||
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
|
for _, lang := range []string{"zh", "en"} {
|
||||||
msg := getLoginMsg(lang)
|
msg := getLoginMsg(lang)
|
||||||
|
|
||||||
// LoginSuccess should contain two %s placeholders (userName, openId)
|
// LoginSuccess should contain two %s placeholders (userName, openId)
|
||||||
@@ -104,10 +102,10 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
|||||||
// --device-code split-flow, and (c) non-streaming harnesses must end the turn
|
// --device-code split-flow, and (c) non-streaming harnesses must end the turn
|
||||||
// after presenting the URL instead of blocking in the same turn.
|
// after presenting the URL instead of blocking in the same turn.
|
||||||
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
|
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
|
||||||
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
|
for _, lang := range []string{"zh", "en"} {
|
||||||
hint := getLoginMsg(lang).AgentTimeoutHint
|
hint := getLoginMsg(lang).AgentTimeoutHint
|
||||||
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
|
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
|
||||||
if lang == i18n.LangZhCN && want == "turn" {
|
if lang == "zh" && want == "turn" {
|
||||||
want = "本轮"
|
want = "本轮"
|
||||||
}
|
}
|
||||||
if !strings.Contains(hint, want) {
|
if !strings.Contains(hint, want) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
@@ -172,12 +171,20 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
|||||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||||
return output.ErrBare(output.ExitAuth)
|
return output.ErrBare(output.ExitAuth)
|
||||||
}
|
}
|
||||||
return errs.NewPermissionError(errs.SubtypeMissingScope, "%s", issue.Message).
|
detail := map[string]interface{}{
|
||||||
WithHint("%s", issue.Hint).
|
"requested": issue.Summary.Requested,
|
||||||
WithIdentity("user").
|
"granted": issue.Summary.Granted,
|
||||||
WithRequestedScopes(issue.Summary.Requested...).
|
"missing": issue.Summary.Missing,
|
||||||
WithGrantedScopes(issue.Summary.Granted...).
|
}
|
||||||
WithMissingScopes(issue.Summary.Missing...)
|
return &output.ExitError{
|
||||||
|
Code: output.ExitAuth,
|
||||||
|
Detail: &output.ErrDetail{
|
||||||
|
Type: "missing_scope",
|
||||||
|
Message: issue.Message,
|
||||||
|
Hint: issue.Hint,
|
||||||
|
Detail: detail,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple asserts that the
|
|
||||||
// failed-login JSON branch (loginSucceeded == false, opts.JSON == true) wires
|
|
||||||
// requested + granted + missing scopes into the typed *PermissionError
|
|
||||||
// envelope. Consumers need the full triple to render actionable diagnostics,
|
|
||||||
// not just the missing set.
|
|
||||||
func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
|
|
||||||
requested := []string{"docx:document", "im:message:send"}
|
|
||||||
granted := []string{"docx:document"}
|
|
||||||
missing := []string{"im:message:send"}
|
|
||||||
|
|
||||||
err := handleLoginScopeIssue(
|
|
||||||
&LoginOptions{JSON: true},
|
|
||||||
getLoginMsg("en"),
|
|
||||||
f,
|
|
||||||
&loginScopeIssue{
|
|
||||||
Message: "scope insufficient",
|
|
||||||
Hint: "re-login with --scope im:message:send",
|
|
||||||
Summary: &loginScopeSummary{
|
|
||||||
Requested: requested,
|
|
||||||
Granted: granted,
|
|
||||||
Missing: missing,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"", // openId empty -> loginSucceeded = false
|
|
||||||
"tester",
|
|
||||||
)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
var permErr *errs.PermissionError
|
|
||||||
if !errors.As(err, &permErr) {
|
|
||||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(permErr.RequestedScopes, requested) {
|
|
||||||
t.Errorf("RequestedScopes = %v, want %v", permErr.RequestedScopes, requested)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(permErr.GrantedScopes, granted) {
|
|
||||||
t.Errorf("GrantedScopes = %v, want %v", permErr.GrantedScopes, granted)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(permErr.MissingScopes, missing) {
|
|
||||||
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, missing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -171,7 +171,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAllKnownDomains(t *testing.T) {
|
func TestAllKnownDomains(t *testing.T) {
|
||||||
domains := allKnownDomains("")
|
domains := allKnownDomains()
|
||||||
if len(domains) == 0 {
|
if len(domains) == 0 {
|
||||||
t.Fatal("expected non-empty known domains")
|
t.Fatal("expected non-empty known domains")
|
||||||
}
|
}
|
||||||
@@ -185,7 +185,7 @@ func TestAllKnownDomains(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSortedKnownDomains(t *testing.T) {
|
func TestSortedKnownDomains(t *testing.T) {
|
||||||
sorted := sortedKnownDomains("")
|
sorted := sortedKnownDomains()
|
||||||
if len(sorted) == 0 {
|
if len(sorted) == 0 {
|
||||||
t.Fatal("expected non-empty sorted domains")
|
t.Fatal("expected non-empty sorted domains")
|
||||||
}
|
}
|
||||||
@@ -195,7 +195,7 @@ func TestSortedKnownDomains(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Should match allKnownDomains
|
// Should match allKnownDomains
|
||||||
known := allKnownDomains("")
|
known := allKnownDomains()
|
||||||
if len(sorted) != len(known) {
|
if len(sorted) != len(known) {
|
||||||
t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known))
|
t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known))
|
||||||
}
|
}
|
||||||
@@ -220,7 +220,7 @@ func TestCollectScopesForDomains(t *testing.T) {
|
|||||||
t.Skip("no from_meta data available")
|
t.Skip("no from_meta data available")
|
||||||
}
|
}
|
||||||
|
|
||||||
scopes := collectScopesForDomains([]string{"calendar"}, "user", "")
|
scopes := collectScopesForDomains([]string{"calendar"}, "user")
|
||||||
if len(scopes) == 0 {
|
if len(scopes) == 0 {
|
||||||
t.Fatal("expected non-empty scopes for calendar domain")
|
t.Fatal("expected non-empty scopes for calendar domain")
|
||||||
}
|
}
|
||||||
@@ -247,7 +247,7 @@ func TestCollectScopesForDomains(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
|
func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
|
||||||
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user", "")
|
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user")
|
||||||
if len(scopes) != 0 {
|
if len(scopes) != 0 {
|
||||||
t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes))
|
t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes))
|
||||||
}
|
}
|
||||||
@@ -400,11 +400,12 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
|||||||
Granted: []string{"base:app:copy"},
|
Granted: []string{"base:app:copy"},
|
||||||
},
|
},
|
||||||
}, "ou_user", "tester")
|
}, "ou_user", "tester")
|
||||||
if err == nil {
|
var exitErr *output.ExitError
|
||||||
t.Fatal("expected error, got nil")
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("expected ExitError, got %v", err)
|
||||||
}
|
}
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
if exitErr.Code != output.ExitAuth {
|
||||||
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||||
}
|
}
|
||||||
got := stderr.String()
|
got := stderr.String()
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
@@ -442,11 +443,12 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
|||||||
Granted: []string{"base:app:copy"},
|
Granted: []string{"base:app:copy"},
|
||||||
},
|
},
|
||||||
}, "ou_user", "tester")
|
}, "ou_user", "tester")
|
||||||
if err == nil {
|
var exitErr *output.ExitError
|
||||||
t.Fatal("expected error, got nil")
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("expected ExitError, got %v", err)
|
||||||
}
|
}
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
if exitErr.Code != output.ExitAuth {
|
||||||
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||||
}
|
}
|
||||||
|
|
||||||
var data map[string]interface{}
|
var data map[string]interface{}
|
||||||
@@ -651,11 +653,12 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
|||||||
Ctx: context.Background(),
|
Ctx: context.Background(),
|
||||||
Scope: "im:message:send",
|
Scope: "im:message:send",
|
||||||
})
|
})
|
||||||
if err == nil {
|
var exitErr *output.ExitError
|
||||||
t.Fatal("expected error, got nil")
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("expected ExitError, got %v", err)
|
||||||
}
|
}
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
if exitErr.Code != output.ExitAuth {
|
||||||
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||||
}
|
}
|
||||||
got := stderr.String()
|
got := stderr.String()
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
@@ -867,90 +870,6 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty pins the
|
|
||||||
// contract that when --json is set and pollDeviceToken returns OK=false,
|
|
||||||
// stdout carries the structured authorization_failed event and stderr is
|
|
||||||
// NOT polluted with a typed envelope. The returned error is a bare
|
|
||||||
// ExitError with ExitAuth so the dispatcher only propagates the exit code
|
|
||||||
// without emitting a second envelope on top of the JSON event.
|
|
||||||
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
|
|
||||||
keyring.MockInit()
|
|
||||||
setupLoginConfigDir(t)
|
|
||||||
|
|
||||||
original := pollDeviceToken
|
|
||||||
t.Cleanup(func() { pollDeviceToken = original })
|
|
||||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
|
||||||
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
|
|
||||||
}
|
|
||||||
|
|
||||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
ProfileName: "default",
|
|
||||||
AppID: "cli_test",
|
|
||||||
AppSecret: "secret",
|
|
||||||
Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
|
|
||||||
reg.Register(&httpmock.Stub{
|
|
||||||
Method: "POST",
|
|
||||||
URL: larkauth.PathDeviceAuthorization,
|
|
||||||
Body: map[string]interface{}{
|
|
||||||
"device_code": "device-code",
|
|
||||||
"user_code": "user-code",
|
|
||||||
"verification_uri": "https://example.com/verify",
|
|
||||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
|
||||||
"expires_in": 240,
|
|
||||||
"interval": 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
err := authLoginRun(&LoginOptions{
|
|
||||||
Factory: f,
|
|
||||||
Ctx: context.Background(),
|
|
||||||
Scope: "im:message:send",
|
|
||||||
JSON: true,
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for aborted authorization")
|
|
||||||
}
|
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
|
||||||
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
|
||||||
}
|
|
||||||
|
|
||||||
// stdout: device_authorization event + authorization_failed event,
|
|
||||||
// the latter carrying the abort message as a structured field.
|
|
||||||
stdoutStr := stdout.String()
|
|
||||||
if !strings.Contains(stdoutStr, `"event":"authorization_failed"`) {
|
|
||||||
t.Errorf("stdout missing authorization_failed event, got: %s", stdoutStr)
|
|
||||||
}
|
|
||||||
if !strings.Contains(stdoutStr, "user denied") {
|
|
||||||
t.Errorf("stdout missing abort message, got: %s", stdoutStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// stderr must NOT carry a typed envelope: ErrBare propagates the exit
|
|
||||||
// code only, so the dispatcher emits nothing on stderr. The waiting-auth
|
|
||||||
// log line goes through the JSON-mode no-op `log` helper so it is also
|
|
||||||
// suppressed in JSON mode.
|
|
||||||
stderrStr := stderr.String()
|
|
||||||
if strings.Contains(stderrStr, `"type":"authentication"`) {
|
|
||||||
t.Errorf("stderr should not contain typed envelope, got: %s", stderrStr)
|
|
||||||
}
|
|
||||||
if strings.Contains(stderrStr, `"error"`) {
|
|
||||||
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returned error must be the bare *output.ExitError signal (no envelope).
|
|
||||||
var exitErr *output.ExitError
|
|
||||||
if !errors.As(err, &exitErr) {
|
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if exitErr.Code != output.ExitAuth {
|
|
||||||
t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth)
|
|
||||||
}
|
|
||||||
if exitErr.Detail != nil {
|
|
||||||
t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
ProfileName: "default",
|
ProfileName: "default",
|
||||||
@@ -1026,27 +945,17 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
hint, _ := data["hint"].(string)
|
hint, _ := data["hint"].(string)
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"MUST generate QR code AND display it",
|
"exactly as returned by the CLI",
|
||||||
"lark-cli auth qrcode",
|
|
||||||
"Prefer PNG QR code (--output)",
|
|
||||||
"use ASCII (--ascii) only when the user explicitly requests it",
|
|
||||||
"This is a required step, do NOT skip it",
|
|
||||||
"CRITICAL",
|
|
||||||
"You MUST include the QR image in your response",
|
|
||||||
"Generating the file alone is NOT enough",
|
|
||||||
"image tags, inline images, or file attachments",
|
|
||||||
"Display order",
|
|
||||||
"place the QR code image below the URL",
|
|
||||||
"opaque string",
|
"opaque string",
|
||||||
"cannot be modified",
|
"Do not URL-encode or decode it",
|
||||||
|
"do not add %20, spaces, or punctuation",
|
||||||
|
"do not wrap it as Markdown link text",
|
||||||
|
"fenced code block containing only the raw URL",
|
||||||
"final message of the turn",
|
"final message of the turn",
|
||||||
"return control to the user",
|
"return control to the user",
|
||||||
"do not block on --device-code in the same turn",
|
"do not block on --device-code in the same turn",
|
||||||
"come back and notify",
|
"After the user confirms authorization in a later step",
|
||||||
"YOU must execute",
|
"lark-cli auth login --device-code device-code",
|
||||||
"lark-cli auth login --device-code <device_code>",
|
|
||||||
"Do NOT cache",
|
|
||||||
"lark-cli auth login --no-wait --json",
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(hint, want) {
|
if !strings.Contains(hint, want) {
|
||||||
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
||||||
@@ -1145,17 +1054,12 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *
|
|||||||
"结束本轮",
|
"结束本轮",
|
||||||
"用户回复已完成授权",
|
"用户回复已完成授权",
|
||||||
"不要在同一轮里展示 URL 后立刻阻塞执行 --device-code",
|
"不要在同一轮里展示 URL 后立刻阻塞执行 --device-code",
|
||||||
"必须生成二维码并展示",
|
"逐字原样转发 CLI 返回的 URL",
|
||||||
"lark-cli auth qrcode",
|
|
||||||
"优先生成 PNG 二维码(--output)",
|
|
||||||
"仅当用户明确要求时才使用 ASCII(--ascii)",
|
|
||||||
"生成后必须在回复中展示图片",
|
|
||||||
"仅生成文件不算完成",
|
|
||||||
"image 标签或内联图片",
|
|
||||||
"二维码图片置于 URL 下方完整展示",
|
|
||||||
"URL 输出规则",
|
|
||||||
"opaque string",
|
"opaque string",
|
||||||
"不要做任何修改",
|
"不要做 URL 编码或解码",
|
||||||
|
"不要补 `%20`、空格或标点",
|
||||||
|
"不要改写成 Markdown 链接",
|
||||||
|
"只包含该 URL 的代码块单独输出",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(hint, want) {
|
if !strings.Contains(hint, want) {
|
||||||
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
|
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
|
||||||
@@ -1173,7 +1077,7 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||||
domains := allKnownDomains("")
|
domains := allKnownDomains()
|
||||||
if domains["whiteboard"] {
|
if domains["whiteboard"] {
|
||||||
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
|
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
|
||||||
}
|
}
|
||||||
@@ -1183,7 +1087,7 @@ func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
|
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
|
||||||
scopes := collectScopesForDomains([]string{"docs"}, "user", "")
|
scopes := collectScopesForDomains([]string{"docs"}, "user")
|
||||||
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
|
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
|
||||||
found := false
|
found := false
|
||||||
for _, s := range scopes {
|
for _, s := range scopes {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -61,7 +60,7 @@ func authLogoutRun(opts *LogoutOptions) error {
|
|||||||
}
|
}
|
||||||
app.Users = []core.AppUser{}
|
app.Users = []core.AppUser{}
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||||
}
|
}
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/skip2/go-qrcode"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/internal/validate"
|
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
|
||||||
)
|
|
||||||
|
|
||||||
// QRCodeOptions holds inputs for auth qrcode command.
|
|
||||||
type QRCodeOptions struct {
|
|
||||||
Factory *cmdutil.Factory
|
|
||||||
Ctx context.Context
|
|
||||||
URL string
|
|
||||||
Size int
|
|
||||||
ASCII bool
|
|
||||||
Output string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCmdAuthQRCode creates the auth qrcode subcommand.
|
|
||||||
func NewCmdAuthQRCode(f *cmdutil.Factory, runF func(*QRCodeOptions) error) *cobra.Command {
|
|
||||||
opts := &QRCodeOptions{Factory: f, Size: 256}
|
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "qrcode <url>",
|
|
||||||
Short: "Generate QR code for verification URL",
|
|
||||||
Long: `Generate a QR code image or ASCII representation for a verification URL.
|
|
||||||
|
|
||||||
This command is designed for AI agents to generate QR codes for OAuth authorization URLs.
|
|
||||||
|
|
||||||
For PNG output, the --output flag is required to specify the output file path (must be a relative path within the current directory).
|
|
||||||
For ASCII output, the result is printed to stdout with fixed size.`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
opts.URL = args[0]
|
|
||||||
opts.Ctx = cmd.Context()
|
|
||||||
if runF != nil {
|
|
||||||
return runF(opts)
|
|
||||||
}
|
|
||||||
return runQRCode(opts)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
|
|
||||||
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
|
|
||||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// runQRCode executes the auth qrcode command.
|
|
||||||
func runQRCode(opts *QRCodeOptions) error {
|
|
||||||
if opts.URL == "" {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "url is required").WithParam("--url")
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.ASCII {
|
|
||||||
var out io.Writer = os.Stdout
|
|
||||||
if opts.Factory != nil {
|
|
||||||
out = opts.Factory.IOStreams.Out
|
|
||||||
}
|
|
||||||
return generateASCIIQRCode(opts.URL, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.Output == "" {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.").WithParam("--output")
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.Size < 32 {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at least 32, got %d", opts.Size).WithParam("--size")
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.Size > 1024 {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at most 1024, got %d", opts.Size).WithParam("--size")
|
|
||||||
}
|
|
||||||
|
|
||||||
safePath, err := validate.SafeOutputPath(opts.Output)
|
|
||||||
if err != nil {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
result := map[string]interface{}{
|
|
||||||
"ok": true,
|
|
||||||
"file_path": safePath,
|
|
||||||
"hint": "You MUST include the QR image in your response. Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.",
|
|
||||||
}
|
|
||||||
|
|
||||||
var out io.Writer = os.Stdout
|
|
||||||
if opts.Factory != nil {
|
|
||||||
out = opts.Factory.IOStreams.Out
|
|
||||||
}
|
|
||||||
encoder := json.NewEncoder(out)
|
|
||||||
encoder.SetEscapeHTML(false)
|
|
||||||
if err := encoder.Encode(result); err != nil {
|
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write output: %v", err).WithCause(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateImageQRCode encodes the URL as a PNG QR code and writes it to outputPath.
|
|
||||||
func generateImageQRCode(url string, size int, outputPath string) error {
|
|
||||||
png, err := qrcode.Encode(url, qrcode.Medium, size)
|
|
||||||
if err != nil {
|
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to encode QR code: %v", err).WithCause(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = vfs.WriteFile(outputPath, png, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write QR code to %s: %v", outputPath, err).WithCause(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateASCIIQRCode encodes the URL as an ASCII QR code and prints it to stdout.
|
|
||||||
func generateASCIIQRCode(url string, w io.Writer) error {
|
|
||||||
q, err := qrcode.New(url, qrcode.Medium)
|
|
||||||
if err != nil {
|
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to create QR code: %v", err).WithCause(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprint(w, q.ToSmallString(false))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
|
|
||||||
var gotOpts *QRCodeOptions
|
|
||||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
|
||||||
gotOpts = opts
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png", "--size", "128"})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if gotOpts.URL != "https://example.com" {
|
|
||||||
t.Errorf("URL = %q, want %q", gotOpts.URL, "https://example.com")
|
|
||||||
}
|
|
||||||
if gotOpts.Size != 128 {
|
|
||||||
t.Errorf("Size = %d, want %d", gotOpts.Size, 128)
|
|
||||||
}
|
|
||||||
if gotOpts.Output != "qr.png" {
|
|
||||||
t.Errorf("Output = %q, want %q", gotOpts.Output, "qr.png")
|
|
||||||
}
|
|
||||||
if gotOpts.ASCII {
|
|
||||||
t.Error("ASCII should be false by default")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
|
|
||||||
var gotOpts *QRCodeOptions
|
|
||||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
|
||||||
gotOpts = opts
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
cmd.SetArgs([]string{"https://example.com", "--ascii"})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !gotOpts.ASCII {
|
|
||||||
t.Error("ASCII should be true when --ascii is passed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewCmdAuthQRCode_DefaultSize(t *testing.T) {
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
|
|
||||||
var gotOpts *QRCodeOptions
|
|
||||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
|
||||||
gotOpts = opts
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
cmd.SetArgs([]string{"https://example.com", "--ascii"})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if gotOpts.Size != 256 {
|
|
||||||
t.Errorf("default Size = %d, want 256", gotOpts.Size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewCmdAuthQRCode_ExactOneArg(t *testing.T) {
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
|
|
||||||
cmd := NewCmdAuthQRCode(f, nil)
|
|
||||||
cmd.SetErr(io.Discard)
|
|
||||||
cmd.SetArgs([]string{})
|
|
||||||
if err := cmd.Execute(); err == nil {
|
|
||||||
t.Fatal("expected error when no URL argument provided")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewCmdAuthQRCode_RunE_PNGEndToEnd(t *testing.T) {
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
oldWd, _ := os.Getwd()
|
|
||||||
if err := os.Chdir(tmpDir); err != nil {
|
|
||||||
t.Fatalf("chdir: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { os.Chdir(oldWd) })
|
|
||||||
|
|
||||||
cmd := NewCmdAuthQRCode(f, nil)
|
|
||||||
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png"})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile("qr.png")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("output file not created: %v", err)
|
|
||||||
}
|
|
||||||
if string(data[:4]) != "\x89PNG" {
|
|
||||||
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
|
||||||
t.Fatalf("stdout is not valid JSON: %v, got: %s", err, stdout.String())
|
|
||||||
}
|
|
||||||
if result["ok"] != true {
|
|
||||||
t.Errorf("ok = %v, want true", result["ok"])
|
|
||||||
}
|
|
||||||
hint, _ := result["hint"].(string)
|
|
||||||
if hint == "" {
|
|
||||||
t.Error("hint is empty")
|
|
||||||
}
|
|
||||||
if !strings.Contains(hint, "MUST include") {
|
|
||||||
t.Errorf("hint missing 'MUST include', got: %s", hint)
|
|
||||||
}
|
|
||||||
if !strings.Contains(hint, "NOT enough") {
|
|
||||||
t.Errorf("hint missing 'NOT enough', got: %s", hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewCmdAuthQRCode_RunE_MissingOutput(t *testing.T) {
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
|
|
||||||
cmd := NewCmdAuthQRCode(f, nil)
|
|
||||||
cmd.SetErr(io.Discard)
|
|
||||||
cmd.SetArgs([]string{"https://example.com"})
|
|
||||||
if err := cmd.Execute(); err == nil {
|
|
||||||
t.Fatal("expected error when --output is missing in PNG mode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
|
|
||||||
cmd := NewCmdAuthQRCode(f, nil)
|
|
||||||
cmd.SetOut(stdout)
|
|
||||||
cmd.SetErr(io.Discard)
|
|
||||||
cmd.SetArgs([]string{"--help"})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
got := stdout.String()
|
|
||||||
for _, want := range []string{
|
|
||||||
"qrcode <url>",
|
|
||||||
"QR code",
|
|
||||||
"--output",
|
|
||||||
"--ascii",
|
|
||||||
"relative path",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Errorf("help missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunQRCode_MissingURL(t *testing.T) {
|
|
||||||
err := runQRCode(&QRCodeOptions{URL: ""})
|
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunQRCode_MissingOutput(t *testing.T) {
|
|
||||||
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
|
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunQRCode_InvalidSize(t *testing.T) {
|
|
||||||
err := runQRCode(&QRCodeOptions{
|
|
||||||
URL: "https://example.com",
|
|
||||||
Size: 16,
|
|
||||||
Output: "qr.png",
|
|
||||||
})
|
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunQRCode_SizeTooLarge(t *testing.T) {
|
|
||||||
err := runQRCode(&QRCodeOptions{
|
|
||||||
URL: "https://example.com",
|
|
||||||
Size: 2048,
|
|
||||||
Output: "qr.png",
|
|
||||||
})
|
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
|
|
||||||
err := runQRCode(&QRCodeOptions{
|
|
||||||
URL: "https://example.com",
|
|
||||||
Size: 256,
|
|
||||||
Output: "/etc/passwd",
|
|
||||||
})
|
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunQRCode_PNGWritesFile(t *testing.T) {
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
oldWd, _ := os.Getwd()
|
|
||||||
if err := os.Chdir(tmpDir); err != nil {
|
|
||||||
t.Fatalf("chdir: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { os.Chdir(oldWd) })
|
|
||||||
|
|
||||||
err := runQRCode(&QRCodeOptions{
|
|
||||||
URL: "https://example.com",
|
|
||||||
Size: 256,
|
|
||||||
Output: "qr.png",
|
|
||||||
Factory: f,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat("qr.png")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("output file not created: %v", err)
|
|
||||||
}
|
|
||||||
if info.Size() == 0 {
|
|
||||||
t.Error("output file is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
if jsonErr := json.Unmarshal(stdout.Bytes(), &result); jsonErr != nil {
|
|
||||||
t.Fatalf("stdout is not valid JSON: %v, got: %s", jsonErr, stdout.String())
|
|
||||||
}
|
|
||||||
if result["ok"] != true {
|
|
||||||
t.Errorf("ok = %v, want true", result["ok"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunQRCode_ASCIIOutputsToStdout(t *testing.T) {
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
|
|
||||||
err := runQRCode(&QRCodeOptions{
|
|
||||||
URL: "https://example.com",
|
|
||||||
ASCII: true,
|
|
||||||
Factory: f,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if stdout.Len() == 0 {
|
|
||||||
t.Error("ASCII QR code produced no output")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateImageQRCode_Success(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
outputPath := filepath.Join(tmpDir, "test-qr.png")
|
|
||||||
|
|
||||||
if err := generateImageQRCode("https://example.com", 256, outputPath); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(outputPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read output file: %v", err)
|
|
||||||
}
|
|
||||||
if len(data) == 0 {
|
|
||||||
t.Error("output file is empty")
|
|
||||||
}
|
|
||||||
if len(data) < 8 {
|
|
||||||
t.Error("output too small to be a valid PNG")
|
|
||||||
}
|
|
||||||
if string(data[:4]) != "\x89PNG" {
|
|
||||||
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateImageQRCode_WriteError(t *testing.T) {
|
|
||||||
err := generateImageQRCode("https://example.com", 256, "/nonexistent/deep/nested/dir/qr.png")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error writing to nonexistent directory")
|
|
||||||
}
|
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitInternal {
|
|
||||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitInternal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateASCIIQRCode_Success(t *testing.T) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := generateASCIIQRCode("https://example.com", &buf)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if buf.Len() == 0 {
|
|
||||||
t.Error("ASCII QR code produced no output")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := generateASCIIQRCode("", &buf)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for empty string")
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
@@ -51,23 +50,11 @@ func authScopesRun(opts *ScopesOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n")
|
fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n")
|
||||||
appInfo, err := getAppInfoFn(opts.Ctx, f, config.AppID)
|
appInfo, err := getAppInfo(opts.Ctx, f, config.AppID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Discriminate by error type so transport / parse failures are not
|
return output.ErrWithHint(output.ExitAPI, "permission",
|
||||||
// reclassified as PermissionError(MissingScope) — re-auth does not
|
fmt.Sprintf("failed to get app scope info: %v", err),
|
||||||
// fix network / 5xx / JSON parse errors and misclassifying them
|
"ensure the app has enabled the application:application:self_manage scope.")
|
||||||
// here would mislead agents into re-auth loops.
|
|
||||||
// - typed errors pass through unchanged
|
|
||||||
// - bare errors become InternalError(SubtypeSDKError) with Cause
|
|
||||||
// preserved so callers (errors.Is) can still see the underlying
|
|
||||||
// transport/parse failure.
|
|
||||||
// Genuine permission failures are surfaced from appInfo *content*,
|
|
||||||
// not from this transport-level error path.
|
|
||||||
if errs.IsTyped(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError,
|
|
||||||
"failed to get app scope info: %v", err).WithCause(err)
|
|
||||||
}
|
}
|
||||||
if opts.Format == "pretty" {
|
if opts.Format == "pretty" {
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
|
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// stubGetAppInfoErr swaps getAppInfoFn for the duration of t so authScopesRun
|
|
||||||
// observes a fixed error from the dependency. t.Cleanup restores the prior
|
|
||||||
// value so tests cannot leak through the package-level seam.
|
|
||||||
func stubGetAppInfoErr(t *testing.T, errToReturn error) {
|
|
||||||
t.Helper()
|
|
||||||
prev := getAppInfoFn
|
|
||||||
getAppInfoFn = func(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
|
||||||
return nil, errToReturn
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { getAppInfoFn = prev })
|
|
||||||
}
|
|
||||||
|
|
||||||
// scopesTestFactory builds a Factory + ScopesOptions pair sufficient to drive
|
|
||||||
// authScopesRun. Config has a non-empty AppID so we get past the config gate
|
|
||||||
// and reach the getAppInfoFn call.
|
|
||||||
func scopesTestFactory(t *testing.T) *ScopesOptions {
|
|
||||||
t.Helper()
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
AppID: "test-app",
|
|
||||||
AppSecret: "test-secret",
|
|
||||||
Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
return &ScopesOptions{
|
|
||||||
Factory: f,
|
|
||||||
Ctx: context.Background(),
|
|
||||||
Format: "json",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAuthScopesRun_NetworkErrorPassedThrough pins that a typed NetworkError
|
|
||||||
// surfaced by the dependency is not re-classified as PermissionError —
|
|
||||||
// re-auth does not fix DNS / transport failures and blanket-wrapping them
|
|
||||||
// would mislead agents into infinite re-auth loops.
|
|
||||||
func TestAuthScopesRun_NetworkErrorPassedThrough(t *testing.T) {
|
|
||||||
netErr := errs.NewNetworkError(errs.SubtypeNetworkDNS, "DNS lookup failed")
|
|
||||||
stubGetAppInfoErr(t, netErr)
|
|
||||||
|
|
||||||
err := authScopesRun(scopesTestFactory(t))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
var permErr *errs.PermissionError
|
|
||||||
if errors.As(err, &permErr) {
|
|
||||||
t.Errorf("network failure must not be classified as PermissionError; got %v", permErr)
|
|
||||||
}
|
|
||||||
var gotNet *errs.NetworkError
|
|
||||||
if !errors.As(err, &gotNet) {
|
|
||||||
t.Fatalf("network failure not preserved through authScopesRun; got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if gotNet != netErr {
|
|
||||||
t.Errorf("typed network error should pass through identity-stable; got %p, want %p", gotNet, netErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAuthScopesRun_PermissionErrorPassedThrough pins that typed permission
|
|
||||||
// failures from the dependency also pass through — IsTyped() must not single
|
|
||||||
// out one category.
|
|
||||||
func TestAuthScopesRun_PermissionErrorPassedThrough(t *testing.T) {
|
|
||||||
permErr := errs.NewPermissionError(errs.SubtypeMissingScope, "scope X missing").
|
|
||||||
WithMissingScopes("im:message")
|
|
||||||
stubGetAppInfoErr(t, permErr)
|
|
||||||
|
|
||||||
err := authScopesRun(scopesTestFactory(t))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
var got *errs.PermissionError
|
|
||||||
if !errors.As(err, &got) {
|
|
||||||
t.Fatalf("expected *PermissionError pass-through, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if got != permErr {
|
|
||||||
t.Errorf("typed permission error should pass through identity-stable; got %p, want %p", got, permErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAuthScopesRun_BareErrorWrappedAsInternal pins the unclassified branch:
|
|
||||||
// a bare error (e.g. json.Unmarshal failure inside getAppInfo) surfaces as
|
|
||||||
// *InternalError{SubtypeSDKError} with the original error preserved on
|
|
||||||
// Cause so errors.Is still walks to it.
|
|
||||||
func TestAuthScopesRun_BareErrorWrappedAsInternal(t *testing.T) {
|
|
||||||
bareErr := fmt.Errorf("failed to parse response: unexpected EOF")
|
|
||||||
stubGetAppInfoErr(t, bareErr)
|
|
||||||
|
|
||||||
err := authScopesRun(scopesTestFactory(t))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
var permErr *errs.PermissionError
|
|
||||||
if errors.As(err, &permErr) {
|
|
||||||
t.Errorf("bare getAppInfo error must not be classified as PermissionError; got %v", permErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
var intErr *errs.InternalError
|
|
||||||
if !errors.As(err, &intErr) {
|
|
||||||
t.Fatalf("expected *InternalError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if intErr.Subtype != errs.SubtypeSDKError {
|
|
||||||
t.Errorf("InternalError.Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError)
|
|
||||||
}
|
|
||||||
if !errors.Is(err, bareErr) {
|
|
||||||
t.Error("InternalError must carry bareErr via WithCause so errors.Is walks to it")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -61,6 +61,7 @@ func authStatusRun(opts *StatusOptions) error {
|
|||||||
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
||||||
result["identities"] = diagnostics
|
result["identities"] = diagnostics
|
||||||
result["identity"] = effectiveIdentity(diagnostics)
|
result["identity"] = effectiveIdentity(diagnostics)
|
||||||
|
addLegacyUserFields(result, diagnostics.User)
|
||||||
addEffectiveVerification(result, diagnostics)
|
addEffectiveVerification(result, diagnostics)
|
||||||
addStatusNote(result, diagnostics)
|
addStatusNote(result, diagnostics)
|
||||||
|
|
||||||
@@ -85,6 +86,29 @@ func effectiveIdentity(d identitydiag.Result) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
|
||||||
|
if user.OpenID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result["userName"] = user.UserName
|
||||||
|
result["userOpenId"] = user.OpenID
|
||||||
|
if user.TokenStatus != "" {
|
||||||
|
result["tokenStatus"] = user.TokenStatus
|
||||||
|
}
|
||||||
|
if user.Scope != "" {
|
||||||
|
result["scope"] = user.Scope
|
||||||
|
}
|
||||||
|
if user.ExpiresAt != "" {
|
||||||
|
result["expiresAt"] = user.ExpiresAt
|
||||||
|
}
|
||||||
|
if user.RefreshExpiresAt != "" {
|
||||||
|
result["refreshExpiresAt"] = user.RefreshExpiresAt
|
||||||
|
}
|
||||||
|
if user.GrantedAt != "" {
|
||||||
|
result["grantedAt"] = user.GrantedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
|
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
|
||||||
switch result["identity"] {
|
switch result["identity"] {
|
||||||
case identityUser:
|
case identityUser:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
cmdevent "github.com/larksuite/cli/cmd/event"
|
cmdevent "github.com/larksuite/cli/cmd/event"
|
||||||
"github.com/larksuite/cli/cmd/profile"
|
"github.com/larksuite/cli/cmd/profile"
|
||||||
"github.com/larksuite/cli/cmd/schema"
|
"github.com/larksuite/cli/cmd/schema"
|
||||||
|
"github.com/larksuite/cli/cmd/sec"
|
||||||
"github.com/larksuite/cli/cmd/service"
|
"github.com/larksuite/cli/cmd/service"
|
||||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||||
_ "github.com/larksuite/cli/events"
|
_ "github.com/larksuite/cli/events"
|
||||||
@@ -133,6 +134,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||||
|
rootCmd.AddCommand(sec.NewCmdSec(f))
|
||||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ import (
|
|||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/i18n"
|
|
||||||
"github.com/larksuite/cli/internal/keychain"
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
@@ -39,10 +37,8 @@ type BindOptions struct {
|
|||||||
// this flag because its own prompts already require human confirmation.
|
// this flag because its own prompts already require human confirmation.
|
||||||
Force bool
|
Force bool
|
||||||
|
|
||||||
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateBindFlags
|
Lang string
|
||||||
langExplicit bool // true when --lang was explicitly passed
|
langExplicit bool // true when --lang was explicitly passed
|
||||||
|
|
||||||
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
|
|
||||||
|
|
||||||
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
|
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
|
||||||
// the account being bound. Populated after resolveAccount; TUI stages
|
// the account being bound. Populated after resolveAccount; TUI stages
|
||||||
@@ -59,7 +55,7 @@ type BindOptions struct {
|
|||||||
|
|
||||||
// NewCmdConfigBind creates the config bind subcommand.
|
// NewCmdConfigBind creates the config bind subcommand.
|
||||||
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
|
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
|
||||||
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN}
|
opts := &BindOptions{Factory: f}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "bind",
|
Use: "bind",
|
||||||
@@ -106,7 +102,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
|
|||||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
|
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().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().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", "", "language preference (e.g. zh or zh_cn)")
|
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
|
||||||
cmdutil.SetRisk(cmd, "write")
|
cmdutil.SetRisk(cmd, "write")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -151,7 +147,7 @@ func configBindRun(opts *BindOptions) error {
|
|||||||
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
|
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
|
applyPreferences(appConfig, opts)
|
||||||
noticeUserDefaultRisk(opts)
|
noticeUserDefaultRisk(opts)
|
||||||
|
|
||||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
||||||
@@ -182,7 +178,7 @@ type existingBinding struct {
|
|||||||
func finalizeSource(opts *BindOptions) (string, error) {
|
func finalizeSource(opts *BindOptions) (string, error) {
|
||||||
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
|
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
|
||||||
if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" {
|
if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" {
|
||||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit).WithParam("--source")
|
return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit)
|
||||||
}
|
}
|
||||||
|
|
||||||
var detected string
|
var detected string
|
||||||
@@ -199,26 +195,23 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
|||||||
// before any interactive prompts — running inside Hermes with
|
// before any interactive prompts — running inside Hermes with
|
||||||
// --source openclaw (or vice versa) is almost always a mistake.
|
// --source openclaw (or vice versa) is almost always a mistake.
|
||||||
if explicit != "" && detected != "" && explicit != detected {
|
if explicit != "" && detected != "" && explicit != detected {
|
||||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
||||||
"--source %q does not match detected Agent environment (%s)", explicit, detected).
|
fmt.Sprintf("--source %q does not match detected Agent environment (%s)", explicit, detected),
|
||||||
WithHint("remove --source to auto-detect, or run this command in the correct Agent context").
|
"remove --source to auto-detect, or run this command in the correct Agent context")
|
||||||
WithParam("--source")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TUI: prompt for language before any downstream prompts. The source
|
// TUI: prompt for language before any downstream prompts. The source
|
||||||
// selection itself may still be skipped entirely if --source or the
|
// selection itself may still be skipped entirely if --source or the
|
||||||
// env already pinned it. Picker offers 2 options (中文 / English) and
|
// env already pinned it.
|
||||||
// drives BOTH opts.Lang (preference) and opts.UILang (TUI rendering).
|
|
||||||
if opts.IsTUI && !opts.langExplicit {
|
if opts.IsTUI && !opts.langExplicit {
|
||||||
lang, err := promptLangSelection()
|
lang, err := promptLangSelection("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == huh.ErrUserAborted {
|
if err == huh.ErrUserAborted {
|
||||||
return "", output.ErrBare(1)
|
return "", output.ErrBare(1)
|
||||||
}
|
}
|
||||||
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
|
return "", err
|
||||||
}
|
}
|
||||||
opts.Lang = string(lang)
|
opts.Lang = lang
|
||||||
opts.UILang = lang
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if explicit != "" {
|
if explicit != "" {
|
||||||
@@ -230,10 +223,9 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
|||||||
if opts.IsTUI {
|
if opts.IsTUI {
|
||||||
return tuiSelectSource(opts)
|
return tuiSelectSource(opts)
|
||||||
}
|
}
|
||||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
||||||
"cannot determine Agent source: no --source flag and no Agent environment detected").
|
"cannot determine Agent source: no --source flag and no Agent environment detected",
|
||||||
WithHint("pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context").
|
"pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context")
|
||||||
WithParam("--source")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileExistingBinding reads any existing config at configPath and decides
|
// reconcileExistingBinding reads any existing config at configPath and decides
|
||||||
@@ -253,7 +245,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
|
|||||||
return existingBinding{}, err
|
return existingBinding{}, err
|
||||||
}
|
}
|
||||||
if action == "cancel" {
|
if action == "cancel" {
|
||||||
msg := getBindMsg(opts.UILang)
|
msg := getBindMsg(opts.Lang)
|
||||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
|
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
|
||||||
return existingBinding{Cancelled: true}, nil
|
return existingBinding{Cancelled: true}, nil
|
||||||
}
|
}
|
||||||
@@ -337,10 +329,9 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
|
|||||||
if !hasStrictBotLock(previousConfigBytes) {
|
if !hasStrictBotLock(previousConfigBytes) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
msg := getBindMsg(opts.UILang)
|
msg := getBindMsg(opts.Lang)
|
||||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite,
|
return output.ErrWithHint(output.ExitValidation, "bind",
|
||||||
"config bind --force", "%s", msg.IdentityEscalationMessage).
|
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
|
||||||
WithHint("%s", msg.IdentityEscalationHint)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
|
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
|
||||||
@@ -356,23 +347,14 @@ func noticeUserDefaultRisk(opts *BindOptions) {
|
|||||||
if opts.IsTUI || opts.Identity != "user-default" {
|
if opts.IsTUI || opts.Identity != "user-default" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := getBindMsg(opts.UILang)
|
msg := getBindMsg(opts.Lang)
|
||||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
|
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyPreferences expands the chosen identity preset into the underlying
|
// applyPreferences expands the chosen identity preset into the underlying
|
||||||
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
|
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
|
||||||
// profile's intent survives later changes to global strict-mode settings.
|
// profile's intent survives later changes to global strict-mode settings.
|
||||||
// preferredLang resolves the language to persist: the requested value when set,
|
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
|
||||||
// otherwise the prior one — so an unset --lang never clears a stored preference.
|
|
||||||
func preferredLang(requested, prior i18n.Lang) i18n.Lang {
|
|
||||||
if requested != "" {
|
|
||||||
return requested
|
|
||||||
}
|
|
||||||
return prior
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) {
|
|
||||||
switch opts.Identity {
|
switch opts.Identity {
|
||||||
case "bot-only":
|
case "bot-only":
|
||||||
sm := core.StrictModeBot
|
sm := core.StrictModeBot
|
||||||
@@ -383,23 +365,9 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.L
|
|||||||
appConfig.StrictMode = &sm
|
appConfig.StrictMode = &sm
|
||||||
appConfig.DefaultAs = core.AsUser
|
appConfig.DefaultAs = core.AsUser
|
||||||
}
|
}
|
||||||
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior)
|
if opts.Lang != "" {
|
||||||
}
|
appConfig.Lang = opts.Lang
|
||||||
|
|
||||||
// priorLang returns the language preference recorded in a previous config, or
|
|
||||||
// "" if there is none / the bytes don't parse. Reads from CurrentApp (or Apps[0]
|
|
||||||
// fallback) — scanning all apps for the first non-empty Lang would leak the
|
|
||||||
// wrong profile's preference into a re-bind when the workspace holds multiple
|
|
||||||
// named profiles and the active one disagrees with Apps[0].
|
|
||||||
func priorLang(previousConfigBytes []byte) i18n.Lang {
|
|
||||||
var multi core.MultiAppConfig
|
|
||||||
if json.Unmarshal(previousConfigBytes, &multi) != nil {
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
if app := multi.CurrentAppConfig(""); app != nil {
|
|
||||||
return app.Lang
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// commitBinding finalizes the bind: atomic write of the new workspace config,
|
// commitBinding finalizes the bind: atomic write of the new workspace config,
|
||||||
@@ -411,21 +379,21 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
|||||||
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
|
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
|
||||||
|
|
||||||
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "bind",
|
||||||
|
"failed to create workspace directory: %v", err)
|
||||||
}
|
}
|
||||||
data, err := json.MarshalIndent(multi, "", " ")
|
data, err := json.MarshalIndent(multi, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "bind",
|
||||||
|
"failed to marshal config: %v", err)
|
||||||
}
|
}
|
||||||
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
|
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "bind",
|
||||||
|
"failed to write config %s: %v", configPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
replaced := previousConfigBytes != nil
|
replaced := previousConfigBytes != nil
|
||||||
// uiMsg renders human-facing TUI text (stderr success banner). Follows
|
msg := getBindMsg(opts.Lang)
|
||||||
// opts.UILang — zh by default; picker can flip it to en. --lang does
|
|
||||||
// not influence the TUI language.
|
|
||||||
uiMsg := getBindMsg(opts.UILang)
|
|
||||||
display := sourceDisplayName(source)
|
display := sourceDisplayName(source)
|
||||||
|
|
||||||
if replaced {
|
if replaced {
|
||||||
@@ -433,11 +401,7 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
|
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
|
||||||
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice)
|
fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice)
|
||||||
|
|
||||||
if opts.langExplicit && opts.Lang != "" {
|
|
||||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(uiMsg.LangPreferenceSet, opts.Lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TUI mode is a human sitting at a terminal; the BindSuccess notice on
|
// 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
|
// stderr is enough and a machine-readable JSON dump on stdout is just
|
||||||
@@ -455,17 +419,12 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
|||||||
"replaced": replaced,
|
"replaced": replaced,
|
||||||
"identity": opts.Identity,
|
"identity": opts.Identity,
|
||||||
}
|
}
|
||||||
// JSON "message" follows the effective preference on disk (appConfig.Lang),
|
brand := brandDisplay(string(appConfig.Brand), opts.Lang)
|
||||||
// not the raw --lang value: when --lang is omitted on re-bind, preferredLang
|
|
||||||
// has already inherited the prior preference into appConfig.Lang, and the
|
|
||||||
// message should respect that inherited choice. stderr above follows UILang.
|
|
||||||
prefMsg := getBindMsg(appConfig.Lang)
|
|
||||||
brand := brandDisplay(string(appConfig.Brand), appConfig.Lang)
|
|
||||||
switch opts.Identity {
|
switch opts.Identity {
|
||||||
case "bot-only":
|
case "bot-only":
|
||||||
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
|
envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand)
|
||||||
case "user-default":
|
case "user-default":
|
||||||
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
|
envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display)
|
||||||
}
|
}
|
||||||
|
|
||||||
resultJSON, _ := json.Marshal(envelope)
|
resultJSON, _ := json.Marshal(envelope)
|
||||||
@@ -502,7 +461,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core
|
|||||||
|
|
||||||
// tuiSelectSource prompts user to choose bind source.
|
// tuiSelectSource prompts user to choose bind source.
|
||||||
func tuiSelectSource(opts *BindOptions) (string, error) {
|
func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||||
msg := getBindMsg(opts.UILang)
|
msg := getBindMsg(opts.Lang)
|
||||||
var source string
|
var source string
|
||||||
|
|
||||||
// Pre-select based on detected env signals
|
// Pre-select based on detected env signals
|
||||||
@@ -527,7 +486,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
|
|||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewSelect[string]().
|
huh.NewSelect[string]().
|
||||||
Title(msg.SelectSource).
|
Title(msg.SelectSource).
|
||||||
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.UILang))).
|
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))).
|
||||||
Options(
|
Options(
|
||||||
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
|
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
|
||||||
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
|
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
|
||||||
@@ -549,7 +508,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
|
|||||||
// tuiSelectApp prompts the user to choose from multiple account candidates.
|
// tuiSelectApp prompts the user to choose from multiple account candidates.
|
||||||
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
|
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
|
||||||
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
|
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
|
||||||
msg := getBindMsg(opts.UILang)
|
msg := getBindMsg(opts.Lang)
|
||||||
options := make([]huh.Option[int], 0, len(candidates))
|
options := make([]huh.Option[int], 0, len(candidates))
|
||||||
for i, c := range candidates {
|
for i, c := range candidates {
|
||||||
label := c.AppID
|
label := c.AppID
|
||||||
@@ -563,7 +522,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
|
|||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewSelect[int]().
|
huh.NewSelect[int]().
|
||||||
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.UILang))).
|
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))).
|
||||||
Options(options...).
|
Options(options...).
|
||||||
Value(&selected),
|
Value(&selected),
|
||||||
),
|
),
|
||||||
@@ -580,7 +539,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
|
|||||||
|
|
||||||
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
|
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
|
||||||
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
|
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
|
||||||
msg := getBindMsg(opts.UILang)
|
msg := getBindMsg(opts.Lang)
|
||||||
|
|
||||||
// Build existing binding summary
|
// Build existing binding summary
|
||||||
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
|
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
|
||||||
@@ -629,14 +588,9 @@ func validateBindFlags(opts *BindOptions) error {
|
|||||||
switch opts.Identity {
|
switch opts.Identity {
|
||||||
case "bot-only", "user-default":
|
case "bot-only", "user-default":
|
||||||
default:
|
default:
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity")
|
return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lang, err := cmdutil.ParseLangFlag(opts.Lang)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
opts.Lang = string(lang)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,8 +606,8 @@ func validateBindFlags(opts *BindOptions) error {
|
|||||||
// DescriptionFunc approach breaks here because a longer description on
|
// DescriptionFunc approach breaks here because a longer description on
|
||||||
// hover pushes options out of the field's initial viewport.
|
// hover pushes options out of the field's initial viewport.
|
||||||
func tuiSelectIdentity(opts *BindOptions) (string, error) {
|
func tuiSelectIdentity(opts *BindOptions) (string, error) {
|
||||||
msg := getBindMsg(opts.UILang)
|
msg := getBindMsg(opts.Lang)
|
||||||
brand := brandDisplay(opts.Brand, opts.UILang)
|
brand := brandDisplay(opts.Brand, opts.Lang)
|
||||||
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
|
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
|
||||||
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
|
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
|
||||||
var value string
|
var value string
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
|
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import "github.com/larksuite/cli/internal/i18n"
|
|
||||||
|
|
||||||
// bindMsg holds all TUI text for config bind, supporting zh/en via --lang.
|
// 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
|
// Brand-aware strings use a %s slot where the UI-friendly product name
|
||||||
@@ -86,11 +84,6 @@ type bindMsg struct {
|
|||||||
// require in-flow human confirmation.
|
// require in-flow human confirmation.
|
||||||
IdentityEscalationMessage string
|
IdentityEscalationMessage string
|
||||||
IdentityEscalationHint string
|
IdentityEscalationHint string
|
||||||
|
|
||||||
// LangPreferenceSet is printed to stderr after a successful bind when the
|
|
||||||
// user explicitly passed --lang. Format: language code. Not printed when
|
|
||||||
// --lang was not explicit (i.e., the cobra default zh stayed in effect).
|
|
||||||
LangPreferenceSet string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var bindMsgZh = &bindMsg{
|
var bindMsgZh = &bindMsg{
|
||||||
@@ -123,8 +116,6 @@ var bindMsgZh = &bindMsg{
|
|||||||
|
|
||||||
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
|
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
|
||||||
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
|
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
|
||||||
|
|
||||||
LangPreferenceSet: "语言偏好已设置:%s",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var bindMsgEn = &bindMsg{
|
var bindMsgEn = &bindMsg{
|
||||||
@@ -159,13 +150,10 @@ var bindMsgEn = &bindMsg{
|
|||||||
|
|
||||||
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.",
|
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`",
|
IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`",
|
||||||
|
|
||||||
LangPreferenceSet: "Language preference set to: %s",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBindMsg picks the zh/en TUI bundle; non-English falls back to zh.
|
func getBindMsg(lang string) *bindMsg {
|
||||||
func getBindMsg(lang i18n.Lang) *bindMsg {
|
if lang == "en" {
|
||||||
if lang.IsEnglish() {
|
|
||||||
return bindMsgEn
|
return bindMsgEn
|
||||||
}
|
}
|
||||||
return bindMsgZh
|
return bindMsgZh
|
||||||
@@ -176,11 +164,11 @@ func getBindMsg(lang i18n.Lang) *bindMsg {
|
|||||||
// "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in 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
|
// this is the safe default when the brand hasn't been resolved yet (for
|
||||||
// example, on the pre-binding source-selection screen).
|
// example, on the pre-binding source-selection screen).
|
||||||
func brandDisplay(brand string, lang i18n.Lang) string {
|
func brandDisplay(brand, lang string) string {
|
||||||
if brand == "lark" || brand == "Lark" || brand == "LARK" {
|
if brand == "lark" || brand == "Lark" || brand == "LARK" {
|
||||||
return "Lark"
|
return "Lark"
|
||||||
}
|
}
|
||||||
if lang.IsEnglish() {
|
if lang == "en" {
|
||||||
return "Feishu"
|
return "Feishu"
|
||||||
}
|
}
|
||||||
return "飞书"
|
return "飞书"
|
||||||
|
|||||||
@@ -13,59 +13,30 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/i18n"
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
// assertExitError checks the full structured error in one assertion. It
|
// assertExitError checks the full structured error in one assertion.
|
||||||
// accepts both *output.ExitError (used by output.ErrWithHint) and the
|
|
||||||
// typed errors (ValidationError, ConfigError) — they normalize to the same
|
|
||||||
// wantDetail fields. The wantDetail.Type is matched against the typed error's
|
|
||||||
// Category string ("validation", "config", etc.).
|
|
||||||
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
|
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
var exitErr *output.ExitError
|
||||||
if errors.As(err, &exitErr) {
|
if !errors.As(err, &exitErr) {
|
||||||
if exitErr.Code != wantCode {
|
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
|
||||||
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
|
|
||||||
}
|
|
||||||
if exitErr.Detail == nil {
|
|
||||||
t.Fatal("expected non-nil error detail")
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
|
|
||||||
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
var ve *errs.ValidationError
|
if exitErr.Code != wantCode {
|
||||||
if errors.As(err, &ve) {
|
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
|
||||||
if got := output.ExitCodeOf(err); got != wantCode {
|
|
||||||
t.Errorf("exit code = %d, want %d", got, wantCode)
|
|
||||||
}
|
|
||||||
gotDetail := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
|
||||||
if !reflect.DeepEqual(gotDetail, wantDetail) {
|
|
||||||
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
var ce *errs.ConfigError
|
if exitErr.Detail == nil {
|
||||||
if errors.As(err, &ce) {
|
t.Fatal("expected non-nil error detail")
|
||||||
if got := output.ExitCodeOf(err); got != wantCode {
|
}
|
||||||
t.Errorf("exit code = %d, want %d", got, wantCode)
|
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
|
||||||
}
|
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
|
||||||
gotDetail := output.ErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
|
|
||||||
if !reflect.DeepEqual(gotDetail, wantDetail) {
|
|
||||||
t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertEnvelope decodes stdout and checks it matches want exactly — every key
|
// assertEnvelope decodes stdout and checks it matches want exactly — every key
|
||||||
@@ -134,229 +105,14 @@ func TestConfigBindCmd_LangDefault(t *testing.T) {
|
|||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if gotOpts.Lang != "" {
|
if gotOpts.Lang != "zh" {
|
||||||
t.Errorf("Lang = %q, want default %q (unset)", gotOpts.Lang, "")
|
t.Errorf("Lang = %q, want default %q", gotOpts.Lang, "zh")
|
||||||
}
|
}
|
||||||
if gotOpts.langExplicit {
|
if gotOpts.langExplicit {
|
||||||
t.Error("expected langExplicit=false when --lang not passed")
|
t.Error("expected langExplicit=false when --lang not passed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConfigBindRun_InvalidLang verifies a non-empty --lang is strictly
|
|
||||||
// validated: wrong case, typos, and removed codes all exit with
|
|
||||||
// ExitValidation (code 2) and a message identifying the offending value.
|
|
||||||
// (Empty is not invalid — see TestConfigBindRun_EmptyLangIsNoOp.)
|
|
||||||
func TestConfigBindRun_InvalidLang(t *testing.T) {
|
|
||||||
saveWorkspace(t)
|
|
||||||
configDir := t.TempDir()
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
||||||
hermesHome := t.TempDir()
|
|
||||||
t.Setenv("HERMES_HOME", hermesHome)
|
|
||||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
|
||||||
t.Fatalf("write .env: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
lang string
|
|
||||||
}{
|
|
||||||
{"wrong case ZH", "ZH"},
|
|
||||||
{"typo frr", "frr"},
|
|
||||||
{"removed code ar", "ar"},
|
|
||||||
{"unknown xx", "xx"},
|
|
||||||
{"hyphen form zh-CN", "zh-CN"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
err := configBindRun(&BindOptions{
|
|
||||||
Factory: f,
|
|
||||||
Source: "hermes",
|
|
||||||
Lang: tc.lang,
|
|
||||||
langExplicit: true,
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
|
||||||
}
|
|
||||||
exitErr, ok := err.(*output.ExitError)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if exitErr.Code != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
|
|
||||||
}
|
|
||||||
if !strings.Contains(exitErr.Error(), "invalid --lang") {
|
|
||||||
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigBindRun_EmptyLangIsNoOp verifies that an empty --lang (omitted or
|
|
||||||
// explicit "") is unset: it neither errors nor persists a language, while a
|
|
||||||
// non-empty short code or Feishu locale both canonicalize to the same locale.
|
|
||||||
func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
lang string
|
|
||||||
explicit bool
|
|
||||||
wantLang i18n.Lang
|
|
||||||
}{
|
|
||||||
{"omitted", "", false, ""},
|
|
||||||
{"explicit empty", "", true, ""},
|
|
||||||
{"short code", "ja", true, i18n.LangJaJP},
|
|
||||||
{"feishu locale", "ja_jp", true, i18n.LangJaJP},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
saveWorkspace(t)
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
hermesHome := t.TempDir()
|
|
||||||
t.Setenv("HERMES_HOME", hermesHome)
|
|
||||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
|
||||||
t.Fatalf("write .env: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
if err := configBindRun(&BindOptions{
|
|
||||||
Factory: f,
|
|
||||||
Source: "hermes",
|
|
||||||
Lang: tc.lang,
|
|
||||||
langExplicit: tc.explicit,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
multi, err := core.LoadMultiAppConfig()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
|
||||||
}
|
|
||||||
app := multi.CurrentAppConfig("")
|
|
||||||
if app == nil {
|
|
||||||
t.Fatal("no app persisted")
|
|
||||||
}
|
|
||||||
if app.Lang != tc.wantLang {
|
|
||||||
t.Errorf("persisted Lang = %q, want %q", app.Lang, tc.wantLang)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigBindRun_OmitLangPreservesPrior guards against a re-bind without
|
|
||||||
// --lang silently dropping a previously stored preference (appConfig is rebuilt
|
|
||||||
// fresh, so commitBinding must inherit the prior Lang).
|
|
||||||
func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
|
|
||||||
saveWorkspace(t)
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
hermesHome := t.TempDir()
|
|
||||||
t.Setenv("HERMES_HOME", hermesHome)
|
|
||||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
|
||||||
t.Fatalf("write .env: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f1, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "ja", langExplicit: true}); err != nil {
|
|
||||||
t.Fatalf("first bind (--lang ja): %v", err)
|
|
||||||
}
|
|
||||||
f2, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
|
|
||||||
t.Fatalf("re-bind (no --lang): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
multi, err := core.LoadMultiAppConfig()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
|
||||||
}
|
|
||||||
if app := multi.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
|
|
||||||
t.Errorf("Lang after re-bind = %v, want %q (preserved)", app, i18n.LangJaJP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPriorLang_RespectsCurrentApp guards against priorLang scanning all apps
|
|
||||||
// and silently returning a non-current profile's Lang. In a multi-profile
|
|
||||||
// workspace (set up via `profile add` before a re-bind), the active profile's
|
|
||||||
// Lang must win over a sibling profile that happens to sit earlier in the slice.
|
|
||||||
func TestPriorLang_RespectsCurrentApp(t *testing.T) {
|
|
||||||
multi := core.MultiAppConfig{
|
|
||||||
CurrentApp: "active",
|
|
||||||
Apps: []core.AppConfig{
|
|
||||||
{Name: "stale", AppId: "cli_stale", Lang: i18n.LangJaJP},
|
|
||||||
{Name: "active", AppId: "cli_active", Lang: i18n.LangEnUS},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bytes, err := json.Marshal(multi)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal: %v", err)
|
|
||||||
}
|
|
||||||
if got := priorLang(bytes); got != i18n.LangEnUS {
|
|
||||||
t.Errorf("priorLang = %q, want %q (must follow CurrentApp, not Apps[0])", got, i18n.LangEnUS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPriorLang_FallsBackToFirstAppWhenCurrentUnset covers the legacy
|
|
||||||
// single-app shape (no CurrentApp): CurrentAppConfig falls back to Apps[0],
|
|
||||||
// so a bind-written config (which always has exactly one app and no
|
|
||||||
// CurrentApp field) still inherits its Lang.
|
|
||||||
func TestPriorLang_FallsBackToFirstAppWhenCurrentUnset(t *testing.T) {
|
|
||||||
multi := core.MultiAppConfig{
|
|
||||||
Apps: []core.AppConfig{
|
|
||||||
{AppId: "cli_only", Lang: i18n.LangJaJP},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
bytes, err := json.Marshal(multi)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal: %v", err)
|
|
||||||
}
|
|
||||||
if got := priorLang(bytes); got != i18n.LangJaJP {
|
|
||||||
t.Errorf("priorLang = %q, want %q", got, i18n.LangJaJP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPriorLang_MalformedReturnsEmpty exercises the unparseable-bytes branch.
|
|
||||||
func TestPriorLang_MalformedReturnsEmpty(t *testing.T) {
|
|
||||||
if got := priorLang([]byte("not json")); got != "" {
|
|
||||||
t.Errorf("priorLang(malformed) = %q, want \"\"", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigBindRun_EnvelopeMessageFollowsInheritedLang guards the JSON envelope
|
|
||||||
// "message" field against regressing to opts.Lang: when --lang is omitted on
|
|
||||||
// re-bind, the inherited preference (appConfig.Lang) must drive the message
|
|
||||||
// language and the embedded brand display — otherwise an AI agent that set
|
|
||||||
// English on first bind sees Chinese in every subsequent re-bind envelope.
|
|
||||||
func TestConfigBindRun_EnvelopeMessageFollowsInheritedLang(t *testing.T) {
|
|
||||||
saveWorkspace(t)
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
hermesHome := t.TempDir()
|
|
||||||
t.Setenv("HERMES_HOME", hermesHome)
|
|
||||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
|
||||||
t.Fatalf("write .env: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f1, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "en", langExplicit: true}); err != nil {
|
|
||||||
t.Fatalf("first bind (--lang en): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f2, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
|
|
||||||
t.Fatalf("re-bind (no --lang): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
envelope := map[string]any{}
|
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
||||||
t.Fatalf("invalid JSON output: %v", err)
|
|
||||||
}
|
|
||||||
msg, _ := envelope["message"].(string)
|
|
||||||
enMsg := getBindMsg(i18n.LangEnUS)
|
|
||||||
wantMsg := fmt.Sprintf(enMsg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", i18n.LangEnUS))
|
|
||||||
if msg != wantMsg {
|
|
||||||
t.Errorf("envelope.message = %q,\nwant %q (must follow inherited appConfig.Lang=en_us, not raw opts.Lang)", msg, wantMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Run function tests (aligned with TestConfigShowRun pattern) ──
|
// ── Run function tests (aligned with TestConfigShowRun pattern) ──
|
||||||
|
|
||||||
func TestConfigBindRun_InvalidSource(t *testing.T) {
|
func TestConfigBindRun_InvalidSource(t *testing.T) {
|
||||||
@@ -383,7 +139,7 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
|
|||||||
// TestFactory has IsTerminal=false by default
|
// TestFactory has IsTerminal=false by default
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: ""})
|
err := configBindRun(&BindOptions{Factory: f, Source: ""})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "validation",
|
Type: "bind",
|
||||||
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
|
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
|
||||||
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
|
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
|
||||||
})
|
})
|
||||||
@@ -422,7 +178,7 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "validation",
|
Type: "bind",
|
||||||
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
|
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
|
||||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||||
})
|
})
|
||||||
@@ -438,7 +194,7 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "validation",
|
Type: "bind",
|
||||||
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
|
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
|
||||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||||
})
|
})
|
||||||
@@ -566,8 +322,8 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||||
envPath := filepath.Join(hermesHome, ".env")
|
envPath := filepath.Join(hermesHome, ".env")
|
||||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "config",
|
Type: "hermes",
|
||||||
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
|
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
|
||||||
Hint: "verify Hermes is installed and configured at " + envPath,
|
Hint: "verify Hermes is installed and configured at " + envPath,
|
||||||
})
|
})
|
||||||
@@ -584,8 +340,8 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
|
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
|
||||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "config",
|
Type: "openclaw",
|
||||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||||
Hint: "verify OpenClaw is installed and configured",
|
Hint: "verify OpenClaw is installed and configured",
|
||||||
})
|
})
|
||||||
@@ -732,7 +488,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "validation",
|
Type: "bind",
|
||||||
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
|
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
|
||||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||||
})
|
})
|
||||||
@@ -750,8 +506,8 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||||
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
|
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
|
||||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "config",
|
Type: "lark-channel",
|
||||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||||
Hint: "verify lark-channel-bridge is installed and configured",
|
Hint: "verify lark-channel-bridge is installed and configured",
|
||||||
})
|
})
|
||||||
@@ -770,8 +526,8 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "config",
|
Type: "lark-channel",
|
||||||
Message: "accounts.app.id missing in " + configPath,
|
Message: "accounts.app.id missing in " + configPath,
|
||||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||||
})
|
})
|
||||||
@@ -789,8 +545,8 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "config",
|
Type: "lark-channel",
|
||||||
Message: "accounts.app.secret is empty in " + configPath,
|
Message: "accounts.app.secret is empty in " + configPath,
|
||||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||||
})
|
})
|
||||||
@@ -839,10 +595,8 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
|||||||
if !errors.As(err, &cfgErr) {
|
if !errors.As(err, &cfgErr) {
|
||||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||||
}
|
}
|
||||||
// Config errors share ExitAuth (3); the workspace is detected but no
|
if cfgErr.Code != output.ExitValidation {
|
||||||
// binding exists yet, which is a config error.
|
t.Errorf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||||
if cfgErr.Code != output.ExitAuth {
|
|
||||||
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
|
||||||
}
|
}
|
||||||
if cfgErr.Type != "openclaw" {
|
if cfgErr.Type != "openclaw" {
|
||||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||||
@@ -1141,8 +895,12 @@ func TestConfigBindRun_OpenClawMultiAccount_MissingAppID(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for multi-account without --app-id, got nil")
|
t.Fatal("expected error for multi-account without --app-id, got nil")
|
||||||
}
|
}
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
var exitErr *output.ExitError
|
||||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||||
|
}
|
||||||
|
if exitErr.Code != output.ExitValidation {
|
||||||
|
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1188,7 +946,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
|||||||
// each accepted variant so every ErrDetail field (Type, Code, Message,
|
// each accepted variant so every ErrDetail field (Type, Code, Message,
|
||||||
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
|
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
|
||||||
base := output.ErrDetail{
|
base := output.ErrDetail{
|
||||||
Type: "validation",
|
Type: "openclaw",
|
||||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||||
}
|
}
|
||||||
wantWorkFirst := base
|
wantWorkFirst := base
|
||||||
@@ -1196,17 +954,20 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
|||||||
wantPersonalFirst := base
|
wantPersonalFirst := base
|
||||||
wantPersonalFirst.Hint = "available app IDs:\n cli_personal_222 (personal)\n cli_work_111 (work)"
|
wantPersonalFirst.Hint = "available app IDs:\n cli_personal_222 (personal)\n cli_work_111 (work)"
|
||||||
|
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
var exitErr *output.ExitError
|
||||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("error type = %T, want *output.ExitError; err = %v", err, err)
|
||||||
}
|
}
|
||||||
var ve *errs.ValidationError
|
if exitErr.Code != output.ExitValidation {
|
||||||
if !errors.As(err, &ve) {
|
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||||
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
|
|
||||||
}
|
}
|
||||||
got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
if exitErr.Detail == nil {
|
||||||
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
|
t.Fatal("expected non-nil error detail")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(*exitErr.Detail, wantWorkFirst) &&
|
||||||
|
!reflect.DeepEqual(*exitErr.Detail, wantPersonalFirst) {
|
||||||
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
|
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
|
||||||
got, wantWorkFirst, wantPersonalFirst)
|
*exitErr.Detail, wantWorkFirst, wantPersonalFirst)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1231,7 +992,7 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "validation",
|
Type: "openclaw",
|
||||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||||
Hint: "available app IDs:\n cli_only_one",
|
Hint: "available app IDs:\n cli_only_one",
|
||||||
})
|
})
|
||||||
@@ -1363,19 +1124,11 @@ func TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce(t *testing.T) {
|
|||||||
Identity: "user-default",
|
Identity: "user-default",
|
||||||
})
|
})
|
||||||
msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default
|
msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default
|
||||||
var ce *errs.ConfirmationRequiredError
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
if !errors.As(err, &ce) {
|
Type: "bind",
|
||||||
t.Fatalf("error type = %T, want *errs.ConfirmationRequiredError; error = %v", err, err)
|
Message: msg.IdentityEscalationMessage,
|
||||||
}
|
Hint: msg.IdentityEscalationHint,
|
||||||
if ce.Risk != errs.RiskHighRiskWrite {
|
})
|
||||||
t.Errorf("Risk = %q, want %q", ce.Risk, errs.RiskHighRiskWrite)
|
|
||||||
}
|
|
||||||
if ce.Message != msg.IdentityEscalationMessage {
|
|
||||||
t.Errorf("Message mismatch:\ngot: %q\nwant: %q", ce.Message, msg.IdentityEscalationMessage)
|
|
||||||
}
|
|
||||||
if ce.Hint != msg.IdentityEscalationHint {
|
|
||||||
t.Errorf("Hint mismatch:\ngot: %q\nwant: %q", ce.Hint, msg.IdentityEscalationHint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config on disk must remain untouched — the gate runs before
|
// Config on disk must remain untouched — the gate runs before
|
||||||
// commitBinding writes anything.
|
// commitBinding writes anything.
|
||||||
@@ -1536,8 +1289,8 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||||
envPath := filepath.Join(hermesHome, ".env")
|
envPath := filepath.Join(hermesHome, ".env")
|
||||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "config",
|
Type: "hermes",
|
||||||
Message: "FEISHU_APP_ID not found in " + envPath,
|
Message: "FEISHU_APP_ID not found in " + envPath,
|
||||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||||
})
|
})
|
||||||
@@ -1556,8 +1309,8 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||||
envPath := filepath.Join(hermesHome, ".env")
|
envPath := filepath.Join(hermesHome, ".env")
|
||||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "config",
|
Type: "hermes",
|
||||||
Message: "FEISHU_APP_SECRET not found in " + envPath,
|
Message: "FEISHU_APP_SECRET not found in " + envPath,
|
||||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||||
})
|
})
|
||||||
@@ -1582,8 +1335,8 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "config",
|
Type: "openclaw",
|
||||||
Message: "openclaw.json missing channels.feishu section",
|
Message: "openclaw.json missing channels.feishu section",
|
||||||
Hint: "configure Feishu in OpenClaw first",
|
Hint: "configure Feishu in OpenClaw first",
|
||||||
})
|
})
|
||||||
@@ -1610,8 +1363,8 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
|
|||||||
openclawPath := filepath.Join(openclawDir, "openclaw.json")
|
openclawPath := filepath.Join(openclawDir, "openclaw.json")
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "config",
|
Type: "openclaw",
|
||||||
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
|
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
|
||||||
Hint: "configure channels.feishu.appSecret in openclaw.json",
|
Hint: "configure channels.feishu.appSecret in openclaw.json",
|
||||||
})
|
})
|
||||||
@@ -1672,8 +1425,8 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "config",
|
Type: "openclaw",
|
||||||
Message: "no Feishu app configured in openclaw.json",
|
Message: "no Feishu app configured in openclaw.json",
|
||||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||||
})
|
})
|
||||||
@@ -1704,14 +1457,10 @@ func TestGetBindMsg_En(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetBindMsg_NonEnLang_FallsBackToZh(t *testing.T) {
|
func TestGetBindMsg_UnknownLang_DefaultsToZh(t *testing.T) {
|
||||||
// Only zh and en TUI bundles exist; any non-English language (canonical
|
msg := getBindMsg("fr")
|
||||||
// locale, short code, or unrecognized value) falls back to zh.
|
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
|
||||||
for _, lang := range []i18n.Lang{"fr_fr", "ja_jp", "ko", "unknown", ""} {
|
t.Errorf("fr (default) SelectSource = %q, want %q", msg.SelectSource, want)
|
||||||
msg := getBindMsg(lang)
|
|
||||||
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
|
|
||||||
t.Errorf("getBindMsg(%q) SelectSource = %q, want %q (zh fallback)", lang, msg.SelectSource, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1874,36 +1623,3 @@ func TestHasStrictBotLock(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConfigBindRun_LangExplicit_PrintsConfirmation covers the flag-mode
|
|
||||||
// confirmation line: when --lang is explicit, bind prints "language preference
|
|
||||||
// set" to stderr (rendered in the TUI language, embedding the preference value).
|
|
||||||
func TestConfigBindRun_LangExplicit_PrintsConfirmation(t *testing.T) {
|
|
||||||
saveWorkspace(t)
|
|
||||||
configDir := t.TempDir()
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
||||||
|
|
||||||
hermesHome := t.TempDir()
|
|
||||||
t.Setenv("HERMES_HOME", hermesHome)
|
|
||||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
|
|
||||||
t.Fatalf("write .env: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
err := configBindRun(&BindOptions{
|
|
||||||
Factory: f,
|
|
||||||
Source: "hermes",
|
|
||||||
Identity: "bot-only",
|
|
||||||
Lang: "en",
|
|
||||||
langExplicit: true,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected success, got error: %v", err)
|
|
||||||
}
|
|
||||||
// The short --lang en is canonicalized to en_us before the confirmation
|
|
||||||
// echoes it back; the TUI language stays zh (flag mode, no picker).
|
|
||||||
want := fmt.Sprintf(getBindMsg(i18n.LangZhCN).LangPreferenceSet, "en_us")
|
|
||||||
if got := stderr.String(); !strings.Contains(got, want) {
|
|
||||||
t.Errorf("stderr = %q, want it to contain confirmation %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/binding"
|
"github.com/larksuite/cli/internal/binding"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ func newBinder(source string, opts *BindOptions) (SourceBinder, error) {
|
|||||||
case "lark-channel":
|
case "lark-channel":
|
||||||
return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil
|
return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil
|
||||||
default:
|
default:
|
||||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported source: %s", source).WithParam("--source")
|
return nil, output.ErrValidation("unsupported source: %s", source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,10 +85,11 @@ func selectCandidate(
|
|||||||
// from ListCandidates itself and never reach here.
|
// from ListCandidates itself and never reach here.
|
||||||
switch src {
|
switch src {
|
||||||
case "openclaw":
|
case "openclaw":
|
||||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "no Feishu app configured in openclaw.json").
|
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||||
WithHint("configure channels.feishu.appId in openclaw.json")
|
"no Feishu app configured in openclaw.json",
|
||||||
|
"configure channels.feishu.appId in openclaw.json")
|
||||||
default:
|
default:
|
||||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "%s: no app configured", src)
|
return nil, output.ErrValidation("%s: no app configured", src)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,9 +99,9 @@ func selectCandidate(
|
|||||||
return &candidates[i], nil
|
return &candidates[i], nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id %q not found in %s", appIDFlag, cfgBase).
|
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||||
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
|
fmt.Sprintf("--app-id %q not found in %s", appIDFlag, cfgBase),
|
||||||
WithParam("--app-id")
|
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(candidates) == 1 {
|
if len(candidates) == 1 {
|
||||||
@@ -111,9 +112,9 @@ func selectCandidate(
|
|||||||
return tuiPrompt(candidates)
|
return tuiPrompt(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "multiple accounts in %s; pass --app-id <id>", cfgBase).
|
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||||
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
|
fmt.Sprintf("multiple accounts in %s; pass --app-id <id>", cfgBase),
|
||||||
WithParam("--app-id")
|
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatCandidates renders candidates as "AppID (Label)" lines for error hints.
|
// formatCandidates renders candidates as "AppID (Label)" lines for error hints.
|
||||||
@@ -148,13 +149,14 @@ func (b *openclawBinder) ConfigPath() string { return b.path }
|
|||||||
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||||
cfg, err := binding.ReadOpenClawConfig(b.path)
|
cfg, err := binding.ReadOpenClawConfig(b.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
|
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||||
WithHint("verify OpenClaw is installed and configured").
|
fmt.Sprintf("cannot read %s: %v", b.path, err),
|
||||||
WithCause(err)
|
"verify OpenClaw is installed and configured")
|
||||||
}
|
}
|
||||||
if cfg.Channels.Feishu == nil {
|
if cfg.Channels.Feishu == nil {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "openclaw.json missing channels.feishu section").
|
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||||
WithHint("configure Feishu in OpenClaw first")
|
"openclaw.json missing channels.feishu section",
|
||||||
|
"configure Feishu in OpenClaw first")
|
||||||
}
|
}
|
||||||
|
|
||||||
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
|
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
|
||||||
@@ -170,7 +172,8 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
|||||||
|
|
||||||
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||||
if b.cfg == nil {
|
if b.cfg == nil {
|
||||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||||
|
"internal: Build called before ListCandidates")
|
||||||
}
|
}
|
||||||
|
|
||||||
var selected *binding.CandidateApp
|
var selected *binding.CandidateApp
|
||||||
@@ -181,25 +184,26 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if selected == nil {
|
if selected == nil {
|
||||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID)
|
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||||
|
"internal: appID %q not in candidates", appID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if selected.AppSecret.IsZero() {
|
if selected.AppSecret.IsZero() {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path).
|
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||||
WithHint("configure channels.feishu.appSecret in openclaw.json")
|
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)
|
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err).
|
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||||
WithHint("check appSecret configuration in %s", b.path).
|
fmt.Sprintf("failed to resolve appSecret for %s: %v", selected.AppID, err),
|
||||||
WithCause(err)
|
fmt.Sprintf("check appSecret configuration in %s", b.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||||
WithHint("use file: reference in config to bypass keychain").
|
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
|
||||||
WithCause(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &core.AppConfig{
|
return &core.AppConfig{
|
||||||
@@ -225,14 +229,15 @@ func (b *hermesBinder) ConfigPath() string { return b.path }
|
|||||||
func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
||||||
envMap, err := readDotenv(b.path)
|
envMap, err := readDotenv(b.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to read Hermes config: %v", err).
|
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||||
WithHint("verify Hermes is installed and configured at %s", b.path).
|
fmt.Sprintf("failed to read Hermes config: %v", err),
|
||||||
WithCause(err)
|
fmt.Sprintf("verify Hermes is installed and configured at %s", b.path))
|
||||||
}
|
}
|
||||||
appID := envMap["FEISHU_APP_ID"]
|
appID := envMap["FEISHU_APP_ID"]
|
||||||
if appID == "" {
|
if appID == "" {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "FEISHU_APP_ID not found in %s", b.path).
|
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||||
WithHint("run 'hermes setup' to configure Feishu credentials")
|
fmt.Sprintf("FEISHU_APP_ID not found in %s", b.path),
|
||||||
|
"run 'hermes setup' to configure Feishu credentials")
|
||||||
}
|
}
|
||||||
b.envMap = envMap
|
b.envMap = envMap
|
||||||
return []Candidate{{AppID: appID, Label: "default"}}, nil
|
return []Candidate{{AppID: appID, Label: "default"}}, nil
|
||||||
@@ -240,22 +245,24 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
|||||||
|
|
||||||
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||||
if b.envMap == nil {
|
if b.envMap == nil {
|
||||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||||
|
"internal: Build called before ListCandidates")
|
||||||
}
|
}
|
||||||
if b.envMap["FEISHU_APP_ID"] != appID {
|
if b.envMap["FEISHU_APP_ID"] != appID {
|
||||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match env", appID)
|
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||||
|
"internal: appID %q does not match env", appID)
|
||||||
}
|
}
|
||||||
appSecret := b.envMap["FEISHU_APP_SECRET"]
|
appSecret := b.envMap["FEISHU_APP_SECRET"]
|
||||||
if appSecret == "" {
|
if appSecret == "" {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "FEISHU_APP_SECRET not found in %s", b.path).
|
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||||
WithHint("run 'hermes setup' to configure Feishu credentials")
|
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)
|
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||||
WithHint("use file: reference in config to bypass keychain").
|
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
|
||||||
WithCause(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &core.AppConfig{
|
return &core.AppConfig{
|
||||||
@@ -283,13 +290,14 @@ func (b *larkChannelBinder) ConfigPath() string { return b.path }
|
|||||||
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
||||||
cfg, err := binding.ReadLarkChannelConfig(b.path)
|
cfg, err := binding.ReadLarkChannelConfig(b.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
|
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||||
WithHint("verify lark-channel-bridge is installed and configured").
|
fmt.Sprintf("cannot read %s: %v", b.path, err),
|
||||||
WithCause(err)
|
"verify lark-channel-bridge is installed and configured")
|
||||||
}
|
}
|
||||||
if cfg.Accounts.App.ID == "" {
|
if cfg.Accounts.App.ID == "" {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "accounts.app.id missing in %s", b.path).
|
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||||
WithHint("run lark-channel-bridge's setup to populate the app credential")
|
fmt.Sprintf("accounts.app.id missing in %s", b.path),
|
||||||
|
"run lark-channel-bridge's setup to populate the app credential")
|
||||||
}
|
}
|
||||||
b.cfg = cfg
|
b.cfg = cfg
|
||||||
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
|
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
|
||||||
@@ -297,30 +305,32 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
|||||||
|
|
||||||
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||||
if b.cfg == nil {
|
if b.cfg == nil {
|
||||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||||
|
"internal: Build called before ListCandidates")
|
||||||
}
|
}
|
||||||
if b.cfg.Accounts.App.ID != appID {
|
if b.cfg.Accounts.App.ID != appID {
|
||||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match config", appID)
|
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||||
|
"internal: appID %q does not match config", appID)
|
||||||
}
|
}
|
||||||
if b.cfg.Accounts.App.Secret.IsZero() {
|
if b.cfg.Accounts.App.Secret.IsZero() {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "accounts.app.secret is empty in %s", b.path).
|
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||||
WithHint("run lark-channel-bridge's setup to populate the app credential")
|
fmt.Sprintf("accounts.app.secret is empty in %s", b.path),
|
||||||
|
"run lark-channel-bridge's setup to populate the app credential")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve through the same SecretInput pipeline openclaw uses, so
|
// Resolve through the same SecretInput pipeline openclaw uses, so
|
||||||
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
|
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
|
||||||
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
|
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err).
|
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||||
WithHint("check appSecret configuration in %s", b.path).
|
fmt.Sprintf("failed to resolve appSecret for %s: %v", appID, err),
|
||||||
WithCause(err)
|
fmt.Sprintf("check appSecret configuration in %s", b.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||||
WithHint("use file: reference in config to bypass keychain").
|
"keychain unavailable: %v", err)
|
||||||
WithCause(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &core.AppConfig{
|
return &core.AppConfig{
|
||||||
@@ -379,12 +389,10 @@ func resolveHermesEnvPath() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
|
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
|
||||||
// source config. LARK_CHANNEL_CONFIG lets a host point bind at a projected
|
// config.json. Mirrors the bridge's src/config/paths.ts which hardcodes
|
||||||
// single-account config without changing lark-cli's target config directory.
|
// ~/.lark-channel/config.json with no env override — multi-instance is not
|
||||||
|
// a supported scenario today.
|
||||||
func resolveLarkChannelConfigPath() string {
|
func resolveLarkChannelConfigPath() string {
|
||||||
if p := os.Getenv("LARK_CHANNEL_CONFIG"); strings.TrimSpace(p) != "" {
|
|
||||||
return expandHome(p)
|
|
||||||
}
|
|
||||||
home, err := vfs.UserHomeDir()
|
home, err := vfs.UserHomeDir()
|
||||||
if err != nil || home == "" {
|
if err != nil || home == "" {
|
||||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -51,8 +50,8 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
|
|||||||
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
|
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
|
||||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||||
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "config",
|
Type: "openclaw",
|
||||||
Message: "no Feishu app configured in openclaw.json",
|
Message: "no Feishu app configured in openclaw.json",
|
||||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||||
})
|
})
|
||||||
@@ -64,8 +63,8 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
|
|||||||
// even before it has a bespoke error message.
|
// even before it has a bespoke error message.
|
||||||
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
|
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
|
||||||
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "config",
|
Type: "validation",
|
||||||
Message: "hermes: no app configured",
|
Message: "hermes: no app configured",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -101,7 +100,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "validation",
|
Type: "openclaw",
|
||||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
||||||
})
|
})
|
||||||
@@ -118,7 +117,7 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "validation",
|
Type: "openclaw",
|
||||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
||||||
})
|
})
|
||||||
@@ -153,7 +152,7 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
|
|||||||
candidates := []Candidate{{AppID: "cli_only"}}
|
candidates := []Candidate{{AppID: "cli_only"}}
|
||||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "validation",
|
Type: "openclaw",
|
||||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||||
Hint: "available app IDs:\n cli_only",
|
Hint: "available app IDs:\n cli_only",
|
||||||
})
|
})
|
||||||
@@ -174,27 +173,3 @@ func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assertCandidate(t, got, Candidate{AppID: "cli_b"})
|
assertCandidate(t, got, Candidate{AppID: "cli_b"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveLarkChannelConfigPath_Default(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
t.Setenv("LARK_CHANNEL_CONFIG", "")
|
|
||||||
|
|
||||||
got := resolveLarkChannelConfigPath()
|
|
||||||
want := filepath.Join(home, ".lark-channel", "config.json")
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveLarkChannelConfigPath_EnvOverride(t *testing.T) {
|
|
||||||
home := t.TempDir()
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
t.Setenv("LARK_CHANNEL_CONFIG", "~/bridge/projection.json")
|
|
||||||
|
|
||||||
got := resolveLarkChannelConfigPath()
|
|
||||||
want := filepath.Join(home, "bridge", "projection.json")
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
|||||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/credential"
|
"github.com/larksuite/cli/internal/credential"
|
||||||
"github.com/larksuite/cli/internal/i18n"
|
|
||||||
"github.com/larksuite/cli/internal/keychain"
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
@@ -96,9 +95,8 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
|||||||
if !errors.As(err, &cfgErr) {
|
if !errors.As(err, &cfgErr) {
|
||||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||||
}
|
}
|
||||||
// Config errors share ExitAuth (3), not ExitValidation.
|
if cfgErr.Code != output.ExitValidation {
|
||||||
if cfgErr.Code != output.ExitAuth {
|
t.Fatalf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||||
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
|
||||||
}
|
}
|
||||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
||||||
@@ -126,11 +124,15 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
|||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
}
|
}
|
||||||
|
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
var exitErr *output.ExitError
|
||||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "no active profile") {
|
if exitErr.Code != output.ExitValidation {
|
||||||
t.Fatalf("error = %v, want to contain 'no active profile'", err)
|
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||||
|
}
|
||||||
|
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "no active profile" {
|
||||||
|
t.Fatalf("detail = %#v, want config/no active profile", exitErr.Detail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,9 +150,8 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
|
|||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
// --lang en is canonicalized to en_us in RunE before runF captures opts.
|
if gotOpts.Lang != "en" {
|
||||||
if gotOpts.Lang != string(i18n.LangEnUS) {
|
t.Errorf("expected Lang en, got %s", gotOpts.Lang)
|
||||||
t.Errorf("expected Lang en_us, got %s", gotOpts.Lang)
|
|
||||||
}
|
}
|
||||||
if !gotOpts.langExplicit {
|
if !gotOpts.langExplicit {
|
||||||
t.Error("expected langExplicit=true when --lang is passed")
|
t.Error("expected langExplicit=true when --lang is passed")
|
||||||
@@ -171,82 +172,14 @@ func TestConfigInitCmd_LangDefault(t *testing.T) {
|
|||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if gotOpts.Lang != "" {
|
if gotOpts.Lang != "zh" {
|
||||||
t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang)
|
t.Errorf("expected default Lang zh, got %s", gotOpts.Lang)
|
||||||
}
|
}
|
||||||
if gotOpts.langExplicit {
|
if gotOpts.langExplicit {
|
||||||
t.Error("expected langExplicit=false when --lang is not passed")
|
t.Error("expected langExplicit=false when --lang is not passed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSaveInitConfig_OmitLangPreservesPrior guards the single-app replace path:
|
|
||||||
// re-running init without --lang must inherit the prior preference, not clear it.
|
|
||||||
func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
|
|
||||||
existing := &core.MultiAppConfig{Apps: []core.AppConfig{
|
|
||||||
{AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP},
|
|
||||||
}}
|
|
||||||
if err := core.SaveMultiAppConfig(existing); err != nil {
|
|
||||||
t.Fatalf("seed config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
|
|
||||||
t.Fatalf("saveInitConfig (no --lang): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := core.LoadMultiAppConfig()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
|
||||||
}
|
|
||||||
if app := got.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
|
|
||||||
t.Errorf("Lang after re-init = %v, want %q (preserved)", app, i18n.LangJaJP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
|
|
||||||
// strictly validated the same way bind validates: wrong-case / typo / removed
|
|
||||||
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
|
|
||||||
func TestConfigInitCmd_InvalidLang(t *testing.T) {
|
|
||||||
clearAgentEnv(t)
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
lang string
|
|
||||||
}{
|
|
||||||
{"wrong case ZH", "ZH"},
|
|
||||||
{"typo frr", "frr"},
|
|
||||||
{"removed code ar", "ar"},
|
|
||||||
{"unknown xx", "xx"},
|
|
||||||
{"hyphen form zh-CN", "zh-CN"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
cmd := NewCmdConfigInit(f, nil)
|
|
||||||
f.IOStreams.In = strings.NewReader("sec\n")
|
|
||||||
cmd.SetArgs([]string{"--lang", tc.lang, "--app-id", "x", "--app-secret-stdin"})
|
|
||||||
err := cmd.Execute()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
|
||||||
}
|
|
||||||
exitErr, ok := err.(*output.ExitError)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if exitErr.Code != output.ExitValidation {
|
|
||||||
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
|
|
||||||
}
|
|
||||||
if !strings.Contains(exitErr.Error(), "invalid --lang") {
|
|
||||||
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHasAnyNonInteractiveFlag(t *testing.T) {
|
func TestHasAnyNonInteractiveFlag(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -465,65 +398,16 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
|
|||||||
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
||||||
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
||||||
}
|
}
|
||||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
var exitErr *output.ExitError
|
||||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
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")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
|
|
||||||
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
|
|
||||||
// the same locale; an unrecognized value errors.
|
|
||||||
func TestValidateInitLang(t *testing.T) {
|
|
||||||
t.Run("empty is a no-op", func(t *testing.T) {
|
|
||||||
for _, explicit := range []bool{false, true} {
|
|
||||||
opts := &ConfigInitOptions{Lang: "", langExplicit: explicit}
|
|
||||||
if err := validateInitLang(opts); err != nil {
|
|
||||||
t.Fatalf("explicit=%v: expected nil error, got %v", explicit, err)
|
|
||||||
}
|
|
||||||
if opts.Lang != "" {
|
|
||||||
t.Errorf("explicit=%v: Lang = %q, want \"\" (unset)", explicit, opts.Lang)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("short and locale canonicalize alike", func(t *testing.T) {
|
|
||||||
for _, in := range []string{"ja", "ja_jp"} {
|
|
||||||
opts := &ConfigInitOptions{Lang: in, langExplicit: true}
|
|
||||||
if err := validateInitLang(opts); err != nil {
|
|
||||||
t.Fatalf("--lang %q: unexpected error %v", in, err)
|
|
||||||
}
|
|
||||||
if opts.Lang != string(i18n.LangJaJP) {
|
|
||||||
t.Errorf("--lang %q normalized to %q, want %q", in, opts.Lang, i18n.LangJaJP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPrintLangPreferenceConfirmation covers the confirmation helper: it prints
|
|
||||||
// to stderr only when --lang explicitly set a non-empty preference.
|
|
||||||
func TestPrintLangPreferenceConfirmation(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
t.Run("explicit non-empty prints confirmation", func(t *testing.T) {
|
|
||||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: true})
|
|
||||||
got := stderr.String()
|
|
||||||
if !strings.Contains(got, "语言偏好") || !strings.Contains(got, "en_us") {
|
|
||||||
t.Errorf("stderr = %q, want confirmation mentioning the preference and en_us", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("implicit prints nothing", func(t *testing.T) {
|
|
||||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: false})
|
|
||||||
if got := stderr.String(); got != "" {
|
|
||||||
t.Errorf("stderr = %q, want empty when --lang is implicit", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("explicit empty prints nothing", func(t *testing.T) {
|
|
||||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "", UILang: i18n.LangZhCN, langExplicit: true})
|
|
||||||
if got := stderr.String(); got != "" {
|
|
||||||
t.Errorf("stderr = %q, want empty when --lang is empty", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,12 +41,12 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
|||||||
|
|
||||||
value := args[0]
|
value := args[0]
|
||||||
if value != "user" && value != "bot" && value != "auto" {
|
if value != "user" && value != "bot" && value != "auto" {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value)
|
return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.DefaultAs = core.Identity(value)
|
app.DefaultAs = core.Identity(value)
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
|
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -15,11 +15,9 @@ import (
|
|||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/auth"
|
"github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/i18n"
|
|
||||||
"github.com/larksuite/cli/internal/keychain"
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
@@ -33,13 +31,9 @@ type ConfigInitOptions struct {
|
|||||||
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
|
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
|
||||||
Brand string
|
Brand string
|
||||||
New bool
|
New bool
|
||||||
|
Lang string
|
||||||
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
|
langExplicit bool // true when --lang was explicitly passed
|
||||||
langExplicit bool // true when --lang was explicitly passed
|
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||||
|
|
||||||
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
|
|
||||||
|
|
||||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
|
||||||
|
|
||||||
// ForceInit overrides the agent-workspace guard. Without it, running
|
// ForceInit overrides the agent-workspace guard. Without it, running
|
||||||
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
|
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
|
||||||
@@ -51,7 +45,7 @@ type ConfigInitOptions struct {
|
|||||||
|
|
||||||
// NewCmdConfigInit creates the config init subcommand.
|
// NewCmdConfigInit creates the config init subcommand.
|
||||||
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
|
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
|
||||||
opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN}
|
opts := &ConfigInitOptions{Factory: f}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "init",
|
Use: "init",
|
||||||
@@ -69,9 +63,6 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
|||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
opts.Ctx = cmd.Context()
|
opts.Ctx = cmd.Context()
|
||||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||||
if err := validateInitLang(opts); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := guardAgentWorkspace(opts); err != nil {
|
if err := guardAgentWorkspace(opts); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -86,7 +77,7 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
|||||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
|
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
|
||||||
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
|
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
|
||||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
||||||
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
||||||
cmdutil.SetRisk(cmd, "write")
|
cmdutil.SetRisk(cmd, "write")
|
||||||
@@ -94,25 +85,6 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// printLangPreferenceConfirmation echoes the set preference to stderr, only
|
|
||||||
// when --lang explicitly set a non-empty value.
|
|
||||||
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
|
|
||||||
if !opts.langExplicit || opts.Lang == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msg := getInitMsg(opts.UILang)
|
|
||||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, opts.Lang))
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateInitLang(opts *ConfigInitOptions) error {
|
|
||||||
lang, err := cmdutil.ParseLangFlag(opts.Lang)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
opts.Lang = string(lang)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
|
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
|
||||||
// Hermes Agent context, because the Agent has already provisioned an app
|
// Hermes Agent context, because the Agent has already provisioned an app
|
||||||
// and 'config bind' is the right tool for hooking lark-cli into it.
|
// and 'config bind' is the right tool for hooking lark-cli into it.
|
||||||
@@ -160,7 +132,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
|
|||||||
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||||
config := &core.MultiAppConfig{
|
config := &core.MultiAppConfig{
|
||||||
Apps: []core.AppConfig{{
|
Apps: []core.AppConfig{{
|
||||||
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
|
AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{},
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
return core.SaveMultiAppConfig(config)
|
return core.SaveMultiAppConfig(config)
|
||||||
@@ -174,13 +146,7 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
|
|||||||
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
|
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
|
||||||
}
|
}
|
||||||
cleanupOldConfig(existing, f, appId)
|
cleanupOldConfig(existing, f, appId)
|
||||||
var prior i18n.Lang
|
return saveAsOnlyApp(appId, secret, brand, lang)
|
||||||
if existing != nil {
|
|
||||||
if app := existing.CurrentAppConfig(""); app != nil {
|
|
||||||
prior = app.Lang
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveAsProfile appends or updates a named profile in the config.
|
// saveAsProfile appends or updates a named profile in the config.
|
||||||
@@ -201,10 +167,11 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
|
|||||||
}
|
}
|
||||||
multi.Apps[idx].Users = []core.AppUser{}
|
multi.Apps[idx].Users = []core.AppUser{}
|
||||||
}
|
}
|
||||||
|
// Update existing profile
|
||||||
multi.Apps[idx].AppId = appId
|
multi.Apps[idx].AppId = appId
|
||||||
multi.Apps[idx].AppSecret = secret
|
multi.Apps[idx].AppSecret = secret
|
||||||
multi.Apps[idx].Brand = brand
|
multi.Apps[idx].Brand = brand
|
||||||
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
|
multi.Apps[idx].Lang = lang
|
||||||
} else {
|
} else {
|
||||||
if findAppIndexByAppID(multi, profileName) >= 0 {
|
if findAppIndexByAppID(multi, profileName) >= 0 {
|
||||||
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
|
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
|
||||||
@@ -215,7 +182,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
|
|||||||
AppId: appId,
|
AppId: appId,
|
||||||
AppSecret: secret,
|
AppSecret: secret,
|
||||||
Brand: brand,
|
Brand: brand,
|
||||||
Lang: i18n.Lang(lang),
|
Lang: lang,
|
||||||
Users: []core.AppUser{},
|
Users: []core.AppUser{},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -246,29 +213,9 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
// wrapUpdateExistingProfileErr classifies the error returned by
|
|
||||||
// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError
|
|
||||||
// for blank-input) pass through unchanged so their exit code semantics
|
|
||||||
// survive; legacy *output.ExitError also passes through; everything else
|
|
||||||
// (filesystem, keychain, etc.) is wrapped as InternalError.
|
|
||||||
func wrapUpdateExistingProfileErr(err error) error {
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if errs.IsTyped(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var exitErr *output.ExitError
|
|
||||||
if errors.As(err, &exitErr) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
|
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
|
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||||
WithParam("--app-secret")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var app *core.AppConfig
|
var app *core.AppConfig
|
||||||
@@ -276,25 +223,22 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
|
|||||||
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
||||||
app = &existing.Apps[idx]
|
app = &existing.Apps[idx]
|
||||||
} else {
|
} else {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile").
|
return output.ErrValidation("App Secret cannot be empty for new profile")
|
||||||
WithParam("--app-secret")
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
app = existing.CurrentAppConfig("")
|
app = existing.CurrentAppConfig("")
|
||||||
if app == nil {
|
if app == nil {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
|
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||||
WithParam("--app-secret")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.AppId != appID {
|
if app.AppId != appID {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty when changing App ID").
|
return output.ErrValidation("App Secret cannot be empty when changing App ID")
|
||||||
WithParam("--app-secret")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.AppId = appID
|
app.AppId = appID
|
||||||
app.Brand = brand
|
app.Brand = brand
|
||||||
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
|
app.Lang = lang
|
||||||
return core.SaveMultiAppConfig(existing)
|
return core.SaveMultiAppConfig(existing)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,13 +250,13 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
scanner := bufio.NewScanner(f.IOStreams.In)
|
scanner := bufio.NewScanner(f.IOStreams.In)
|
||||||
if !scanner.Scan() {
|
if !scanner.Scan() {
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to read secret from stdin: %v", err).WithCause(err)
|
return output.ErrValidation("failed to read secret from stdin: %v", err)
|
||||||
}
|
}
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret")
|
return output.ErrValidation("stdin is empty, expected app secret")
|
||||||
}
|
}
|
||||||
opts.appSecret = strings.TrimSpace(scanner.Text())
|
opts.appSecret = strings.TrimSpace(scanner.Text())
|
||||||
if opts.appSecret == "" {
|
if opts.appSecret == "" {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty")
|
return output.ErrValidation("app secret read from stdin is empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +268,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
// Validate --profile name if set
|
// Validate --profile name if set
|
||||||
if opts.ProfileName != "" {
|
if opts.ProfileName != "" {
|
||||||
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
|
return output.ErrValidation("%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,33 +277,35 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
brand := parseBrand(opts.Brand)
|
brand := parseBrand(opts.Brand)
|
||||||
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
|
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||||
}
|
}
|
||||||
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||||
}
|
}
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||||
printLangPreferenceConfirmation(opts)
|
|
||||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For interactive modes, prompt language selection if --lang was not explicitly set.
|
// For interactive modes, prompt language selection if --lang was not explicitly set
|
||||||
// Picker offers 2 options (中文 / English) and drives BOTH opts.Lang
|
|
||||||
// (preference) and opts.UILang (TUI rendering).
|
|
||||||
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
|
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
|
||||||
lang, err := promptLangSelection()
|
savedLang := ""
|
||||||
|
if existing != nil {
|
||||||
|
if app := existing.CurrentAppConfig(""); app != nil {
|
||||||
|
savedLang = app.Lang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lang, err := promptLangSelection(savedLang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == huh.ErrUserAborted {
|
if err == huh.ErrUserAborted {
|
||||||
return output.ErrBare(1)
|
return output.ErrBare(1)
|
||||||
}
|
}
|
||||||
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
|
return err
|
||||||
}
|
}
|
||||||
opts.Lang = string(lang)
|
opts.Lang = lang
|
||||||
opts.UILang = lang
|
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := getInitMsg(opts.UILang)
|
msg := getInitMsg(opts.Lang)
|
||||||
|
|
||||||
// Mode 3: Create new app directly (--new)
|
// Mode 3: Create new app directly (--new)
|
||||||
if opts.New {
|
if opts.New {
|
||||||
@@ -368,17 +314,16 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
|
return output.ErrValidation("app creation returned no result")
|
||||||
}
|
}
|
||||||
existing, _ := core.LoadMultiAppConfig()
|
existing, _ := core.LoadMultiAppConfig()
|
||||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||||
}
|
}
|
||||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||||
}
|
}
|
||||||
printLangPreferenceConfirmation(opts)
|
|
||||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -390,8 +335,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
return output.ErrValidation("App ID and App Secret cannot be empty")
|
||||||
WithParam("--app-id")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, _ := core.LoadMultiAppConfig()
|
existing, _ := core.LoadMultiAppConfig()
|
||||||
@@ -400,31 +344,33 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
// New secret provided (either from "create" or "existing" with input)
|
// New secret provided (either from "create" or "existing" with input)
|
||||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||||
}
|
}
|
||||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||||
}
|
}
|
||||||
} else if result.Mode == "existing" && result.AppID != "" {
|
} else if result.Mode == "existing" && result.AppID != "" {
|
||||||
// Existing app with unchanged secret — update app ID and brand only
|
// Existing app with unchanged secret — update app ID and brand only
|
||||||
if err := wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil {
|
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
|
||||||
return err
|
var exitErr *output.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
return output.ErrValidation("App ID and App Secret cannot be empty")
|
||||||
WithParam("--app-id")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Mode == "existing" {
|
if result.Mode == "existing" {
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
|
||||||
}
|
}
|
||||||
printLangPreferenceConfirmation(opts)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-terminal: cannot run interactive mode, guide user to --new
|
// Non-terminal: cannot run interactive mode, guide user to --new
|
||||||
if !f.IOStreams.IsTerminal {
|
if !f.IOStreams.IsTerminal {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
|
return output.ErrValidation("config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode 5: Legacy interactive (readline fallback)
|
// Mode 5: Legacy interactive (readline fallback)
|
||||||
@@ -452,7 +398,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
}
|
}
|
||||||
appIdInput, err := readLine(prompt)
|
appIdInput, err := readLine(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
return output.ErrValidation("%s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt = "App Secret"
|
prompt = "App Secret"
|
||||||
@@ -461,7 +407,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
}
|
}
|
||||||
appSecretInput, err := readLine(prompt)
|
appSecretInput, err := readLine(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
return output.ErrValidation("%s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt = "Brand (lark/feishu)"
|
prompt = "Brand (lark/feishu)"
|
||||||
@@ -472,7 +418,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
}
|
}
|
||||||
brandInput, err := readLine(prompt)
|
brandInput, err := readLine(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
return output.ErrValidation("%s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedAppId := appIdInput
|
resolvedAppId := appIdInput
|
||||||
@@ -494,18 +440,16 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if resolvedAppId == "" || resolvedSecret.IsZero() {
|
if resolvedAppId == "" || resolvedSecret.IsZero() {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
return output.ErrValidation("App ID and App Secret cannot be empty")
|
||||||
WithParam("--app-id")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
|
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||||
}
|
}
|
||||||
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||||
}
|
}
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||||
printLangPreferenceConfirmation(opts)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,16 @@ package config
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
qrcode "github.com/skip2/go-qrcode"
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/transport"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// configInitResult holds the result of the interactive config init flow.
|
// configInitResult holds the result of the interactive config init flow.
|
||||||
@@ -126,16 +125,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
if appID == "" || appSecret == "" {
|
||||||
case appID == "" && appSecret == "":
|
return nil, output.ErrValidation("App ID and App Secret cannot be empty")
|
||||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
|
||||||
WithParam("--app-id")
|
|
||||||
case appID == "":
|
|
||||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID cannot be empty").
|
|
||||||
WithParam("--app-id")
|
|
||||||
case appSecret == "":
|
|
||||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty").
|
|
||||||
WithParam("--app-secret")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &configInitResult{
|
return &configInitResult{
|
||||||
@@ -177,12 +168,10 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Request app registration (begin)
|
// Step 1: Request app registration (begin)
|
||||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
httpClient := &http.Client{}
|
||||||
// a bypass of proxy plugin mode.
|
|
||||||
httpClient := transport.NewHTTPClient(0)
|
|
||||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
return nil, output.ErrAuth("app registration failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Build and display verification URL + QR code
|
// Step 2: Build and display verification URL + QR code
|
||||||
@@ -210,7 +199,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
|||||||
}
|
}
|
||||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
|
return nil, output.ErrAuth("%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Handle Lark brand special case
|
// Step 4: Handle Lark brand special case
|
||||||
@@ -219,12 +208,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
|||||||
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
|
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
|
||||||
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
|
return nil, output.ErrAuth("lark endpoint retry failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.ClientID == "" || result.ClientSecret == "" {
|
if result.ClientID == "" || result.ClientSecret == "" {
|
||||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
|
return nil, output.ErrAuth("app registration succeeded but missing client_id or client_secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine final brand from response
|
// Determine final brand from response
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/i18n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type initMsg struct {
|
type initMsg struct {
|
||||||
@@ -27,10 +26,6 @@ type initMsg struct {
|
|||||||
DetectedLarkTenant string
|
DetectedLarkTenant string
|
||||||
AppCreated string
|
AppCreated string
|
||||||
ConfigSaved string
|
ConfigSaved string
|
||||||
|
|
||||||
// LangPreferenceSet is printed to stderr after a successful init when the
|
|
||||||
// user explicitly passed --lang. Format: language code.
|
|
||||||
LangPreferenceSet string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var initMsgZh = &initMsg{
|
var initMsgZh = &initMsg{
|
||||||
@@ -48,7 +43,6 @@ var initMsgZh = &initMsg{
|
|||||||
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
||||||
AppCreated: "应用配置成功! App ID: %s",
|
AppCreated: "应用配置成功! App ID: %s",
|
||||||
ConfigSaved: "应用配置成功! App ID: %s",
|
ConfigSaved: "应用配置成功! App ID: %s",
|
||||||
LangPreferenceSet: "语言偏好已设置:%s",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var initMsgEn = &initMsg{
|
var initMsgEn = &initMsg{
|
||||||
@@ -66,27 +60,29 @@ var initMsgEn = &initMsg{
|
|||||||
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
||||||
AppCreated: "App configured! App ID: %s",
|
AppCreated: "App configured! App ID: %s",
|
||||||
ConfigSaved: "App configured! App ID: %s",
|
ConfigSaved: "App configured! App ID: %s",
|
||||||
LangPreferenceSet: "Language preference set to: %s",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getInitMsg picks the zh/en TUI bundle; non-English falls back to zh.
|
func getInitMsg(lang string) *initMsg {
|
||||||
func getInitMsg(lang i18n.Lang) *initMsg {
|
if lang == "en" {
|
||||||
if lang.IsEnglish() {
|
|
||||||
return initMsgEn
|
return initMsgEn
|
||||||
}
|
}
|
||||||
return initMsgZh
|
return initMsgZh
|
||||||
}
|
}
|
||||||
|
|
||||||
// promptLangSelection shows the 中文/English picker and returns the chosen locale.
|
// promptLangSelection shows an interactive language picker and returns the chosen lang code.
|
||||||
func promptLangSelection() (i18n.Lang, error) {
|
// savedLang is used as the pre-selected default (from existing config).
|
||||||
lang := i18n.LangZhCN
|
func promptLangSelection(savedLang string) (string, error) {
|
||||||
|
lang := savedLang
|
||||||
|
if lang != "en" {
|
||||||
|
lang = "zh"
|
||||||
|
}
|
||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewSelect[i18n.Lang]().
|
huh.NewSelect[string]().
|
||||||
Title("Language / 语言").
|
Title("Language / 语言").
|
||||||
Options(
|
Options(
|
||||||
huh.NewOption("中文", i18n.LangZhCN),
|
huh.NewOption("中文", "zh"),
|
||||||
huh.NewOption("English", i18n.LangEnUS),
|
huh.NewOption("English", "en"),
|
||||||
).
|
).
|
||||||
Value(&lang),
|
Value(&lang),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/i18n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetInitMsg_Zh(t *testing.T) {
|
func TestGetInitMsg_Zh(t *testing.T) {
|
||||||
@@ -31,7 +29,7 @@ func TestGetInitMsg_En(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetInitMsg_DefaultsToZh(t *testing.T) {
|
func TestGetInitMsg_DefaultsToZh(t *testing.T) {
|
||||||
for _, lang := range []i18n.Lang{"", "unknown", "xyz", "invalid"} {
|
for _, lang := range []string{"", "fr", "ja", "unknown"} {
|
||||||
msg := getInitMsg(lang)
|
msg := getInitMsg(lang)
|
||||||
if msg != initMsgZh {
|
if msg != initMsgZh {
|
||||||
t.Errorf("getInitMsg(%q) should default to zh", lang)
|
t.Errorf("getInitMsg(%q) should default to zh", lang)
|
||||||
@@ -64,7 +62,6 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
|
|||||||
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
||||||
"AppCreated": msg.AppCreated,
|
"AppCreated": msg.AppCreated,
|
||||||
"ConfigSaved": msg.ConfigSaved,
|
"ConfigSaved": msg.ConfigSaved,
|
||||||
"LangPreferenceSet": msg.LangPreferenceSet,
|
|
||||||
}
|
}
|
||||||
for name, val := range fields {
|
for name, val := range fields {
|
||||||
if val == "" {
|
if val == "" {
|
||||||
@@ -74,7 +71,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestInitMsg_FormatStrings(t *testing.T) {
|
func TestInitMsg_FormatStrings(t *testing.T) {
|
||||||
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
|
for _, lang := range []string{"zh", "en"} {
|
||||||
msg := getInitMsg(lang)
|
msg := getInitMsg(lang)
|
||||||
// AppCreated and ConfigSaved should contain %s for App ID
|
// AppCreated and ConfigSaved should contain %s for App ID
|
||||||
got := fmt.Sprintf(msg.AppCreated, "cli_test123")
|
got := fmt.Sprintf(msg.AppCreated, "cli_test123")
|
||||||
@@ -87,37 +84,3 @@ func TestInitMsg_FormatStrings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetInitMsg_BilingualCollapse(t *testing.T) {
|
|
||||||
// The TUI is bilingual (zh + en). Only English-bucket languages return the
|
|
||||||
// English struct — by canonical locale ("en_us") or legacy short ("en").
|
|
||||||
// Everything else (zh, the other codes, invalid, "") returns Chinese.
|
|
||||||
tests := []struct {
|
|
||||||
lang i18n.Lang
|
|
||||||
shouldBeEn bool
|
|
||||||
}{
|
|
||||||
{i18n.LangZhCN, false},
|
|
||||||
{i18n.LangEnUS, true},
|
|
||||||
{"en", true}, // legacy short value
|
|
||||||
{i18n.LangJaJP, false},
|
|
||||||
{"fr_fr", false},
|
|
||||||
{"invalid", false},
|
|
||||||
{"", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(string(tt.lang), func(t *testing.T) {
|
|
||||||
msg := getInitMsg(tt.lang)
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatal("getInitMsg returned nil")
|
|
||||||
}
|
|
||||||
want := initMsgZh
|
|
||||||
if tt.shouldBeEn {
|
|
||||||
want = initMsgEn
|
|
||||||
}
|
|
||||||
if msg != want {
|
|
||||||
t.Errorf("getInitMsg(%q) returned wrong struct", tt.lang)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
)
|
|
||||||
|
|
||||||
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
|
|
||||||
// must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2:
|
|
||||||
// SubtypeInvalidClient is reserved for IAM rejection of malformed credentials,
|
|
||||||
// not for missing user input.
|
|
||||||
|
|
||||||
func TestUpdateExistingProfileWithoutSecret_NilConfig_EmitsValidationError(t *testing.T) {
|
|
||||||
err := updateExistingProfileWithoutSecret(nil, "", "cli_test", core.BrandFeishu, "en")
|
|
||||||
assertValidationParam(t, err, "--app-secret")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateExistingProfileWithoutSecret_UnknownProfile_EmitsValidationError(t *testing.T) {
|
|
||||||
existing := &core.MultiAppConfig{
|
|
||||||
Apps: []core.AppConfig{{
|
|
||||||
Name: "default",
|
|
||||||
AppId: "app-default",
|
|
||||||
AppSecret: core.PlainSecret("secret-default"),
|
|
||||||
Brand: core.BrandFeishu,
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", core.BrandFeishu, "en")
|
|
||||||
assertValidationParam(t, err, "--app-secret")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateExistingProfileWithoutSecret_NoCurrentApp_EmitsValidationError(t *testing.T) {
|
|
||||||
existing := &core.MultiAppConfig{
|
|
||||||
CurrentApp: "missing",
|
|
||||||
Apps: []core.AppConfig{{
|
|
||||||
Name: "default",
|
|
||||||
AppId: "app-default",
|
|
||||||
AppSecret: core.PlainSecret("secret-default"),
|
|
||||||
Brand: core.BrandFeishu,
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
err := updateExistingProfileWithoutSecret(existing, "", "cli_test", core.BrandFeishu, "en")
|
|
||||||
assertValidationParam(t, err, "--app-secret")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t *testing.T) {
|
|
||||||
existing := &core.MultiAppConfig{
|
|
||||||
Apps: []core.AppConfig{{
|
|
||||||
Name: "default",
|
|
||||||
AppId: "app-default",
|
|
||||||
AppSecret: core.PlainSecret("secret-default"),
|
|
||||||
Brand: core.BrandFeishu,
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
err := updateExistingProfileWithoutSecret(existing, "", "cli_different", core.BrandFeishu, "en")
|
|
||||||
assertValidationParam(t, err, "--app-secret")
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrapUpdateExistingProfileErr is the caller-side classifier for the error
|
|
||||||
// returned by updateExistingProfileWithoutSecret. It must preserve typed-error
|
|
||||||
// exit semantics (regression: typed ValidationError was being downgraded to
|
|
||||||
// InternalError by the legacy *output.ExitError-only passthrough).
|
|
||||||
|
|
||||||
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
|
|
||||||
if got := wrapUpdateExistingProfileErr(nil); got != nil {
|
|
||||||
t.Fatalf("expected nil, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T) {
|
|
||||||
in := errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile").
|
|
||||||
WithParam("--app-secret")
|
|
||||||
got := wrapUpdateExistingProfileErr(in)
|
|
||||||
assertValidationParam(t, got, "--app-secret")
|
|
||||||
// Exit code must remain ExitValidation (2), not ExitInternal (5).
|
|
||||||
if code := output.ExitCodeOf(got); code != output.ExitValidation {
|
|
||||||
t.Errorf("ExitCodeOf = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
|
||||||
}
|
|
||||||
// Must NOT be wrapped as *InternalError.
|
|
||||||
var intErr *errs.InternalError
|
|
||||||
if errors.As(got, &intErr) {
|
|
||||||
t.Errorf("typed ValidationError was downgraded to *InternalError: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) {
|
|
||||||
in := &output.ExitError{Code: 7, Err: errors.New("legacy")}
|
|
||||||
got := wrapUpdateExistingProfileErr(in)
|
|
||||||
var exitErr *output.ExitError
|
|
||||||
if !errors.As(got, &exitErr) {
|
|
||||||
t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got)
|
|
||||||
}
|
|
||||||
if exitErr.Code != 7 {
|
|
||||||
t.Errorf("Code = %d, want 7", exitErr.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
|
|
||||||
in := fmt.Errorf("disk full")
|
|
||||||
got := wrapUpdateExistingProfileErr(in)
|
|
||||||
var intErr *errs.InternalError
|
|
||||||
if !errors.As(got, &intErr) {
|
|
||||||
t.Fatalf("expected *errs.InternalError, got %T: %v", got, got)
|
|
||||||
}
|
|
||||||
if intErr.Subtype != errs.SubtypeSDKError {
|
|
||||||
t.Errorf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertValidationParam asserts err is *ValidationError with the given Param.
|
|
||||||
func assertValidationParam(t *testing.T, err error, wantParam string) {
|
|
||||||
t.Helper()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
var valErr *errs.ValidationError
|
|
||||||
if !errors.As(err, &valErr) {
|
|
||||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
|
||||||
t.Errorf("Subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
|
||||||
}
|
|
||||||
if valErr.Param != wantParam {
|
|
||||||
t.Errorf("Param = %q, want %q", valErr.Param, wantParam)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
//go:build darwin
|
|
||||||
|
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/internal/keychain"
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewCmdConfigKeychainDowngrade creates the macOS-only subcommand that pins
|
|
||||||
// the master key to the local file fallback (master.key.file) so subsequent
|
|
||||||
// operations bypass the OS Keychain. Useful inside sandboxes like Codex
|
|
||||||
// where the system Keychain is unreachable.
|
|
||||||
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "keychain-downgrade",
|
|
||||||
Short: "Downgrade keychain storage to a local file (macOS only)",
|
|
||||||
Long: `Materialize the master key from the macOS system Keychain into a local file
|
|
||||||
under ~/Library/Application Support/lark-cli/master.key.file, then pin all
|
|
||||||
subsequent reads to that file.
|
|
||||||
|
|
||||||
Intended workflow: run this once from an interactive Terminal session on
|
|
||||||
macOS (where the system Keychain is reachable). After it finishes,
|
|
||||||
sandboxed / automation / CI runs of lark-cli on the same machine will read
|
|
||||||
the master key from the local file and no longer need the OS Keychain.
|
|
||||||
|
|
||||||
This is the supported fix for environments like the Codex sandbox where the
|
|
||||||
system Keychain is blocked. Running keychain-downgrade from inside such a
|
|
||||||
sandbox will itself fail with "keychain access blocked" — that is expected;
|
|
||||||
run it from an interactive macOS session instead.
|
|
||||||
|
|
||||||
The OS Keychain entry is preserved as a cold backup; nothing is deleted there.
|
|
||||||
The command is idempotent: re-running it on an already-downgraded install
|
|
||||||
reports "already downgraded" and exits 0.`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return configKeychainDowngradeRun(f)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
cmdutil.SetRisk(cmd, "write")
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func configKeychainDowngradeRun(f *cmdutil.Factory) error {
|
|
||||||
service := keychain.LarkCliService
|
|
||||||
keyPath := keychain.MasterKeyFilePath(service)
|
|
||||||
|
|
||||||
result, err := keychain.DowngradeMasterKeyToFile(service)
|
|
||||||
if err != nil {
|
|
||||||
return errs.NewInternalError(errs.SubtypeSDKError,
|
|
||||||
"keychain downgrade failed: %v", err).
|
|
||||||
WithHint("This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design.").
|
|
||||||
WithCause(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch result {
|
|
||||||
case keychain.DowngradeAlreadyDone:
|
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("keychain already downgraded; subsequent operations read from %s", keyPath))
|
|
||||||
case keychain.DowngradeUsedKeychainKey:
|
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("downgraded: copied master key from system Keychain to %s. Subsequent operations will read from file, bypassing the OS Keychain (useful inside sandboxes like Codex).", keyPath))
|
|
||||||
case keychain.DowngradeCreatedNewKey:
|
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("system Keychain was empty; generated a new master key and wrote it to %s. The OS Keychain was not modified.", keyPath))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
//go:build !darwin
|
|
||||||
|
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewCmdConfigKeychainDowngrade is registered on all platforms so that
|
|
||||||
// `lark-cli config --help` reads the same everywhere. On non-macOS it
|
|
||||||
// refuses with a clear message.
|
|
||||||
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
|
|
||||||
_ = f
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "keychain-downgrade",
|
|
||||||
Short: "Downgrade keychain storage to a local file (macOS only)",
|
|
||||||
Long: `Downgrade keychain storage to a local file. This subcommand is only supported on macOS; on this platform the keychain layer already uses local files.`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "keychain-downgrade is only supported on macOS")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
@@ -82,8 +82,8 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
|
|||||||
"version": p.Version,
|
"version": p.Version,
|
||||||
"capabilities": p.Capabilities,
|
"capabilities": p.Capabilities,
|
||||||
}
|
}
|
||||||
if len(p.Rules) > 0 {
|
if p.Rule != nil {
|
||||||
entry["rules"] = p.Rules
|
entry["rule"] = p.Rule
|
||||||
}
|
}
|
||||||
entry["hooks"] = map[string]any{
|
entry["hooks"] = map[string]any{
|
||||||
"observers": p.Observers,
|
"observers": p.Observers,
|
||||||
|
|||||||
@@ -59,20 +59,16 @@ func runConfigPolicyShow(f *cmdutil.Factory) error {
|
|||||||
"source_name": sourceName,
|
"source_name": sourceName,
|
||||||
"denied_paths": active.DeniedPaths,
|
"denied_paths": active.DeniedPaths,
|
||||||
}
|
}
|
||||||
if len(active.Rules) > 0 {
|
if active.Rule != nil {
|
||||||
rules := make([]map[string]any, 0, len(active.Rules))
|
out["rule"] = map[string]any{
|
||||||
for _, r := range active.Rules {
|
"name": active.Rule.Name,
|
||||||
rules = append(rules, map[string]any{
|
"description": active.Rule.Description,
|
||||||
"name": r.Name,
|
"allow": active.Rule.Allow,
|
||||||
"description": r.Description,
|
"deny": active.Rule.Deny,
|
||||||
"allow": r.Allow,
|
"max_risk": active.Rule.MaxRisk,
|
||||||
"deny": r.Deny,
|
"identities": active.Rule.Identities,
|
||||||
"max_risk": r.MaxRisk,
|
"allow_unannotated": active.Rule.AllowUnannotated,
|
||||||
"identities": r.Identities,
|
|
||||||
"allow_unannotated": r.AllowUnannotated,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
out["rules"] = rules
|
|
||||||
}
|
}
|
||||||
output.PrintJson(f.IOStreams.Out, out)
|
output.PrintJson(f.IOStreams.Out, out)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
|||||||
MaxRisk: "read",
|
MaxRisk: "read",
|
||||||
}
|
}
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||||
Rules: []*platform.Rule{rule},
|
Rule: rule,
|
||||||
Source: cmdpolicy.ResolveSource{
|
Source: cmdpolicy.ResolveSource{
|
||||||
Kind: cmdpolicy.SourcePlugin,
|
Kind: cmdpolicy.SourcePlugin,
|
||||||
Name: "secaudit",
|
Name: "secaudit",
|
||||||
@@ -83,16 +83,12 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
|||||||
if got["denied_paths"] != float64(42) {
|
if got["denied_paths"] != float64(42) {
|
||||||
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
|
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
|
||||||
}
|
}
|
||||||
rulesAny, ok := got["rules"].([]any)
|
ruleMap, ok := got["rule"].(map[string]any)
|
||||||
if !ok || len(rulesAny) != 1 {
|
|
||||||
t.Fatalf("rules field missing or wrong shape: %v", got["rules"])
|
|
||||||
}
|
|
||||||
ruleMap, ok := rulesAny[0].(map[string]any)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("rules[0] wrong type")
|
t.Fatalf("rule field missing or wrong type")
|
||||||
}
|
}
|
||||||
if ruleMap["name"] != "secaudit" {
|
if ruleMap["name"] != "secaudit" {
|
||||||
t.Errorf("rules[0].name = %v", ruleMap["name"])
|
t.Errorf("rule.name = %v", ruleMap["name"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +101,7 @@ func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
|
|||||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||||
|
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||||
Rules: []*platform.Rule{{Name: "my-yaml-rule"}},
|
Rule: &platform.Rule{Name: "my-yaml-rule"},
|
||||||
Source: cmdpolicy.ResolveSource{
|
Source: cmdpolicy.ResolveSource{
|
||||||
Kind: cmdpolicy.SourceYAML,
|
Kind: cmdpolicy.SourceYAML,
|
||||||
Name: "/Users/alice/.lark-cli/policy.yml",
|
Name: "/Users/alice/.lark-cli/policy.yml",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/auth"
|
"github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -43,14 +42,14 @@ func configRemoveRun(opts *ConfigRemoveOptions) error {
|
|||||||
|
|
||||||
config, err := core.LoadMultiAppConfig()
|
config, err := core.LoadMultiAppConfig()
|
||||||
if err != nil || config == nil || len(config.Apps) == 0 {
|
if err != nil || config == nil || len(config.Apps) == 0 {
|
||||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured yet")
|
return output.ErrValidation("not configured yet")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save empty config first. If this fails, keep secrets and tokens intact so the
|
// Save empty config first. If this fails, keep secrets and tokens intact so the
|
||||||
// existing config can still be retried instead of ending up half-removed.
|
// existing config can still be retried instead of ending up half-removed.
|
||||||
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
|
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
|
||||||
if err := core.SaveMultiAppConfig(empty); err != nil {
|
if err := core.SaveMultiAppConfig(empty); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up keychain entries for all apps after config is cleared.
|
// Clean up keychain entries for all apps after config is cleared.
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
@@ -48,14 +47,14 @@ func configShowRun(opts *ConfigShowOptions) error {
|
|||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return core.NotConfiguredError()
|
return core.NotConfiguredError()
|
||||||
}
|
}
|
||||||
return errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
|
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
if config == nil || len(config.Apps) == 0 {
|
if config == nil || len(config.Apps) == 0 {
|
||||||
return core.NotConfiguredError()
|
return core.NotConfiguredError()
|
||||||
}
|
}
|
||||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||||
if app == nil {
|
if app == nil {
|
||||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
|
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli profile list")
|
||||||
}
|
}
|
||||||
users := "(no logged-in users)"
|
users := "(no logged-in users)"
|
||||||
if len(app.Users) > 0 {
|
if len(app.Users) > 0 {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,14 +73,14 @@ explicit user confirmation — never run on your own initiative.`,
|
|||||||
|
|
||||||
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
|
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
|
||||||
if global {
|
if global {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with --global").WithParam("--reset")
|
return output.ErrValidation("--reset cannot be used with --global")
|
||||||
}
|
}
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with a value argument").WithParam("--reset")
|
return output.ErrValidation("--reset cannot be used with a value argument")
|
||||||
}
|
}
|
||||||
app.StrictMode = nil
|
app.StrictMode = nil
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
|
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
|
||||||
return nil
|
return nil
|
||||||
@@ -104,7 +104,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
|||||||
switch mode {
|
switch mode {
|
||||||
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
||||||
default:
|
default:
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid value %q, valid values: bot | user | off", value)
|
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture the old mode at the SAME scope being changed, so we can warn
|
// Capture the old mode at the SAME scope being changed, so we can warn
|
||||||
@@ -144,7 +144,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/identitydiag"
|
"github.com/larksuite/cli/internal/identitydiag"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/transport"
|
|
||||||
"github.com/larksuite/cli/internal/update"
|
"github.com/larksuite/cli/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -153,9 +152,7 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
|
httpClient := &http.Client{}
|
||||||
// the real egress path (and are blocked when proxy plugin fails closed).
|
|
||||||
httpClient := transport.NewHTTPClient(0)
|
|
||||||
mcpURL := ep.MCP + "/mcp"
|
mcpURL := ep.MCP + "/mcp"
|
||||||
|
|
||||||
type probeResult struct {
|
type probeResult struct {
|
||||||
|
|||||||
@@ -4,13 +4,9 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
internalauth "github.com/larksuite/cli/internal/auth"
|
internalauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -18,43 +14,12 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
"github.com/larksuite/cli/shortcuts"
|
"github.com/larksuite/cli/shortcuts"
|
||||||
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
|
// enrichMissingScopeError preserves the original need_user_authorization
|
||||||
// "current command requires scope(s): X, Y" hint when the underlying error is
|
// message and appends a scope hint when the current command declares the
|
||||||
// a need_user_authorization signal AND the current command declares scopes
|
// required scopes locally.
|
||||||
// locally (via shortcut registration or service-method metadata). Existing
|
|
||||||
// Hint text is preserved; scopes are appended on a new line.
|
|
||||||
func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
|
||||||
if err == nil || f == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !internalauth.IsNeedUserAuthorizationError(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var authErr *errs.AuthenticationError
|
|
||||||
if !errors.As(err, &authErr) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
|
||||||
if len(scopes) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
|
|
||||||
if authErr.Hint == "" {
|
|
||||||
authErr.Hint = scopeHint
|
|
||||||
return
|
|
||||||
}
|
|
||||||
authErr.Hint += "\n" + scopeHint
|
|
||||||
}
|
|
||||||
|
|
||||||
// enrichMissingScopeError appends a "current command requires scope(s): X"
|
|
||||||
// hint to a legacy *output.ExitError when the underlying error carries the
|
|
||||||
// need_user_authorization marker AND the current command declares scopes
|
|
||||||
// locally.
|
|
||||||
//
|
|
||||||
// Deprecated: enrichment for the legacy envelope; the typed path is
|
|
||||||
// applyNeedAuthorizationHint above.
|
|
||||||
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||||
if exitErr == nil || exitErr.Detail == nil {
|
if exitErr == nil || exitErr.Detail == nil {
|
||||||
return
|
return
|
||||||
@@ -62,10 +27,12 @@ func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
|||||||
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
|
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
||||||
if len(scopes) == 0 {
|
if len(scopes) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
|
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
|
||||||
if exitErr.Detail.Hint == "" {
|
if exitErr.Detail.Hint == "" {
|
||||||
exitErr.Detail.Hint = scopeHint
|
exitErr.Detail.Hint = scopeHint
|
||||||
@@ -149,7 +116,47 @@ func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []s
|
|||||||
if methodMap == nil {
|
if methodMap == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return registry.DeclaredScopesForMethod(methodMap, identity)
|
return declaredScopesForMethod(methodMap, identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// declaredScopesForMethod returns all requiredScopes when present; otherwise it
|
||||||
|
// resolves the single recommended scope from the method's scopes list.
|
||||||
|
func declaredScopesForMethod(method map[string]interface{}, identity string) []string {
|
||||||
|
if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 {
|
||||||
|
return interfaceStrings(requiredRaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawScopes, _ := method["scopes"].([]interface{})
|
||||||
|
if len(rawScopes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
recommended := registry.SelectRecommendedScope(rawScopes, identity)
|
||||||
|
if recommended == "" {
|
||||||
|
for _, raw := range rawScopes {
|
||||||
|
if scope, ok := raw.(string); ok && scope != "" {
|
||||||
|
recommended = scope
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if recommended == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{recommended}
|
||||||
|
}
|
||||||
|
|
||||||
|
// interfaceStrings converts a []interface{} containing strings into a compact
|
||||||
|
// []string, skipping empty or non-string values.
|
||||||
|
func interfaceStrings(values []interface{}) []string {
|
||||||
|
scopes := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
scope, ok := value.(string)
|
||||||
|
if !ok || scope == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scopes = append(scopes, scope)
|
||||||
|
}
|
||||||
|
return scopes
|
||||||
}
|
}
|
||||||
|
|
||||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||||
return json.RawMessage(resp.RawBody), apiErr
|
return json.RawMessage(resp.RawBody), apiErr
|
||||||
}
|
}
|
||||||
return json.RawMessage(resp.RawBody), nil
|
return json.RawMessage(resp.RawBody), nil
|
||||||
|
|||||||
@@ -36,71 +36,47 @@ const userPolicyFileName = "policy.yml"
|
|||||||
// pluginRules carries Plugin.Restrict() contributions collected from
|
// pluginRules carries Plugin.Restrict() contributions collected from
|
||||||
// the InstallAll phase; nil/empty is fine.
|
// the InstallAll phase; nil/empty is fine.
|
||||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
||||||
// Plugin rules shadow the yaml source entirely (Resolve: plugin >
|
yamlPath, err := userPolicyPath()
|
||||||
// yaml). When a plugin contributed rules we therefore do NOT even
|
if err != nil {
|
||||||
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy
|
// No user home dir means we cannot locate the policy. Treat
|
||||||
// error once a plugin is present, so reading a malformed yaml here
|
// the same as "file missing": no pruning, no error. This keeps
|
||||||
// would let an unrelated broken file on the user's machine abort a
|
// non-interactive CI environments (no HOME set) running.
|
||||||
// plugin-governed binary -- exactly the file the plugin is supposed
|
yamlPath = ""
|
||||||
// to shadow. Skipping the read keeps the shadow contract honest.
|
|
||||||
var (
|
|
||||||
yamlRules []*platform.Rule
|
|
||||||
yamlPath string
|
|
||||||
)
|
|
||||||
if len(pluginRules) == 0 {
|
|
||||||
p, perr := userPolicyPath()
|
|
||||||
if perr != nil {
|
|
||||||
// No user home dir means we cannot locate the policy. Treat
|
|
||||||
// the same as "file missing": no pruning, no error. This keeps
|
|
||||||
// non-interactive CI environments (no HOME set) running.
|
|
||||||
p = ""
|
|
||||||
}
|
|
||||||
yamlPath = p
|
|
||||||
loaded, lerr := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
|
||||||
if lerr != nil {
|
|
||||||
// Yaml-only failures are fail-OPEN at the caller (warn and
|
|
||||||
// continue), but the active-policy snapshot is process-global
|
|
||||||
// and may still carry data from a previous build in long-lived
|
|
||||||
// embedders / tests. Clear it explicitly so `config policy
|
|
||||||
// show` reports "no policy" instead of a stale rule that
|
|
||||||
// doesn't reflect the current command tree.
|
|
||||||
cmdpolicy.SetActive(nil)
|
|
||||||
return lerr
|
|
||||||
}
|
|
||||||
yamlRules = loaded
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rules, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
yamlRule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
||||||
|
if err != nil {
|
||||||
|
// Yaml-only failures are fail-OPEN at the caller (warn and
|
||||||
|
// continue), but the active-policy snapshot is process-global
|
||||||
|
// and may still carry data from a previous build in long-lived
|
||||||
|
// embedders / tests. Clear it explicitly so `config policy
|
||||||
|
// show` reports "no policy" instead of a stale rule that
|
||||||
|
// doesn't reflect the current command tree.
|
||||||
|
cmdpolicy.SetActive(nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rule, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||||
PluginRules: pluginRules,
|
PluginRules: pluginRules,
|
||||||
YAMLRules: yamlRules,
|
YAMLRule: yamlRule,
|
||||||
YAMLPath: yamlPath,
|
YAMLPath: yamlPath,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cmdpolicy.SetActive(nil)
|
cmdpolicy.SetActive(nil)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(rules) == 0 {
|
if rule == nil {
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuleName attributes a denial to a specific rule in the envelope.
|
engine := cmdpolicy.New(rule)
|
||||||
// With a single rule that is unambiguous and preserves the legacy
|
|
||||||
// envelope verbatim; with several rules a denial means "no rule
|
|
||||||
// granted it", which has no single owner, so the field is left empty
|
|
||||||
// and reason_code=no_matching_rule carries the meaning instead.
|
|
||||||
ruleName := ""
|
|
||||||
if len(rules) == 1 {
|
|
||||||
ruleName = rules[0].Name
|
|
||||||
}
|
|
||||||
|
|
||||||
engine := cmdpolicy.NewSet(rules)
|
|
||||||
decisions := engine.EvaluateAll(rootCmd)
|
decisions := engine.EvaluateAll(rootCmd)
|
||||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName)
|
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
|
||||||
cmdpolicy.Apply(rootCmd, denied)
|
cmdpolicy.Apply(rootCmd, denied)
|
||||||
|
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||||
Rules: rules,
|
Rule: rule,
|
||||||
Source: source,
|
Source: source,
|
||||||
DeniedPaths: len(denied),
|
DeniedPaths: len(denied),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/platform"
|
|
||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
@@ -186,39 +184,6 @@ func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a plugin contributed rules, a malformed user policy.yml must NOT
|
|
||||||
// abort: plugin rules shadow yaml entirely, so the broken file is never
|
|
||||||
// read. Regression -- previously LoadYAMLPolicy ran first and an
|
|
||||||
// unrelated broken yaml on the user's machine could fatal a
|
|
||||||
// plugin-governed binary (build.go fail-CLOSES on policy errors when a
|
|
||||||
// plugin is present).
|
|
||||||
func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) {
|
|
||||||
cfgDir := tmpHome(t)
|
|
||||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
|
||||||
writePolicy(t, cfgDir, "::: not yaml :::") // broken on purpose
|
|
||||||
|
|
||||||
pluginRules := []cmdpolicy.PluginRule{
|
|
||||||
{PluginName: "secaudit", Rule: &platform.Rule{
|
|
||||||
Name: "docs-only",
|
|
||||||
Allow: []string{"docs/**"},
|
|
||||||
MaxRisk: "write",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
root := fakeTree(t)
|
|
||||||
if err := applyUserPolicyPruning(root, pluginRules); err != nil {
|
|
||||||
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plugin rule actually applied: im/+send is outside docs/** -> hidden.
|
|
||||||
if send := findLeaf(t, root, "im", "+send"); !send.Hidden {
|
|
||||||
t.Errorf("im/+send should be hidden by plugin rule (not in docs/** allow)")
|
|
||||||
}
|
|
||||||
// docs/+update is within allow and at/below max_risk -> stays visible.
|
|
||||||
if update := findLeaf(t, root, "docs", "+update"); update.Hidden {
|
|
||||||
t.Errorf("docs/+update should remain visible under plugin rule")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
|
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
|
||||||
// Resolve and produces an error. This is the safety contract: a typo in
|
// Resolve and produces an error. This is the safety contract: a typo in
|
||||||
// the rule must not silently lower the pruning bar.
|
// the rule must not silently lower the pruning bar.
|
||||||
|
|||||||
@@ -36,13 +36,6 @@ import (
|
|||||||
// makeErr is called for every guarded dispatch; it must return a fresh
|
// makeErr is called for every guarded dispatch; it must return a fresh
|
||||||
// *output.ExitError each time (the envelope writer mutates a few fields
|
// *output.ExitError each time (the envelope writer mutates a few fields
|
||||||
// as it serialises).
|
// as it serialises).
|
||||||
// Deprecated: installFatalGuard accepts a *output.ExitError-producing lambda,
|
|
||||||
// which is part of the legacy error surface that predates the typed error
|
|
||||||
// contract introduced by errs/. New code MUST NOT add new callers — the
|
|
||||||
// platform-extension fatal-guard plumbing will switch to typed errs.* errors
|
|
||||||
// when the platform-extension framework migrates. This wrapper is retained
|
|
||||||
// only for the existing in-tree call sites; it will be removed once they
|
|
||||||
// have moved to the typed surface.
|
|
||||||
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
|
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
|
||||||
// Two cobra subcommands are injected lazily at Execute() time and
|
// Two cobra subcommands are injected lazily at Execute() time and
|
||||||
// would otherwise slip past walkGuard. We pre-register both so
|
// would otherwise slip past walkGuard. We pre-register both so
|
||||||
@@ -82,12 +75,6 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError)
|
|||||||
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
|
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
|
||||||
// failure as a structured plugin_install envelope before any command
|
// failure as a structured plugin_install envelope before any command
|
||||||
// runs.
|
// runs.
|
||||||
// Deprecated: installPluginInstallErrorGuard produces a legacy
|
|
||||||
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
|
|
||||||
// such producers — plugin install failures should surface as a typed
|
|
||||||
// *errs.XxxError once the platform-extension framework migrates. This
|
|
||||||
// helper is retained only while existing call sites are migrated; it will
|
|
||||||
// be removed once they have moved to the typed surface.
|
|
||||||
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
||||||
makeErr := func() *output.ExitError {
|
makeErr := func() *output.ExitError {
|
||||||
var pi *internalplatform.PluginInstallError
|
var pi *internalplatform.PluginInstallError
|
||||||
@@ -129,12 +116,6 @@ func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
|||||||
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
|
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
|
||||||
//
|
//
|
||||||
// Either way the CLI must NOT silently continue with a broken policy.
|
// Either way the CLI must NOT silently continue with a broken policy.
|
||||||
// Deprecated: installPluginConflictGuard produces a legacy *output.ExitError
|
|
||||||
// via its internal makeErr lambda. New code MUST NOT add such producers —
|
|
||||||
// plugin conflict failures should surface as a typed *errs.XxxError once the
|
|
||||||
// platform-extension framework migrates. This helper is retained only while
|
|
||||||
// existing call sites are migrated; it will be removed once they have moved
|
|
||||||
// to the typed surface.
|
|
||||||
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
||||||
makeErr := func() *output.ExitError {
|
makeErr := func() *output.ExitError {
|
||||||
envelopeType := "plugin_install"
|
envelopeType := "plugin_install"
|
||||||
@@ -162,12 +143,6 @@ func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
|||||||
// failure as a plugin_lifecycle envelope. The reason_code splits
|
// failure as a plugin_lifecycle envelope. The reason_code splits
|
||||||
// returned-error vs panic so consumers (audit / on-call) can tell the
|
// returned-error vs panic so consumers (audit / on-call) can tell the
|
||||||
// two failure modes apart.
|
// two failure modes apart.
|
||||||
// Deprecated: installPluginLifecycleErrorGuard produces a legacy
|
|
||||||
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
|
|
||||||
// such producers — plugin lifecycle failures should surface as a typed
|
|
||||||
// *errs.XxxError once the platform-extension framework migrates. This
|
|
||||||
// helper is retained only while existing call sites are migrated; it will
|
|
||||||
// be removed once they have moved to the typed surface.
|
|
||||||
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
||||||
makeErr := func() *output.ExitError {
|
makeErr := func() *output.ExitError {
|
||||||
reasonCode := "lifecycle_failed"
|
reasonCode := "lifecycle_failed"
|
||||||
@@ -219,13 +194,6 @@ func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
|||||||
//
|
//
|
||||||
// This way the very first non-nil step in cobra's chain is always our
|
// This way the very first non-nil step in cobra's chain is always our
|
||||||
// guard, regardless of which leaf the user invoked.
|
// guard, regardless of which leaf the user invoked.
|
||||||
// Deprecated: walkGuard accepts a *output.ExitError-producing lambda, part
|
|
||||||
// of the legacy error surface that predates the typed error contract
|
|
||||||
// introduced by errs/. New code MUST NOT add new callers — the platform-
|
|
||||||
// extension guard plumbing will switch to typed errs.* errors when the
|
|
||||||
// platform-extension framework migrates. This wrapper is retained only for
|
|
||||||
// the existing in-tree call sites; it will be removed once they have moved
|
|
||||||
// to the typed surface.
|
|
||||||
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
|
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/i18n"
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)")
|
cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)")
|
||||||
cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin")
|
cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin")
|
||||||
cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark")
|
cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark")
|
||||||
cmd.Flags().StringVar(&lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
cmd.Flags().StringVar(&lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||||
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
|
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
|
||||||
|
|
||||||
_ = cmd.MarkFlagRequired("name")
|
_ = cmd.MarkFlagRequired("name")
|
||||||
@@ -56,12 +55,6 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
|
|||||||
return output.ErrValidation("%v", err)
|
return output.ErrValidation("%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
langPref, err := cmdutil.ParseLangFlag(lang)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
lang = string(langPref)
|
|
||||||
|
|
||||||
// Read secret from stdin
|
// Read secret from stdin
|
||||||
if !appSecretStdin {
|
if !appSecretStdin {
|
||||||
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
|
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
|
||||||
@@ -122,7 +115,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
|
|||||||
AppId: appID,
|
AppId: appID,
|
||||||
AppSecret: secret,
|
AppSecret: secret,
|
||||||
Brand: parsedBrand,
|
Brand: parsedBrand,
|
||||||
Lang: i18n.Lang(lang),
|
Lang: lang,
|
||||||
Users: []core.AppUser{},
|
Users: []core.AppUser{},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/i18n"
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
)
|
)
|
||||||
@@ -52,56 +51,6 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProfileAddRun_Lang covers the unified --lang contract on profile add:
|
|
||||||
// short codes and Feishu locales both canonicalize to the same stored locale,
|
|
||||||
// empty stores no preference, and an unrecognized value errors.
|
|
||||||
func TestProfileAddRun_Lang(t *testing.T) {
|
|
||||||
t.Run("short and locale canonicalize and persist alike", func(t *testing.T) {
|
|
||||||
for _, in := range []string{"ja", "ja_jp"} {
|
|
||||||
setupProfileConfigDir(t)
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
f.IOStreams.In = strings.NewReader("secret\n")
|
|
||||||
if err := profileAddRun(f, "p", "app-p", true, "feishu", in, false); err != nil {
|
|
||||||
t.Fatalf("--lang %q: profileAddRun() error = %v", in, err)
|
|
||||||
}
|
|
||||||
saved, err := core.LoadMultiAppConfig()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
|
||||||
}
|
|
||||||
if app := saved.FindApp("p"); app == nil || app.Lang != i18n.LangJaJP {
|
|
||||||
t.Errorf("--lang %q: stored Lang = %v, want %q", in, app, i18n.LangJaJP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("empty stores no preference", func(t *testing.T) {
|
|
||||||
setupProfileConfigDir(t)
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
f.IOStreams.In = strings.NewReader("secret\n")
|
|
||||||
if err := profileAddRun(f, "p", "app-p", true, "feishu", "", false); err != nil {
|
|
||||||
t.Fatalf("profileAddRun() error = %v", err)
|
|
||||||
}
|
|
||||||
saved, _ := core.LoadMultiAppConfig()
|
|
||||||
if app := saved.FindApp("p"); app == nil || app.Lang != "" {
|
|
||||||
t.Errorf("stored Lang = %v, want \"\" (unset)", app)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("invalid lang errors", func(t *testing.T) {
|
|
||||||
setupProfileConfigDir(t)
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
f.IOStreams.In = strings.NewReader("secret\n")
|
|
||||||
err := profileAddRun(f, "p", "app-p", true, "feishu", "ZH", false)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected validation error for --lang ZH, got nil")
|
|
||||||
}
|
|
||||||
exitErr, ok := err.(*output.ExitError)
|
|
||||||
if !ok || exitErr.Code != output.ExitValidation {
|
|
||||||
t.Fatalf("expected ExitValidation, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
|
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
|
||||||
setupProfileConfigDir(t)
|
setupProfileConfigDir(t)
|
||||||
multi := &core.MultiAppConfig{
|
multi := &core.MultiAppConfig{
|
||||||
|
|||||||
@@ -105,10 +105,6 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
|||||||
},
|
},
|
||||||
RunE: func(c *cobra.Command, _ []string) error {
|
RunE: func(c *cobra.Command, _ []string) error {
|
||||||
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
||||||
// Legacy *output.ExitError producer: this literal predates the
|
|
||||||
// typed error contract introduced by errs/. New denial sites MUST
|
|
||||||
// NOT construct *output.ExitError directly — they should return a
|
|
||||||
// typed *errs.XxxError once the cmdpolicy framework migrates.
|
|
||||||
return &output.ExitError{
|
return &output.ExitError{
|
||||||
Code: output.ExitValidation,
|
Code: output.ExitValidation,
|
||||||
Detail: &output.ErrDetail{
|
Detail: &output.ErrDetail{
|
||||||
|
|||||||
252
cmd/root.go
252
cmd/root.go
@@ -4,22 +4,24 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/extension/platform"
|
"github.com/larksuite/cli/extension/platform"
|
||||||
internalauth "github.com/larksuite/cli/internal/auth"
|
internalauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/errclass"
|
|
||||||
"github.com/larksuite/cli/internal/errcompat"
|
|
||||||
"github.com/larksuite/cli/internal/hook"
|
"github.com/larksuite/cli/internal/hook"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
@@ -198,68 +200,22 @@ func configureFlagCompletions(args []string) {
|
|||||||
|
|
||||||
// handleRootError dispatches a command error to the appropriate handler
|
// handleRootError dispatches a command error to the appropriate handler
|
||||||
// and returns the process exit code.
|
// and returns the process exit code.
|
||||||
//
|
|
||||||
// Dispatch order:
|
|
||||||
// 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError)
|
|
||||||
// are promoted via errcompat to their typed errs/ counterparts, with the
|
|
||||||
// original preserved in the Cause chain.
|
|
||||||
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
|
|
||||||
// *errs.SecurityPolicyError, *errs.AuthenticationError): render via the
|
|
||||||
// typed envelope writer, which lifts extension fields (missing_scopes,
|
|
||||||
// console_url, challenge_url, ...) to the top level. Routed by
|
|
||||||
// errs.CategoryOf via ExitCodeOf.
|
|
||||||
// 3. Legacy *output.ExitError: asExitError adapts it to the legacy
|
|
||||||
// envelope, written via WriteErrorEnvelope.
|
|
||||||
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
|
|
||||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||||
errOut := f.IOStreams.ErrOut
|
errOut := f.IOStreams.ErrOut
|
||||||
|
|
||||||
// Promote legacy error shapes into typed errs/ before envelope marshal.
|
// SecurityPolicyError uses a custom envelope format (string codes, challenge_url, retryable)
|
||||||
// NeedAuthorizationError check is first because it is the more specific
|
// that differs from the standard ErrDetail, so it's handled separately.
|
||||||
// shape; *core.ConfigError check follows. errors.As preserves the original
|
var spErr *internalauth.SecurityPolicyError
|
||||||
// in the Cause chain, so external errors.As(&core.ConfigError{}) consumers
|
if errors.As(err, &spErr) {
|
||||||
// (cmd/auth/list.go, cmd/doctor/doctor.go, ...) still match.
|
writeSecurityPolicyError(errOut, spErr)
|
||||||
//
|
return 1
|
||||||
// Outer-typed short-circuit: if err is already a typed *errs.* error,
|
|
||||||
// skip PromoteXxxError so the producer's Subtype / Hint / extension
|
|
||||||
// fields are not overwritten by a coarser promoted shape derived from a
|
|
||||||
// legacy error buried in its Cause chain. Promotion is only for legacy
|
|
||||||
// untyped entry points.
|
|
||||||
if !isOuterTypedError(err) {
|
|
||||||
var needAuthErr *internalauth.NeedAuthorizationError
|
|
||||||
if errors.As(err, &needAuthErr) {
|
|
||||||
err = errcompat.PromoteAuthError(needAuthErr)
|
|
||||||
} else {
|
|
||||||
var cfgErr *core.ConfigError
|
|
||||||
if errors.As(err, &cfgErr) {
|
|
||||||
err = errcompat.PromoteConfigError(cfgErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// When the typed error is a need_user_authorization signal, fold in the
|
|
||||||
// current command's declared scopes as a Hint so the user/AI sees the
|
|
||||||
// concrete scope(s) to re-auth with. The hint is computed on the fly from
|
|
||||||
// local shortcut/service metadata — it never depends on server state.
|
|
||||||
applyNeedAuthorizationHint(f, err)
|
|
||||||
|
|
||||||
// Staged dispatch: capture the typed exit code BEFORE attempting the
|
|
||||||
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire
|
|
||||||
// (partial-write still returns true) so the exit code we read here is
|
|
||||||
// preserved even if stderr is torn — torn stderr must not downgrade
|
|
||||||
// typed exits 3/4/6/10 to the legacy "Error:" path with exit 1.
|
|
||||||
// WriteTypedErrorEnvelope still returns false when err carries no
|
|
||||||
// Problem; in that case we fall through to the legacy bridge below.
|
|
||||||
typedExit := output.ExitCodeOf(err)
|
|
||||||
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
|
|
||||||
return typedExit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All other structured errors normalize to ExitError.
|
||||||
if exitErr := asExitError(err); exitErr != nil {
|
if exitErr := asExitError(err); exitErr != nil {
|
||||||
if !exitErr.Raw {
|
if !exitErr.Raw {
|
||||||
// Raw errors (e.g. from `api` command via output.MarkRaw)
|
// Raw errors (e.g. from `api` command) preserve the original API
|
||||||
// preserve the original API error detail; skip enrichment
|
// error detail; skip enrichment which would clear it.
|
||||||
// which would clear it.
|
|
||||||
enrichMissingScopeError(f, exitErr)
|
enrichMissingScopeError(f, exitErr)
|
||||||
enrichPermissionError(f, exitErr)
|
enrichPermissionError(f, exitErr)
|
||||||
}
|
}
|
||||||
@@ -267,23 +223,13 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
|||||||
return exitErr.Code
|
return exitErr.Code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cobra errors (required flags, unknown commands, etc.)
|
||||||
fmt.Fprintln(errOut, "Error:", err)
|
fmt.Fprintln(errOut, "Error:", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// isOuterTypedError returns true if err is a typed *errs.* error AT THE
|
|
||||||
// TOP OF THE CHAIN (not buried inside Unwrap). Used by handleRootError
|
|
||||||
// to gate PromoteXxxError so a producer's outer typed envelope is never
|
|
||||||
// overwritten by a coarser shape derived from its legacy Cause.
|
|
||||||
func isOuterTypedError(err error) bool {
|
|
||||||
_, ok := err.(errs.TypedError)
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// asExitError converts known structured error types to *output.ExitError.
|
// asExitError converts known structured error types to *output.ExitError.
|
||||||
// Returns nil for unrecognized errors (e.g. cobra flag errors).
|
// Returns nil for unrecognized errors (e.g. cobra flag errors).
|
||||||
//
|
|
||||||
// Deprecated: legacy *output.ExitError bridge.
|
|
||||||
func asExitError(err error) *output.ExitError {
|
func asExitError(err error) *output.ExitError {
|
||||||
var cfgErr *core.ConfigError
|
var cfgErr *core.ConfigError
|
||||||
if errors.As(err, &cfgErr) {
|
if errors.As(err, &cfgErr) {
|
||||||
@@ -296,6 +242,49 @@ func asExitError(err error) *output.ExitError {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeSecurityPolicyError writes the security-policy-specific JSON envelope to w.
|
||||||
|
// This format intentionally differs from the standard ErrDetail envelope:
|
||||||
|
// it uses string codes ("challenge_required"/"access_denied") and extra fields
|
||||||
|
// (retryable, challenge_url) for machine-readable policy error handling.
|
||||||
|
func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyError) {
|
||||||
|
var codeStr string
|
||||||
|
switch spErr.Code {
|
||||||
|
case internalauth.LarkErrBlockByPolicyTryAuth:
|
||||||
|
codeStr = "challenge_required"
|
||||||
|
case internalauth.LarkErrBlockByPolicy:
|
||||||
|
codeStr = "access_denied"
|
||||||
|
default:
|
||||||
|
codeStr = strconv.Itoa(spErr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
errData := map[string]interface{}{
|
||||||
|
"type": "auth_error",
|
||||||
|
"code": codeStr,
|
||||||
|
"message": spErr.Message,
|
||||||
|
"retryable": false,
|
||||||
|
}
|
||||||
|
if spErr.ChallengeURL != "" {
|
||||||
|
errData["challenge_url"] = spErr.ChallengeURL
|
||||||
|
}
|
||||||
|
if spErr.CLIHint != "" {
|
||||||
|
errData["hint"] = spErr.CLIHint
|
||||||
|
}
|
||||||
|
|
||||||
|
env := map[string]interface{}{"ok": false, "error": errData}
|
||||||
|
|
||||||
|
buffer := &bytes.Buffer{}
|
||||||
|
encoder := json.NewEncoder(buffer)
|
||||||
|
encoder.SetEscapeHTML(false)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
err := encoder.Encode(env)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(w, `{"ok":false,"error":{"type":"internal_error","code":"marshal_error","message":"failed to marshal error"}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, buffer.String())
|
||||||
|
}
|
||||||
|
|
||||||
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
||||||
// group commands (no Run/RunE) with an unknown_subcommand error.
|
// group commands (no Run/RunE) with an unknown_subcommand error.
|
||||||
//
|
//
|
||||||
@@ -318,13 +307,6 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that
|
|
||||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
|
||||||
// add producers of this shape — unknown-subcommand signals should move to
|
|
||||||
// a typed *errs.ValidationError (or a dedicated typed error) carrying the
|
|
||||||
// agent-protocol metadata as typed extension fields. This helper is retained
|
|
||||||
// only while existing dispatch sites are migrated; it will be removed once
|
|
||||||
// they have moved to the typed surface.
|
|
||||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return cmd.Help()
|
return cmd.Help()
|
||||||
@@ -399,55 +381,97 @@ func installTipsHelpFunc(root *cobra.Command) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// enrichPermissionError rewrites the legacy *output.ExitError envelope so its
|
// enrichPermissionError adds console_url and improves the hint for permission errors.
|
||||||
// Message + Hint match the per-subtype canonical text produced by the typed
|
// It differentiates between:
|
||||||
// dispatcher path (errclass.CanonicalPermissionMessage / errclass.PermissionHint).
|
// - LarkErrAppScopeNotEnabled (99991672): app has not enabled the API scope → hint to admin console
|
||||||
// This guarantees a caller observing the wire envelope cannot tell whether
|
// - LarkErrUserScopeInsufficient (99991679): user has not authorized the scope → hint to auth login --scope
|
||||||
// the error reached the dispatcher via the legacy *ExitError bridge or via
|
|
||||||
// the typed *errs.PermissionError fast path.
|
|
||||||
//
|
|
||||||
// Deprecated: legacy *output.ExitError enrichment; typed PermissionError
|
|
||||||
// values produced by errclass.BuildAPIError already carry MissingScopes +
|
|
||||||
// ConsoleURL directly.
|
|
||||||
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||||
if exitErr.Detail == nil {
|
if exitErr.Detail == nil || exitErr.Detail.Type != "permission" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Only the legacy permission-class envelope types route here. "app_status"
|
// Extract required scopes from API error detail
|
||||||
// covers 99991662 (app_disabled) / 99991673 (app_unavailable); "permission"
|
scopes := extractRequiredScopes(exitErr.Detail.Detail)
|
||||||
// covers the four scope-class codes (99991672 / 99991676 / 99991679 / 230027).
|
if len(scopes) == 0 {
|
||||||
if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "app_status" {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
larkCode := exitErr.Detail.Code
|
|
||||||
meta, ok := errclass.LookupCodeMeta(larkCode)
|
|
||||||
if !ok || meta.Category != errs.CategoryAuthorization {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract required scopes from API error detail (shared helper). May be
|
|
||||||
// empty for app-status codes — canonical message + hint still apply.
|
|
||||||
missing := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
|
|
||||||
|
|
||||||
cfg, err := f.Config()
|
cfg, err := f.Config()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse the same console URL builder as the typed path so both wire
|
// Select the recommended (least-privilege) scope
|
||||||
// envelopes carry identical console_url values for the same input.
|
scopeIfaces := make([]interface{}, len(scopes))
|
||||||
consoleURL := errclass.ConsoleURL(string(cfg.Brand), cfg.AppID, missing)
|
for i, s := range scopes {
|
||||||
|
scopeIfaces[i] = s
|
||||||
// Clear raw API detail — useful info is now in message/hint/console_url.
|
}
|
||||||
exitErr.Detail.Detail = nil
|
recommended := registry.SelectRecommendedScope(scopeIfaces, "tenant")
|
||||||
|
if recommended == "" {
|
||||||
identity := string(f.ResolvedIdentity)
|
recommended = scopes[0]
|
||||||
if identity == "" {
|
|
||||||
identity = "user"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exitErr.Detail.Message = errclass.CanonicalPermissionMessage(meta.Subtype, cfg.AppID, missing, exitErr.Detail.Message)
|
// Build admin console URL with the recommended scope
|
||||||
exitErr.Detail.Hint = errclass.PermissionHint(missing, identity, meta.Subtype, consoleURL)
|
host := "open.feishu.cn"
|
||||||
exitErr.Detail.ConsoleURL = consoleURL
|
if cfg.Brand == "lark" {
|
||||||
|
host = "open.larksuite.com"
|
||||||
|
}
|
||||||
|
consoleURL := fmt.Sprintf("https://%s/page/scope-apply?clientID=%s&scopes=%s", host, url.QueryEscape(cfg.AppID), url.QueryEscape(recommended))
|
||||||
|
|
||||||
|
// Clear raw API detail — useful info is now in message/hint/console_url
|
||||||
|
exitErr.Detail.Detail = nil
|
||||||
|
|
||||||
|
isBot := f.ResolvedIdentity.IsBot()
|
||||||
|
|
||||||
|
larkCode := exitErr.Detail.Code
|
||||||
|
switch larkCode {
|
||||||
|
case output.LarkErrUserScopeInsufficient, output.LarkErrUserNotAuthorized:
|
||||||
|
// User has not authorized the scope → re-authorize
|
||||||
|
exitErr.Detail.Message = fmt.Sprintf("User not authorized: required scope %s [%d]", recommended, larkCode)
|
||||||
|
if isBot {
|
||||||
|
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
|
||||||
|
} else {
|
||||||
|
exitErr.Detail.Hint = 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.", recommended)
|
||||||
|
}
|
||||||
|
exitErr.Detail.ConsoleURL = consoleURL
|
||||||
|
|
||||||
|
case output.LarkErrAppScopeNotEnabled:
|
||||||
|
// App has not enabled the API scope → admin console
|
||||||
|
exitErr.Detail.Message = fmt.Sprintf("App scope not enabled: required scope %s [%d]", recommended, larkCode)
|
||||||
|
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
|
||||||
|
exitErr.Detail.ConsoleURL = consoleURL
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Other permission errors (matched by keyword)
|
||||||
|
exitErr.Detail.Message = fmt.Sprintf("Permission denied: required scope %s [%d]", recommended, larkCode)
|
||||||
|
if isBot {
|
||||||
|
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
|
||||||
|
} else {
|
||||||
|
exitErr.Detail.Hint = fmt.Sprintf(
|
||||||
|
"enable scope in console (see console_url), or 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.", recommended)
|
||||||
|
}
|
||||||
|
exitErr.Detail.ConsoleURL = consoleURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractRequiredScopes extracts scope names from the API error's permission_violations field.
|
||||||
|
func extractRequiredScopes(detail interface{}) []string {
|
||||||
|
m, ok := detail.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
violations, ok := m["permission_violations"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var scopes []string
|
||||||
|
for _, v := range violations {
|
||||||
|
vm, ok := v.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if subject, ok := vm["subject"].(string); ok {
|
||||||
|
scopes = append(scopes, subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scopes
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,8 +161,160 @@ func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
|
|||||||
stderr.Reset()
|
stderr.Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- api command ---
|
||||||
|
|
||||||
|
func TestIntegration_Api_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||||
|
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/im/v1/messages",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 230002,
|
||||||
|
"msg": "Bot/User can NOT be out of the chat.",
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"log_id": "test-log-id-001",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
rootCmd := buildIntegrationRootCmd(t, f)
|
||||||
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||||
|
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
|
||||||
|
"--params", `{"receive_id_type":"chat_id"}`,
|
||||||
|
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// api uses MarkRaw: detail preserved, no enrichment
|
||||||
|
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||||
|
OK: false,
|
||||||
|
Identity: "bot",
|
||||||
|
Error: &output.ErrDetail{
|
||||||
|
Type: "api_error",
|
||||||
|
Code: 230002,
|
||||||
|
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
|
||||||
|
Detail: map[string]interface{}{
|
||||||
|
"log_id": "test-log-id-001",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_Api_PermissionError_NotEnriched(t *testing.T) {
|
||||||
|
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/test/perm",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 99991672,
|
||||||
|
"msg": "scope not enabled for this app",
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"permission_violations": []interface{}{
|
||||||
|
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||||
|
},
|
||||||
|
"log_id": "test-log-id-perm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
rootCmd := buildIntegrationRootCmd(t, f)
|
||||||
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||||
|
"api", "--as", "bot", "GET", "/open-apis/test/perm",
|
||||||
|
})
|
||||||
|
|
||||||
|
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
|
||||||
|
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||||
|
OK: false,
|
||||||
|
Identity: "bot",
|
||||||
|
Error: &output.ErrDetail{
|
||||||
|
Type: "permission",
|
||||||
|
Code: 99991672,
|
||||||
|
Message: "Permission denied [99991672]",
|
||||||
|
Hint: "check app permissions or re-authorize: lark-cli auth login",
|
||||||
|
Detail: map[string]interface{}{
|
||||||
|
"permission_violations": []interface{}{
|
||||||
|
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||||
|
},
|
||||||
|
"log_id": "test-log-id-perm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// --- service command ---
|
// --- service command ---
|
||||||
|
|
||||||
|
func TestIntegration_Service_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||||
|
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/im/v1/chats/oc_fake",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 99992356,
|
||||||
|
"msg": "id not exist",
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"log_id": "test-log-id-svc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
rootCmd := buildIntegrationRootCmd(t, f)
|
||||||
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||||
|
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
|
||||||
|
})
|
||||||
|
|
||||||
|
// service: no MarkRaw, non-permission error — detail preserved
|
||||||
|
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||||
|
OK: false,
|
||||||
|
Identity: "bot",
|
||||||
|
Error: &output.ErrDetail{
|
||||||
|
Type: "api_error",
|
||||||
|
Code: 99992356,
|
||||||
|
Message: "API error: [99992356] id not exist",
|
||||||
|
Detail: map[string]interface{}{
|
||||||
|
"log_id": "test-log-id-svc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_Service_PermissionError_Enriched(t *testing.T) {
|
||||||
|
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/im/v1/chats/oc_test",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 99991672,
|
||||||
|
"msg": "scope not enabled",
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"permission_violations": []interface{}{
|
||||||
|
map[string]interface{}{"subject": "im:chat:readonly"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
rootCmd := buildIntegrationRootCmd(t, f)
|
||||||
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||||
|
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
|
||||||
|
})
|
||||||
|
|
||||||
|
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
|
||||||
|
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||||
|
OK: false,
|
||||||
|
Identity: "bot",
|
||||||
|
Error: &output.ErrDetail{
|
||||||
|
Type: "permission",
|
||||||
|
Code: 99991672,
|
||||||
|
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
|
||||||
|
Hint: "enable the scope in developer console (see console_url)",
|
||||||
|
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) {
|
func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) {
|
||||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||||
@@ -281,7 +433,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
|||||||
OK: false,
|
OK: false,
|
||||||
Identity: "bot",
|
Identity: "bot",
|
||||||
Error: &output.ErrDetail{
|
Error: &output.ErrDetail{
|
||||||
Type: "validation",
|
Type: "command_denied",
|
||||||
Message: `strict mode is "user", only user-identity commands are available`,
|
Message: `strict mode is "user", only user-identity commands are available`,
|
||||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||||
},
|
},
|
||||||
@@ -300,7 +452,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
|||||||
OK: false,
|
OK: false,
|
||||||
Identity: "user",
|
Identity: "user",
|
||||||
Error: &output.ErrDetail{
|
Error: &output.ErrDetail{
|
||||||
Type: "validation",
|
Type: "command_denied",
|
||||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||||
},
|
},
|
||||||
@@ -345,7 +497,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
|
|||||||
OK: false,
|
OK: false,
|
||||||
Identity: "user",
|
Identity: "user",
|
||||||
Error: &output.ErrDetail{
|
Error: &output.ErrDetail{
|
||||||
Type: "validation",
|
Type: "command_denied",
|
||||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||||
},
|
},
|
||||||
@@ -372,7 +524,7 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
|||||||
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
||||||
})
|
})
|
||||||
|
|
||||||
// shortcut: typed error via DoAPIJSON path
|
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
|
||||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||||
OK: false,
|
OK: false,
|
||||||
Identity: "bot",
|
Identity: "bot",
|
||||||
@@ -384,8 +536,11 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSetupNotices_ColdStart_NoNotice verifies that missing state
|
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
|
||||||
// produces no skills key in the composed notice.
|
// produces no skills key in the composed notice. Users who installed
|
||||||
|
// skills via `npx skills add` (no stamp) must not see the misleading
|
||||||
|
// "not installed" notice — only `lark-cli update` users opt into the
|
||||||
|
// drift tracker.
|
||||||
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||||
clearNoticeEnv(t)
|
clearNoticeEnv(t)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -416,13 +571,13 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSetupNotices_InSync verifies that matching state produces no
|
// TestSetupNotices_InSync verifies that a matching stamp produces no
|
||||||
// skills key in the composed notice.
|
// skills key in the composed notice.
|
||||||
func TestSetupNotices_InSync(t *testing.T) {
|
func TestSetupNotices_InSync(t *testing.T) {
|
||||||
clearNoticeEnv(t)
|
clearNoticeEnv(t)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,13 +604,13 @@ func TestSetupNotices_InSync(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSetupNotices_Drift verifies mismatching state produces the
|
// TestSetupNotices_Drift verifies a mismatching stamp produces the
|
||||||
// drift message with both current and target populated.
|
// drift message with both current and target populated.
|
||||||
func TestSetupNotices_Drift(t *testing.T) {
|
func TestSetupNotices_Drift(t *testing.T) {
|
||||||
clearNoticeEnv(t)
|
clearNoticeEnv(t)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,7 +659,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
|||||||
clearNoticeEnv(t)
|
clearNoticeEnv(t)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
718
cmd/root_test.go
718
cmd/root_test.go
@@ -4,25 +4,19 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/cmd/api"
|
"github.com/larksuite/cli/cmd/api"
|
||||||
"github.com/larksuite/cli/cmd/auth"
|
"github.com/larksuite/cli/cmd/auth"
|
||||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||||
"github.com/larksuite/cli/cmd/schema"
|
"github.com/larksuite/cli/cmd/schema"
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
internalauth "github.com/larksuite/cli/internal/auth"
|
internalauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
|
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
|
||||||
@@ -74,6 +68,273 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleRootError_RawError_SkipsEnrichmentButWritesEnvelope(t *testing.T) {
|
||||||
|
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a permission error (would normally be enriched) and mark it Raw
|
||||||
|
err := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "API error: [99991672] scope not enabled", map[string]interface{}{
|
||||||
|
"permission_violations": []interface{}{
|
||||||
|
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
err.Raw = true
|
||||||
|
|
||||||
|
code := handleRootError(f, err)
|
||||||
|
if code != output.ExitAPI {
|
||||||
|
t.Errorf("expected exit code %d, got %d", output.ExitAPI, code)
|
||||||
|
}
|
||||||
|
// stderr should contain the error envelope
|
||||||
|
if stderr.Len() == 0 {
|
||||||
|
t.Error("expected non-empty stderr for Raw error — WriteErrorEnvelope should always run")
|
||||||
|
}
|
||||||
|
// The message should NOT have been enriched by enrichPermissionError
|
||||||
|
// (ErrAPI sets "Permission denied [code]" but enrichment would replace it with "App scope not enabled: ...")
|
||||||
|
if strings.Contains(err.Error(), "App scope not enabled") {
|
||||||
|
t.Errorf("expected message not enriched, got: %s", err.Error())
|
||||||
|
}
|
||||||
|
// Detail.Detail should be preserved (enrichPermissionError clears it to nil)
|
||||||
|
if err.Detail != nil && err.Detail.Detail == nil {
|
||||||
|
t.Error("expected Detail.Detail to be preserved, but it was cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRootError_NonRawError_EnrichesAndWritesEnvelope(t *testing.T) {
|
||||||
|
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a permission error without Raw — should be enriched
|
||||||
|
err := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "API error: [99991672] scope not enabled", map[string]interface{}{
|
||||||
|
"permission_violations": []interface{}{
|
||||||
|
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
code := handleRootError(f, err)
|
||||||
|
if code != output.ExitAPI {
|
||||||
|
t.Errorf("expected exit code %d, got %d", output.ExitAPI, code)
|
||||||
|
}
|
||||||
|
// stderr should contain the error envelope
|
||||||
|
if stderr.Len() == 0 {
|
||||||
|
t.Error("expected non-empty stderr for non-Raw error")
|
||||||
|
}
|
||||||
|
// The message should have been enriched
|
||||||
|
if !strings.Contains(err.Error(), "App scope not enabled") {
|
||||||
|
t.Errorf("expected enriched message, got: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
appID string
|
||||||
|
scope string
|
||||||
|
wantInURL string // substring that must appear in console_url
|
||||||
|
denyInURL string // substring that must NOT appear raw in console_url
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ampersand in scope",
|
||||||
|
appID: "cli_good",
|
||||||
|
scope: "scope&evil=injected",
|
||||||
|
wantInURL: "scopes=scope%26evil%3Dinjected",
|
||||||
|
denyInURL: "scopes=scope&evil=injected",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hash in scope",
|
||||||
|
appID: "cli_good",
|
||||||
|
scope: "scope#fragment",
|
||||||
|
wantInURL: "scopes=scope%23fragment",
|
||||||
|
denyInURL: "scopes=scope#fragment",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "space in scope",
|
||||||
|
appID: "cli_good",
|
||||||
|
scope: "scope with spaces",
|
||||||
|
wantInURL: "scopes=scope+with+spaces",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "special chars in appID",
|
||||||
|
appID: "app&id=bad",
|
||||||
|
scope: "calendar:calendar:readonly",
|
||||||
|
wantInURL: "clientID=app%26id%3Dbad",
|
||||||
|
denyInURL: "clientID=app&id=bad",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: tt.appID, AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
exitErr := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "scope not enabled", map[string]interface{}{
|
||||||
|
"permission_violations": []interface{}{
|
||||||
|
map[string]interface{}{"subject": tt.scope},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
handleRootError(f, exitErr)
|
||||||
|
|
||||||
|
consoleURL := exitErr.Detail.ConsoleURL
|
||||||
|
if consoleURL == "" {
|
||||||
|
t.Fatal("expected console_url to be set")
|
||||||
|
}
|
||||||
|
if !strings.Contains(consoleURL, tt.wantInURL) {
|
||||||
|
t.Errorf("console_url missing expected escaped value\n want substring: %s\n got url: %s", tt.wantInURL, consoleURL)
|
||||||
|
}
|
||||||
|
if tt.denyInURL != "" && strings.Contains(consoleURL, tt.denyInURL) {
|
||||||
|
t.Errorf("console_url contains unescaped dangerous value\n deny substring: %s\n got url: %s", tt.denyInURL, consoleURL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichMissingScopeError_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
f.ResolvedIdentity = core.AsUser
|
||||||
|
|
||||||
|
var target registry.CommandEntry
|
||||||
|
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
||||||
|
if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" {
|
||||||
|
target = entry
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target.Command == "" {
|
||||||
|
t.Fatal("failed to locate a calendar create command in local registry metadata")
|
||||||
|
}
|
||||||
|
parts := strings.Split(target.Command, " ")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
t.Fatalf("expected resource/method command, got %q", target.Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
|
serviceCmd := &cobra.Command{Use: "calendar"}
|
||||||
|
resourceCmd := &cobra.Command{Use: parts[0]}
|
||||||
|
methodCmd := &cobra.Command{Use: parts[1]}
|
||||||
|
root.AddCommand(serviceCmd)
|
||||||
|
serviceCmd.AddCommand(resourceCmd)
|
||||||
|
resourceCmd.AddCommand(methodCmd)
|
||||||
|
f.CurrentCommand = methodCmd
|
||||||
|
|
||||||
|
exitErr := output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||||
|
enrichMissingScopeError(f, exitErr)
|
||||||
|
|
||||||
|
if exitErr.Code != output.ExitAPI {
|
||||||
|
t.Fatalf("expected exit code %d, got %d", output.ExitAPI, exitErr.Code)
|
||||||
|
}
|
||||||
|
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
||||||
|
t.Fatalf("expected api_error detail, got %+v", exitErr.Detail)
|
||||||
|
}
|
||||||
|
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
|
||||||
|
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): calendar:calendar.event:create") {
|
||||||
|
t.Fatalf("expected scope guidance in hint, got %q", exitErr.Detail.Hint)
|
||||||
|
}
|
||||||
|
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
|
||||||
|
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
|
||||||
|
}
|
||||||
|
if exitErr.Detail.Detail != nil {
|
||||||
|
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
f.ResolvedIdentity = core.AsUser
|
||||||
|
|
||||||
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
|
serviceCmd := &cobra.Command{Use: "docs"}
|
||||||
|
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||||
|
root.AddCommand(serviceCmd)
|
||||||
|
serviceCmd.AddCommand(shortcutCmd)
|
||||||
|
f.CurrentCommand = shortcutCmd
|
||||||
|
|
||||||
|
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||||
|
enrichMissingScopeError(f, exitErr)
|
||||||
|
|
||||||
|
if exitErr.Code != output.ExitNetwork {
|
||||||
|
t.Fatalf("expected exit code %d, got %d", output.ExitNetwork, exitErr.Code)
|
||||||
|
}
|
||||||
|
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||||
|
t.Fatalf("expected network detail, got %+v", exitErr.Detail)
|
||||||
|
}
|
||||||
|
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
|
||||||
|
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): docx:document:create") {
|
||||||
|
t.Fatalf("expected shortcut scope hint, got %q", exitErr.Detail.Hint)
|
||||||
|
}
|
||||||
|
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
|
||||||
|
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
|
||||||
|
}
|
||||||
|
if exitErr.Detail.Detail != nil {
|
||||||
|
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichMissingScopeError_ShortcutIncludesConditionalScopes(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
f.ResolvedIdentity = core.AsUser
|
||||||
|
|
||||||
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
|
serviceCmd := &cobra.Command{Use: "drive"}
|
||||||
|
shortcutCmd := &cobra.Command{Use: "+status"}
|
||||||
|
root.AddCommand(serviceCmd)
|
||||||
|
serviceCmd.AddCommand(shortcutCmd)
|
||||||
|
f.CurrentCommand = shortcutCmd
|
||||||
|
|
||||||
|
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||||
|
enrichMissingScopeError(f, exitErr)
|
||||||
|
|
||||||
|
if exitErr.Detail == nil {
|
||||||
|
t.Fatal("expected error detail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
|
||||||
|
t.Fatalf("expected conditional scope hint for drive +status, got %q", exitErr.Detail.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
f.ResolvedIdentity = core.AsUser
|
||||||
|
|
||||||
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
|
serviceCmd := &cobra.Command{Use: "docs"}
|
||||||
|
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||||
|
root.AddCommand(serviceCmd)
|
||||||
|
serviceCmd.AddCommand(shortcutCmd)
|
||||||
|
f.CurrentCommand = shortcutCmd
|
||||||
|
|
||||||
|
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||||
|
exitErr.Detail.Hint = "existing hint"
|
||||||
|
enrichMissingScopeError(f, exitErr)
|
||||||
|
|
||||||
|
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
|
||||||
|
if exitErr.Detail.Hint != want {
|
||||||
|
t.Fatalf("expected appended hint %q, got %q", want, exitErr.Detail.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||||
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
||||||
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
||||||
@@ -135,446 +396,3 @@ func TestIsCompletionCommand(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPromoteConfigError_* lives with the implementation in
|
|
||||||
// internal/errcompat/promote_test.go.
|
|
||||||
|
|
||||||
// TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that
|
|
||||||
// *errs.SecurityPolicyError flows through the canonical typed envelope
|
|
||||||
// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype,
|
|
||||||
// top-level identity, exit code 6 — after the dispatcher carve-out is removed.
|
|
||||||
func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
t.Run("21000 challenge_required", func(t *testing.T) {
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
errOut := &bytes.Buffer{}
|
|
||||||
f.IOStreams.ErrOut = errOut
|
|
||||||
|
|
||||||
spErr := &errs.SecurityPolicyError{
|
|
||||||
Problem: errs.Problem{
|
|
||||||
Category: errs.CategoryPolicy,
|
|
||||||
Subtype: errs.SubtypeChallengeRequired,
|
|
||||||
Code: 21000,
|
|
||||||
Message: "blocked by access policy",
|
|
||||||
Hint: "complete challenge in your browser",
|
|
||||||
},
|
|
||||||
ChallengeURL: "https://example.com/challenge",
|
|
||||||
}
|
|
||||||
|
|
||||||
gotExit := handleRootError(f, spErr)
|
|
||||||
if gotExit != int(output.ExitContentSafety) {
|
|
||||||
t.Errorf("exit code = %d, want %d (ExitContentSafety)", gotExit, output.ExitContentSafety)
|
|
||||||
}
|
|
||||||
|
|
||||||
var env map[string]any
|
|
||||||
if err := json.Unmarshal(errOut.Bytes(), &env); err != nil {
|
|
||||||
t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String())
|
|
||||||
}
|
|
||||||
errObj, ok := env["error"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("envelope missing top-level error object: %s", errOut.String())
|
|
||||||
}
|
|
||||||
if got := errObj["type"]; got != "policy" {
|
|
||||||
t.Errorf("error.type = %v, want %q", got, "policy")
|
|
||||||
}
|
|
||||||
if got := errObj["subtype"]; got != "challenge_required" {
|
|
||||||
t.Errorf("error.subtype = %v, want %q", got, "challenge_required")
|
|
||||||
}
|
|
||||||
if got, ok := errObj["code"].(float64); !ok || int(got) != 21000 {
|
|
||||||
t.Errorf("error.code = %v (%T), want 21000 (number)", errObj["code"], errObj["code"])
|
|
||||||
}
|
|
||||||
if got := errObj["challenge_url"]; got != "https://example.com/challenge" {
|
|
||||||
t.Errorf("error.challenge_url = %v, want challenge url", got)
|
|
||||||
}
|
|
||||||
if got := errObj["hint"]; got != "complete challenge in your browser" {
|
|
||||||
t.Errorf("error.hint = %v, want hint message", got)
|
|
||||||
}
|
|
||||||
if _, exists := errObj["retryable"]; exists {
|
|
||||||
t.Errorf("error.retryable leaked into canonical envelope: %v", errObj["retryable"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("21001 access_denied", func(t *testing.T) {
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
errOut := &bytes.Buffer{}
|
|
||||||
f.IOStreams.ErrOut = errOut
|
|
||||||
|
|
||||||
spErr := &errs.SecurityPolicyError{
|
|
||||||
Problem: errs.Problem{
|
|
||||||
Category: errs.CategoryPolicy,
|
|
||||||
Subtype: errs.SubtypeAccessDenied,
|
|
||||||
Code: 21001,
|
|
||||||
Message: "access denied",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
gotExit := handleRootError(f, spErr)
|
|
||||||
if gotExit != int(output.ExitContentSafety) {
|
|
||||||
t.Errorf("exit code = %d, want %d", gotExit, output.ExitContentSafety)
|
|
||||||
}
|
|
||||||
|
|
||||||
var env map[string]any
|
|
||||||
if err := json.Unmarshal(errOut.Bytes(), &env); err != nil {
|
|
||||||
t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String())
|
|
||||||
}
|
|
||||||
errObj := env["error"].(map[string]any)
|
|
||||||
if got := errObj["type"]; got != "policy" {
|
|
||||||
t.Errorf("error.type = %v, want %q", got, "policy")
|
|
||||||
}
|
|
||||||
if got := errObj["subtype"]; got != "access_denied" {
|
|
||||||
t.Errorf("error.subtype = %v, want %q", got, "access_denied")
|
|
||||||
}
|
|
||||||
if got, ok := errObj["code"].(float64); !ok || int(got) != 21001 {
|
|
||||||
t.Errorf("error.code = %v, want 21001 (number)", errObj["code"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// newAuthErrorWithNeedAuthMarker builds a typed *errs.AuthenticationError whose Message
|
|
||||||
// contains the need_user_authorization marker — the same shape that
|
|
||||||
// resolveAccessToken now produces when the credential chain returns
|
|
||||||
// *internalauth.NeedAuthorizationError.
|
|
||||||
func newAuthErrorWithNeedAuthMarker() *errs.AuthenticationError {
|
|
||||||
cause := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
|
|
||||||
return &errs.AuthenticationError{
|
|
||||||
Problem: errs.Problem{
|
|
||||||
Category: errs.CategoryAuthentication,
|
|
||||||
Subtype: errs.SubtypeUnknown,
|
|
||||||
Message: fmt.Sprintf("API call failed: %s", cause),
|
|
||||||
},
|
|
||||||
Cause: cause,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// failingWriter writes up to limit bytes then returns io.ErrShortWrite on
|
|
||||||
// the write that would push past the limit. Used to simulate a stderr that
|
|
||||||
// dies mid-envelope.
|
|
||||||
type failingWriter struct {
|
|
||||||
limit int
|
|
||||||
n int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *failingWriter) Write(p []byte) (int, error) {
|
|
||||||
if f.n+len(p) > f.limit {
|
|
||||||
canWrite := f.limit - f.n
|
|
||||||
if canWrite < 0 {
|
|
||||||
canWrite = 0
|
|
||||||
}
|
|
||||||
f.n += canWrite
|
|
||||||
return canWrite, io.ErrShortWrite
|
|
||||||
}
|
|
||||||
f.n += len(p)
|
|
||||||
return len(p), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
|
|
||||||
// stderr write fails mid-envelope, handleRootError still returns the typed
|
|
||||||
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
|
|
||||||
// plain "Error:" path with exit 1. ExitCodeOf is computed from the typed
|
|
||||||
// err BEFORE the envelope write so the exit code is preserved even when
|
|
||||||
// the consumer's stderr pipe dies.
|
|
||||||
func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
w := &failingWriter{limit: 20}
|
|
||||||
f.IOStreams.ErrOut = w
|
|
||||||
|
|
||||||
err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired")
|
|
||||||
exit := handleRootError(f, err)
|
|
||||||
if exit != int(output.ExitAuth) {
|
|
||||||
t.Errorf("exit = %d, want %d (typed exit code preserved despite write failure)", exit, int(output.ExitAuth))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleRootError_TypedOuterShortCircuitsPromote pins that when a typed
|
|
||||||
// *errs.AuthenticationError carries a legacy *NeedAuthorizationError in its
|
|
||||||
// Cause chain, the dispatcher does NOT run PromoteAuthError — doing so
|
|
||||||
// would replace the producer's TokenExpired subtype + custom hint with the
|
|
||||||
// promoted shape's TokenMissing.
|
|
||||||
func TestHandleRootError_TypedOuterShortCircuitsPromote(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
errOut := &bytes.Buffer{}
|
|
||||||
f.IOStreams.ErrOut = errOut
|
|
||||||
|
|
||||||
innerLegacy := &internalauth.NeedAuthorizationError{UserOpenId: "u_123"}
|
|
||||||
outer := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired").
|
|
||||||
WithHint("custom producer hint").
|
|
||||||
WithCause(innerLegacy)
|
|
||||||
|
|
||||||
exit := handleRootError(f, outer)
|
|
||||||
if exit != int(output.ExitAuth) {
|
|
||||||
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
|
|
||||||
}
|
|
||||||
got := errOut.String()
|
|
||||||
if !strings.Contains(got, `"subtype": "token_expired"`) {
|
|
||||||
t.Errorf("envelope lost producer Subtype TokenExpired; got %s", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "custom producer hint") {
|
|
||||||
t.Errorf("envelope lost producer Hint; got %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT pins
|
|
||||||
// that a typed AuthenticationError carrying the need_user_authorization marker gets a
|
|
||||||
// declared-scopes Hint appended when the current command is a registered
|
|
||||||
// service method.
|
|
||||||
func TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
f.ResolvedIdentity = core.AsUser
|
|
||||||
|
|
||||||
var target registry.CommandEntry
|
|
||||||
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
|
||||||
if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" {
|
|
||||||
target = entry
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if target.Command == "" {
|
|
||||||
t.Fatal("failed to locate a calendar create command in local registry metadata")
|
|
||||||
}
|
|
||||||
parts := strings.Split(target.Command, " ")
|
|
||||||
if len(parts) != 2 {
|
|
||||||
t.Fatalf("expected resource/method command, got %q", target.Command)
|
|
||||||
}
|
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
|
||||||
serviceCmd := &cobra.Command{Use: "calendar"}
|
|
||||||
resourceCmd := &cobra.Command{Use: parts[0]}
|
|
||||||
methodCmd := &cobra.Command{Use: parts[1]}
|
|
||||||
root.AddCommand(serviceCmd)
|
|
||||||
serviceCmd.AddCommand(resourceCmd)
|
|
||||||
resourceCmd.AddCommand(methodCmd)
|
|
||||||
f.CurrentCommand = methodCmd
|
|
||||||
|
|
||||||
authErr := newAuthErrorWithNeedAuthMarker()
|
|
||||||
applyNeedAuthorizationHint(f, authErr)
|
|
||||||
|
|
||||||
if authErr.Category != errs.CategoryAuthentication {
|
|
||||||
t.Errorf("Category = %q, want authentication", authErr.Category)
|
|
||||||
}
|
|
||||||
if !strings.Contains(authErr.Message, "need_user_authorization") {
|
|
||||||
t.Errorf("Message should preserve need_user_authorization marker; got %q", authErr.Message)
|
|
||||||
}
|
|
||||||
if !strings.Contains(authErr.Hint, "current command requires scope(s): calendar:calendar.event:create") {
|
|
||||||
t.Errorf("expected declared-scope hint, got %q", authErr.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT pins the
|
|
||||||
// same hint behavior for mounted shortcut commands.
|
|
||||||
func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
f.ResolvedIdentity = core.AsUser
|
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
|
||||||
serviceCmd := &cobra.Command{Use: "docs"}
|
|
||||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
|
||||||
root.AddCommand(serviceCmd)
|
|
||||||
serviceCmd.AddCommand(shortcutCmd)
|
|
||||||
f.CurrentCommand = shortcutCmd
|
|
||||||
|
|
||||||
authErr := newAuthErrorWithNeedAuthMarker()
|
|
||||||
applyNeedAuthorizationHint(f, authErr)
|
|
||||||
|
|
||||||
if !strings.Contains(authErr.Hint, "current command requires scope(s): docx:document:create") {
|
|
||||||
t.Errorf("expected shortcut scope hint, got %q", authErr.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes pins that
|
|
||||||
// conditional scopes declared on a shortcut surface in the hint.
|
|
||||||
func TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
f.ResolvedIdentity = core.AsUser
|
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
|
||||||
serviceCmd := &cobra.Command{Use: "drive"}
|
|
||||||
shortcutCmd := &cobra.Command{Use: "+status"}
|
|
||||||
root.AddCommand(serviceCmd)
|
|
||||||
serviceCmd.AddCommand(shortcutCmd)
|
|
||||||
f.CurrentCommand = shortcutCmd
|
|
||||||
|
|
||||||
authErr := newAuthErrorWithNeedAuthMarker()
|
|
||||||
applyNeedAuthorizationHint(f, authErr)
|
|
||||||
|
|
||||||
if !strings.Contains(authErr.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
|
|
||||||
t.Errorf("expected conditional scope hint for drive +status, got %q", authErr.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestApplyNeedAuthorizationHint_AppendsExistingHint pins that the
|
|
||||||
// declared-scopes guidance is appended (separated by newline) when the typed
|
|
||||||
// AuthenticationError already carries a Hint from elsewhere.
|
|
||||||
func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
f.ResolvedIdentity = core.AsUser
|
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
|
||||||
serviceCmd := &cobra.Command{Use: "docs"}
|
|
||||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
|
||||||
root.AddCommand(serviceCmd)
|
|
||||||
serviceCmd.AddCommand(shortcutCmd)
|
|
||||||
f.CurrentCommand = shortcutCmd
|
|
||||||
|
|
||||||
authErr := newAuthErrorWithNeedAuthMarker()
|
|
||||||
authErr.Hint = "existing hint"
|
|
||||||
applyNeedAuthorizationHint(f, authErr)
|
|
||||||
|
|
||||||
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
|
|
||||||
if authErr.Hint != want {
|
|
||||||
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEnrichPermissionError_CanonicalConvergence pins that the legacy
|
|
||||||
// *output.ExitError dispatch path produces the same canonical Message + Hint
|
|
||||||
// + ConsoleURL as the typed *errs.PermissionError dispatch path. Both paths
|
|
||||||
// share errclass.CanonicalPermissionMessage / errclass.PermissionHint /
|
|
||||||
// errclass.ConsoleURL — so a wire consumer cannot tell which path produced
|
|
||||||
// the envelope.
|
|
||||||
func TestEnrichPermissionError_CanonicalConvergence(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
larkCode int
|
|
||||||
legacyErrType string
|
|
||||||
wantMsgSubstrs []string
|
|
||||||
wantHintSubstrs []string
|
|
||||||
wantConsoleURL bool
|
|
||||||
wantNoAuthLogin bool // hint must not suggest `auth login`
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "99991672 app_scope_not_applied",
|
|
||||||
larkCode: 99991672,
|
|
||||||
legacyErrType: "permission",
|
|
||||||
wantMsgSubstrs: []string{"access denied", "app cli_test", "drive:drive:read"},
|
|
||||||
wantHintSubstrs: []string{"developer console", "open.feishu.cn"},
|
|
||||||
wantConsoleURL: true,
|
|
||||||
wantNoAuthLogin: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "99991679 missing_scope",
|
|
||||||
larkCode: 99991679,
|
|
||||||
legacyErrType: "permission",
|
|
||||||
wantMsgSubstrs: []string{"unauthorized", "user authorization"},
|
|
||||||
wantHintSubstrs: []string{"lark-cli auth login"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "99991673 app_unavailable",
|
|
||||||
larkCode: 99991673,
|
|
||||||
legacyErrType: "app_status",
|
|
||||||
wantMsgSubstrs: []string{"unauthorized app", "app cli_test", "not properly installed"},
|
|
||||||
wantHintSubstrs: []string{"tenant admin", "install status"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "99991662 app_disabled",
|
|
||||||
larkCode: 99991662,
|
|
||||||
legacyErrType: "app_status",
|
|
||||||
wantMsgSubstrs: []string{"app cli_test", "not in use", "currently disabled"},
|
|
||||||
wantHintSubstrs: []string{"tenant admin", "re-enable"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
f.ResolvedIdentity = core.AsUser
|
|
||||||
|
|
||||||
// Mimic the wire shape ErrAPI produces: legacy *ExitError with
|
|
||||||
// Detail.Type populated by ClassifyLarkError, Detail.Detail
|
|
||||||
// carrying the permission_violations block so ExtractRequiredScopes
|
|
||||||
// can recover the missing scope.
|
|
||||||
scopeForDetail := "drive:drive:read"
|
|
||||||
exitErr := &output.ExitError{
|
|
||||||
Code: output.ExitAPI,
|
|
||||||
Detail: &output.ErrDetail{
|
|
||||||
Type: tc.legacyErrType,
|
|
||||||
Code: tc.larkCode,
|
|
||||||
Message: "upstream raw message — must be replaced",
|
|
||||||
Detail: map[string]interface{}{
|
|
||||||
"permission_violations": []interface{}{
|
|
||||||
map[string]interface{}{"subject": scopeForDetail},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
enrichPermissionError(f, exitErr)
|
|
||||||
|
|
||||||
for _, sub := range tc.wantMsgSubstrs {
|
|
||||||
if !strings.Contains(exitErr.Detail.Message, sub) {
|
|
||||||
t.Errorf("Message %q missing substring %q", exitErr.Detail.Message, sub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if exitErr.Detail.Message == "upstream raw message — must be replaced" {
|
|
||||||
t.Errorf("Message must be rewritten to canonical text; got upstream verbatim")
|
|
||||||
}
|
|
||||||
for _, sub := range tc.wantHintSubstrs {
|
|
||||||
if !strings.Contains(exitErr.Detail.Hint, sub) {
|
|
||||||
t.Errorf("Hint %q missing substring %q", exitErr.Detail.Hint, sub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tc.wantNoAuthLogin && strings.Contains(exitErr.Detail.Hint, "auth login") {
|
|
||||||
t.Errorf("Hint must not suggest `auth login` for this subtype; got %q", exitErr.Detail.Hint)
|
|
||||||
}
|
|
||||||
if tc.wantConsoleURL && exitErr.Detail.ConsoleURL == "" {
|
|
||||||
t.Error("ConsoleURL should be populated when missing scopes are present")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEnrichPermissionError_SkipsUnrelatedTypes pins that an ExitError whose
|
|
||||||
// Detail.Type is neither "permission" nor "app_status" is left untouched —
|
|
||||||
// no Message rewrite, no Hint rewrite, no ConsoleURL injection.
|
|
||||||
func TestEnrichPermissionError_SkipsUnrelatedTypes(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
||||||
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
|
|
||||||
})
|
|
||||||
f.ResolvedIdentity = core.AsUser
|
|
||||||
|
|
||||||
for _, ty := range []string{"api_error", "validation", "rate_limit", "auth"} {
|
|
||||||
exitErr := &output.ExitError{
|
|
||||||
Code: output.ExitAPI,
|
|
||||||
Detail: &output.ErrDetail{
|
|
||||||
Type: ty,
|
|
||||||
Code: 99991400,
|
|
||||||
Message: "untouched",
|
|
||||||
Hint: "original hint",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
enrichPermissionError(f, exitErr)
|
|
||||||
if exitErr.Detail.Message != "untouched" {
|
|
||||||
t.Errorf("type=%q: Message was rewritten unexpectedly: %q", ty, exitErr.Detail.Message)
|
|
||||||
}
|
|
||||||
if exitErr.Detail.Hint != "original hint" {
|
|
||||||
t.Errorf("type=%q: Hint was rewritten unexpectedly: %q", ty, exitErr.Detail.Hint)
|
|
||||||
}
|
|
||||||
if exitErr.Detail.ConsoleURL != "" {
|
|
||||||
t.Errorf("type=%q: ConsoleURL should not be injected; got %q", ty, exitErr.Detail.ConsoleURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
"github.com/larksuite/cli/internal/schema"
|
|
||||||
"github.com/larksuite/cli/internal/util"
|
"github.com/larksuite/cli/internal/util"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -25,8 +24,7 @@ type SchemaOptions struct {
|
|||||||
Ctx context.Context
|
Ctx context.Context
|
||||||
|
|
||||||
// Positional args
|
// Positional args
|
||||||
Path string // first positional, when only one is given
|
Path string
|
||||||
ExtraArgs []string // 2nd+ positional args (space-separated form)
|
|
||||||
|
|
||||||
// Flags
|
// Flags
|
||||||
Format string
|
Format string
|
||||||
@@ -361,16 +359,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
|||||||
opts := &SchemaOptions{Factory: f}
|
opts := &SchemaOptions{Factory: f}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "schema [path | service resource method]",
|
Use: "schema [path]",
|
||||||
Short: "View API method parameters, types, and scopes",
|
Short: "View API method parameters, types, and scopes",
|
||||||
Args: cobra.MaximumNArgs(8),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
opts.Path = args[0]
|
opts.Path = args[0]
|
||||||
}
|
}
|
||||||
if len(args) > 1 {
|
|
||||||
opts.ExtraArgs = args[1:]
|
|
||||||
}
|
|
||||||
opts.Ctx = cmd.Context()
|
opts.Ctx = cmd.Context()
|
||||||
if runF != nil {
|
if runF != nil {
|
||||||
return runF(opts)
|
return runF(opts)
|
||||||
@@ -385,108 +380,60 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
|||||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
})
|
})
|
||||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
cmdutil.SetRisk(cmd, "read")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// completeSchemaPath provides tab-completion for the schema path argument.
|
// completeSchemaPath provides tab-completion for the schema path argument.
|
||||||
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
|
// It handles dotted resource names (e.g. app.table.fields) by iterating all
|
||||||
// newer space-separated form (e.g. `schema im messages reply`).
|
// resources and classifying each as a prefix-match or fully-matched.
|
||||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
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) {
|
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
mode := f.ResolveStrictMode(cmd.Context())
|
if len(args) > 0 {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
// Case 1: legacy "single dotted arg" path — no previous args yet
|
|
||||||
if len(args) == 0 {
|
|
||||||
parts := strings.Split(toComplete, ".")
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 2: space-form, args already has segments
|
parts := strings.Split(toComplete, ".")
|
||||||
// Walk down service -> resource(s) -> method based on existing args
|
|
||||||
serviceName := args[0]
|
// 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)
|
spec := registry.LoadFromMeta(serviceName)
|
||||||
if spec == nil {
|
if spec == nil {
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
|
mode := f.ResolveStrictMode(cmd.Context())
|
||||||
spec = filterSpecByStrictMode(spec, mode)
|
spec = filterSpecByStrictMode(spec, mode)
|
||||||
resources, _ := spec["resources"].(map[string]interface{})
|
resources, _ := spec["resources"].(map[string]interface{})
|
||||||
if resources == nil {
|
if resources == nil {
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
|
|
||||||
// args[1:] are resource path segments (possibly partial); current
|
afterService := strings.Join(parts[1:], ".")
|
||||||
// toComplete is the next segment under cursor.
|
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||||
consumed := args[1:]
|
|
||||||
resource, _, remaining := findResourceByPath(resources, consumed)
|
allTrailingDot := len(completions) > 0
|
||||||
if resource == nil {
|
for _, c := range completions {
|
||||||
// Suggest top-level resource names that match toComplete
|
if !strings.HasSuffix(c, ".") {
|
||||||
var completions []string
|
allTrailingDot = false
|
||||||
for resName := range resources {
|
break
|
||||||
if strings.HasPrefix(resName, toComplete) {
|
|
||||||
completions = append(completions, resName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(completions)
|
|
||||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
|
||||||
}
|
|
||||||
if len(remaining) > 0 {
|
|
||||||
// Already typed past the resource — suggest methods
|
|
||||||
methods, _ := resource["methods"].(map[string]interface{})
|
|
||||||
methods = filterMethodsByStrictMode(methods, mode)
|
|
||||||
var completions []string
|
|
||||||
for mName := range methods {
|
|
||||||
if strings.HasPrefix(mName, toComplete) {
|
|
||||||
completions = append(completions, mName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(completions)
|
|
||||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
|
||||||
}
|
|
||||||
// Resource matched exactly, suggest methods
|
|
||||||
methods, _ := resource["methods"].(map[string]interface{})
|
|
||||||
methods = filterMethodsByStrictMode(methods, mode)
|
|
||||||
var completions []string
|
|
||||||
for mName := range methods {
|
|
||||||
if strings.HasPrefix(mName, toComplete) {
|
|
||||||
completions = append(completions, mName)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(completions)
|
directive := cobra.ShellCompDirectiveNoFileComp
|
||||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
if allTrailingDot {
|
||||||
|
directive |= cobra.ShellCompDirectiveNoSpace
|
||||||
|
}
|
||||||
|
return completions, directive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,231 +469,94 @@ func schemaRun(opts *SchemaOptions) error {
|
|||||||
out := opts.Factory.IOStreams.Out
|
out := opts.Factory.IOStreams.Out
|
||||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||||
|
|
||||||
// args may have arrived as a single string (legacy single-arg path) or
|
if opts.Path == "" {
|
||||||
// split into multiple — normalize to a single args slice.
|
|
||||||
var rawArgs []string
|
|
||||||
if opts.Path != "" {
|
|
||||||
rawArgs = []string{opts.Path}
|
|
||||||
}
|
|
||||||
if len(opts.ExtraArgs) > 0 {
|
|
||||||
if opts.Path != "" {
|
|
||||||
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
|
|
||||||
} else {
|
|
||||||
rawArgs = append([]string(nil), opts.ExtraArgs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts := schema.ParsePath(rawArgs)
|
|
||||||
|
|
||||||
if opts.Format == "pretty" {
|
|
||||||
return runPrettyMode(out, parts, mode)
|
|
||||||
}
|
|
||||||
return runJSONMode(out, parts, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// runJSONMode dispatches list/single envelope output based on parts.
|
|
||||||
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
|
|
||||||
// output is deterministic across machines.
|
|
||||||
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
|
||||||
filter := strictModeFilter(mode)
|
|
||||||
|
|
||||||
switch len(parts) {
|
|
||||||
case 0:
|
|
||||||
envs := schema.AssembleAll(filter)
|
|
||||||
output.PrintJson(out, envs)
|
|
||||||
return nil
|
|
||||||
case 1:
|
|
||||||
spec := registry.EmbeddedSpec(parts[0])
|
|
||||||
if spec == nil {
|
|
||||||
return errUnknownEmbeddedService(parts[0])
|
|
||||||
}
|
|
||||||
envs := schema.AssembleService(parts[0], spec, filter)
|
|
||||||
output.PrintJson(out, envs)
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return runJSONForPath(out, parts, filter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
|
|
||||||
// to single-method match. Uses embedded data only.
|
|
||||||
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
|
|
||||||
serviceName := parts[0]
|
|
||||||
spec := registry.EmbeddedSpec(serviceName)
|
|
||||||
if spec == nil {
|
|
||||||
return errUnknownEmbeddedService(serviceName)
|
|
||||||
}
|
|
||||||
resources, _ := spec["resources"].(map[string]interface{})
|
|
||||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
|
||||||
if resource == nil {
|
|
||||||
var names []string
|
|
||||||
for k := range resources {
|
|
||||||
names = append(names, k)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
|
||||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
|
||||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
|
||||||
}
|
|
||||||
if len(remaining) == 0 {
|
|
||||||
// Resource-scoped envelope array
|
|
||||||
envs := assembleResource(serviceName, resName, resource, filter)
|
|
||||||
output.PrintJson(out, envs)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
methodName := remaining[0]
|
|
||||||
methods, _ := resource["methods"].(map[string]interface{})
|
|
||||||
method, ok := methods[methodName].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
var names []string
|
|
||||||
for k := range methods {
|
|
||||||
names = append(names, k)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
|
||||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
|
||||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
|
||||||
}
|
|
||||||
if len(remaining) > 1 {
|
|
||||||
// Method exists but caller appended extra segments — reject so they
|
|
||||||
// don't silently get this method's schema when they typo'd the path.
|
|
||||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
|
||||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
|
||||||
serviceName, resName, strings.Join(remaining, ".")),
|
|
||||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
|
||||||
methodName, strings.Join(remaining[1:], ".")))
|
|
||||||
}
|
|
||||||
if filter != nil && !filter(method) {
|
|
||||||
// Method exists in spec but filtered out by strict mode
|
|
||||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
|
||||||
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
|
|
||||||
"Use --as user / --as bot to switch")
|
|
||||||
}
|
|
||||||
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
|
|
||||||
output.PrintJson(out, env)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
|
|
||||||
methods, _ := resource["methods"].(map[string]interface{})
|
|
||||||
resourcePath := []string{resName}
|
|
||||||
var envs []schema.Envelope
|
|
||||||
for methodName, raw := range methods {
|
|
||||||
method, ok := raw.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if filter != nil && !filter(method) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
|
|
||||||
}
|
|
||||||
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
|
|
||||||
return envs
|
|
||||||
}
|
|
||||||
|
|
||||||
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
|
|
||||||
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
|
|
||||||
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
|
||||||
if len(parts) == 0 {
|
|
||||||
printServices(out)
|
printServices(out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(opts.Path, ".")
|
||||||
|
|
||||||
serviceName := parts[0]
|
serviceName := parts[0]
|
||||||
spec := registry.LoadFromMeta(serviceName)
|
spec := registry.LoadFromMeta(serviceName)
|
||||||
if spec == nil {
|
if spec == nil {
|
||||||
return errUnknownService(serviceName)
|
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||||
|
fmt.Sprintf("Unknown service: %s", serviceName),
|
||||||
|
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(parts) == 1 {
|
if len(parts) == 1 {
|
||||||
printResourceList(out, spec, mode)
|
if opts.Format == "pretty" {
|
||||||
|
printResourceList(out, spec, mode)
|
||||||
|
} else {
|
||||||
|
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
resources, _ := spec["resources"].(map[string]interface{})
|
resources, _ := spec["resources"].(map[string]interface{})
|
||||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||||
if resource == nil {
|
if resource == nil {
|
||||||
var names []string
|
var resNames []string
|
||||||
for k := range resources {
|
for k := range resources {
|
||||||
names = append(names, k)
|
resNames = append(resNames, k)
|
||||||
}
|
}
|
||||||
sort.Strings(names)
|
|
||||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
fmt.Sprintf("Available: %s", strings.Join(resNames, ", ")))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(remaining) == 0 {
|
if len(remaining) == 0 {
|
||||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
if opts.Format == "pretty" {
|
||||||
methods, _ := resource["methods"].(map[string]interface{})
|
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||||
methods = filterMethodsByStrictMode(methods, mode)
|
methods, _ := resource["methods"].(map[string]interface{})
|
||||||
for _, mName := range sortedKeys(methods) {
|
methods = filterMethodsByStrictMode(methods, mode)
|
||||||
m, _ := methods[mName].(map[string]interface{})
|
for _, mName := range sortedKeys(methods) {
|
||||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
m, _ := methods[mName].(map[string]interface{})
|
||||||
desc := registry.GetStrFromMap(m, "description")
|
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
desc := registry.GetStrFromMap(m, "description")
|
||||||
|
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||||
|
} else {
|
||||||
|
// For JSON output, filter methods in a copy to avoid mutating the registry.
|
||||||
|
if mode.IsActive() {
|
||||||
|
filtered := make(map[string]interface{})
|
||||||
|
for k, v := range resource {
|
||||||
|
filtered[k] = v
|
||||||
|
}
|
||||||
|
if methods, ok := resource["methods"].(map[string]interface{}); ok {
|
||||||
|
filtered["methods"] = filterMethodsByStrictMode(methods, mode)
|
||||||
|
}
|
||||||
|
output.PrintJson(out, filtered)
|
||||||
|
} else {
|
||||||
|
output.PrintJson(out, resource)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
methodName := remaining[0]
|
methodName := remaining[0]
|
||||||
methods, _ := resource["methods"].(map[string]interface{})
|
methods, _ := resource["methods"].(map[string]interface{})
|
||||||
methods = filterMethodsByStrictMode(methods, mode)
|
methods = filterMethodsByStrictMode(methods, mode)
|
||||||
method, ok := methods[methodName].(map[string]interface{})
|
method, ok := methods[methodName].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
var names []string
|
var mNames []string
|
||||||
for k := range methods {
|
for k := range methods {
|
||||||
names = append(names, k)
|
mNames = append(mNames, k)
|
||||||
}
|
}
|
||||||
sort.Strings(names)
|
|
||||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
fmt.Sprintf("Available: %s", strings.Join(mNames, ", ")))
|
||||||
}
|
}
|
||||||
if len(remaining) > 1 {
|
|
||||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
if opts.Format == "pretty" {
|
||||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
printMethodDetail(out, spec, resName, methodName, method)
|
||||||
serviceName, resName, strings.Join(remaining, ".")),
|
} else {
|
||||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
output.PrintJson(out, method)
|
||||||
methodName, strings.Join(remaining[1:], ".")))
|
|
||||||
}
|
}
|
||||||
printMethodDetail(out, spec, resName, methodName, method)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
|
|
||||||
// nil if strict mode is not active.
|
|
||||||
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
|
|
||||||
if !mode.IsActive() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
|
||||||
return func(method map[string]interface{}) bool {
|
|
||||||
tokens, _ := method["accessTokens"].([]interface{})
|
|
||||||
if tokens == nil {
|
|
||||||
return true // permissive when meta_data lacks accessTokens
|
|
||||||
}
|
|
||||||
for _, t := range tokens {
|
|
||||||
if s, _ := t.(string); s == token {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func errUnknownService(name string) error {
|
|
||||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
|
||||||
fmt.Sprintf("Unknown service: %s", name),
|
|
||||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
|
||||||
}
|
|
||||||
|
|
||||||
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
|
|
||||||
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
|
|
||||||
// overlay-only services would mislead callers when those services subsequently
|
|
||||||
// fail to resolve in envelope output.
|
|
||||||
func errUnknownEmbeddedService(name string) error {
|
|
||||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
|
||||||
fmt.Sprintf("Unknown service: %s", name),
|
|
||||||
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
|
|
||||||
}
|
|
||||||
|
|
||||||
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
|
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
|
||||||
// filtered by strict mode. Returns the original spec when strict mode is off.
|
// filtered by strict mode. Returns the original spec when strict mode is off.
|
||||||
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package schema
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -34,165 +33,17 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
|
func TestSchemaCmd_NoArgs(t *testing.T) {
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
|
||||||
cmd := NewCmdSchema(f, nil)
|
cmd := NewCmdSchema(f, nil)
|
||||||
cmd.SetArgs([]string{"--format", "pretty"})
|
cmd.SetArgs([]string{})
|
||||||
if err := cmd.Execute(); err != nil {
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "Available services") {
|
if !strings.Contains(stdout.String(), "Available services") {
|
||||||
t.Error("expected service list in pretty mode")
|
t.Error("expected service list output")
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
|
|
||||||
cmd := NewCmdSchema(f, nil)
|
|
||||||
cmd.SetArgs([]string{}) // default --format json
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
out := strings.TrimSpace(stdout.String())
|
|
||||||
if !strings.HasPrefix(out, "[") {
|
|
||||||
head := out
|
|
||||||
if len(head) > 80 {
|
|
||||||
head = head[:80]
|
|
||||||
}
|
|
||||||
t.Errorf("expected JSON array root, first 80 chars:\n%s", head)
|
|
||||||
}
|
|
||||||
var envs []map[string]interface{}
|
|
||||||
if err := json.Unmarshal([]byte(out), &envs); err != nil {
|
|
||||||
t.Fatalf("unmarshal failed: %v", err)
|
|
||||||
}
|
|
||||||
if len(envs) < 193 {
|
|
||||||
t.Errorf("envelopes count = %d, want >= 193", len(envs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
|
|
||||||
cmd := NewCmdSchema(f, nil)
|
|
||||||
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
var env map[string]interface{}
|
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
||||||
t.Fatalf("not valid JSON: %v\n%s", err, stdout.String())
|
|
||||||
}
|
|
||||||
if env["name"] != "im images create" {
|
|
||||||
t.Errorf("name = %v, want \"im images create\"", env["name"])
|
|
||||||
}
|
|
||||||
for _, key := range []string{"description", "inputSchema", "outputSchema", "_meta"} {
|
|
||||||
if _, ok := env[key]; !ok {
|
|
||||||
t.Errorf("missing top-level key: %s", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
meta, _ := env["_meta"].(map[string]interface{})
|
|
||||||
if meta["envelope_version"] != "1.0" {
|
|
||||||
t.Errorf("envelope_version = %v, want \"1.0\"", meta["envelope_version"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSchemaCmd_SpaceSeparatedPath_EqualsDotted(t *testing.T) {
|
|
||||||
f1, out1, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
cmd1 := NewCmdSchema(f1, nil)
|
|
||||||
cmd1.SetArgs([]string{"im", "images", "create"})
|
|
||||||
if err := cmd1.Execute(); err != nil {
|
|
||||||
t.Fatalf("space form failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f2, out2, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
cmd2 := NewCmdSchema(f2, nil)
|
|
||||||
cmd2.SetArgs([]string{"im.images.create"})
|
|
||||||
if err := cmd2.Execute(); err != nil {
|
|
||||||
t.Fatalf("dotted form failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if out1.String() != out2.String() {
|
|
||||||
t.Errorf("space and dotted forms produced different output")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSchemaCmd_ServiceListIsArray(t *testing.T) {
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
|
|
||||||
cmd := NewCmdSchema(f, nil)
|
|
||||||
cmd.SetArgs([]string{"im"})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
var envs []map[string]interface{}
|
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &envs); err != nil {
|
|
||||||
t.Fatalf("unmarshal failed: %v\n%s", err, stdout.String())
|
|
||||||
}
|
|
||||||
if len(envs) == 0 {
|
|
||||||
t.Fatal("expected non-empty array for service im")
|
|
||||||
}
|
|
||||||
for _, e := range envs {
|
|
||||||
name, _ := e["name"].(string)
|
|
||||||
if !strings.HasPrefix(name, "im ") {
|
|
||||||
t.Errorf("envelope name %q does not start with \"im \"", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSchemaCmd_HighRiskYesInjection(t *testing.T) {
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
|
|
||||||
cmd := NewCmdSchema(f, nil)
|
|
||||||
cmd.SetArgs([]string{"im.messages.delete"})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
var env map[string]interface{}
|
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
||||||
t.Fatalf("unmarshal failed: %v", err)
|
|
||||||
}
|
|
||||||
is, _ := env["inputSchema"].(map[string]interface{})
|
|
||||||
props, _ := is["properties"].(map[string]interface{})
|
|
||||||
if _, ok := props["yes"]; !ok {
|
|
||||||
t.Errorf("inputSchema.properties.yes missing for high-risk-write command")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
|
|
||||||
cmd := NewCmdSchema(f, nil)
|
|
||||||
cmd.SetArgs([]string{"im.reactions.list"})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
var env map[string]interface{}
|
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
||||||
t.Fatalf("unmarshal failed: %v", err)
|
|
||||||
}
|
|
||||||
is, _ := env["inputSchema"].(map[string]interface{})
|
|
||||||
props, _ := is["properties"].(map[string]interface{})
|
|
||||||
if _, ok := props["yes"]; ok {
|
|
||||||
t.Errorf("yes property should not appear for risk=read command")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
||||||
|
|
||||||
cmd := NewCmdSchema(f, nil)
|
|
||||||
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
out := stdout.String()
|
|
||||||
// Existing pretty rendering surfaces these markers — they must still appear
|
|
||||||
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
|
|
||||||
if !strings.Contains(out, want) {
|
|
||||||
t.Errorf("pretty output missing marker %q", want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
251
cmd/sec/config_init.go
Normal file
251
cmd/sec/config_init.go
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package sec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewCmdSecConfig is the parent for `lark-cli sec config <verb>`. Currently
|
||||||
|
// it only carries `init`; future verbs (e.g. `show`, `reset`) plug in here.
|
||||||
|
func NewCmdSecConfig(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "config",
|
||||||
|
Short: "Manage lark-sec-cli daemon configuration",
|
||||||
|
}
|
||||||
|
cmd.AddCommand(NewCmdSecConfigInit(f, nil))
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigInitOptions holds inputs for `lark-cli sec config init`.
|
||||||
|
type ConfigInitOptions struct {
|
||||||
|
Factory *cmdutil.Factory
|
||||||
|
AppID string
|
||||||
|
AppSecret string
|
||||||
|
Brand string
|
||||||
|
Yes bool // skip the interactive form when all required values are provided
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCmdSecConfigInit collects App ID / App Secret / Brand from the user and
|
||||||
|
// registers them with the running lark-sec-cli daemon's admin endpoint. The
|
||||||
|
// daemon stashes the secret in the OS keychain and switches into sidecar mode
|
||||||
|
// for SEC_AUTH credential isolation.
|
||||||
|
func NewCmdSecConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
|
||||||
|
opts := &ConfigInitOptions{Factory: f}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "init",
|
||||||
|
Short: "Register a Lark App with the running lark-sec-cli daemon",
|
||||||
|
Long: `Register an App ID / App Secret with the lark-sec-cli daemon.
|
||||||
|
|
||||||
|
The daemon must already be running (start it with "lark-cli sec run"). The
|
||||||
|
registration POSTs to /_sec/api/v1/register-app on the local proxy port,
|
||||||
|
HMAC-signed with the daemon's proxy.key.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
return runConfigInit(cmd, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (skips the prompt when set)")
|
||||||
|
cmd.Flags().StringVar(&opts.AppSecret, "app-secret", "", "App Secret (skips the prompt when set)")
|
||||||
|
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark")
|
||||||
|
cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "skip the interactive form when all required values are provided")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// secBridge mirrors what the daemon writes to ~/.lark-cli/sec_config.json.
|
||||||
|
// It's the single contract between lark-cli and lark-sec-cli at runtime —
|
||||||
|
// we don't reach into lark-sec-cli internals, only what it chooses to publish.
|
||||||
|
type secBridge struct {
|
||||||
|
Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"`
|
||||||
|
Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"`
|
||||||
|
CA string `json:"LARKSUITE_CLI_SEC_CA"`
|
||||||
|
Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfigInit(cmd *cobra.Command, opts *ConfigInitOptions) error {
|
||||||
|
errOut := opts.Factory.IOStreams.ErrOut
|
||||||
|
trace := verboseOut(cmd, errOut)
|
||||||
|
|
||||||
|
tracef(trace, "sec config init", "loading daemon bridge from %s/sec_config.json", core.GetConfigDir())
|
||||||
|
bridge, err := loadBridge()
|
||||||
|
if err != nil {
|
||||||
|
return output.ErrWithHint(output.ExitValidation, "sec_bridge_missing",
|
||||||
|
fmt.Sprintf("daemon bridge file unreadable: %v", err),
|
||||||
|
"Start the daemon first: `lark-cli sec run`.")
|
||||||
|
}
|
||||||
|
tracef(trace, "sec config init", "bridge: enable=%t proxy=%s ca=%s auth=%t", bridge.Enable, bridge.Proxy, bridge.CA, bridge.Auth)
|
||||||
|
if !bridge.Enable || bridge.Proxy == "" {
|
||||||
|
return output.ErrWithHint(output.ExitValidation, "sec_not_running",
|
||||||
|
"lark-sec-cli is not advertising an active proxy",
|
||||||
|
"Run `lark-cli sec run` to start it.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The HMAC key sits next to the CA in the daemon's config dir. Deriving
|
||||||
|
// from the bridge's SEC_CA path keeps lark-cli decoupled from the daemon's
|
||||||
|
// install location — if the daemon ever moves, the bridge follows and we
|
||||||
|
// follow with it.
|
||||||
|
tracef(trace, "sec config init", "reading daemon HMAC key beside %s", bridge.CA)
|
||||||
|
hmacKey, err := readHMACKey(bridge.CA)
|
||||||
|
if err != nil {
|
||||||
|
return output.Errorf(output.ExitInternal, "sec_hmac_key", "read daemon HMAC key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := promptForMissing(opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tracef(trace, "sec config init", "POST %s/_sec/api/v1/register-app app_id=%s brand=%s", bridge.Proxy, opts.AppID, opts.Brand)
|
||||||
|
if err := registerApp(cmd.Context(), bridge.Proxy, hmacKey, opts.AppID, opts.AppSecret, opts.Brand); err != nil {
|
||||||
|
return output.Errorf(output.ExitAPI, "sec_register_app", "register-app: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output.PrintSuccess(errOut,
|
||||||
|
fmt.Sprintf("registered app %s with lark-sec-cli (%s)", opts.AppID, opts.Brand))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadBridge reads the daemon-written sec_config.json from lark-cli's config dir.
|
||||||
|
func loadBridge() (*secBridge, error) {
|
||||||
|
path := filepath.Join(core.GetConfigDir(), "sec_config.json")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var b secBridge
|
||||||
|
if err := json.Unmarshal(data, &b); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHMACKey returns the daemon's proxy.key bytes. The daemon writes the key
|
||||||
|
// hex-encoded (64 ASCII chars); we hex-decode here. If the file is a raw
|
||||||
|
// 32-byte blob (older daemon variants), we use it as-is.
|
||||||
|
func readHMACKey(caPath string) ([]byte, error) {
|
||||||
|
if caPath == "" {
|
||||||
|
return nil, errors.New("sec_config.json has no LARKSUITE_CLI_SEC_CA — can't locate proxy.key")
|
||||||
|
}
|
||||||
|
keyPath := filepath.Join(filepath.Dir(caPath), "proxy.key")
|
||||||
|
raw, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw = bytes.TrimSpace(raw)
|
||||||
|
if len(raw) == 64 {
|
||||||
|
if decoded, err := hex.DecodeString(string(raw)); err == nil {
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptForMissing fills in any of AppID / AppSecret / Brand the user didn't
|
||||||
|
// provide via flags. --yes refuses to prompt; that's caller error if any are
|
||||||
|
// still missing at that point.
|
||||||
|
func promptForMissing(opts *ConfigInitOptions) error {
|
||||||
|
if opts.AppID != "" && opts.AppSecret != "" && opts.Brand != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if opts.Yes {
|
||||||
|
return output.ErrValidation("--yes set but missing one of --app-id / --app-secret / --brand")
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := []*huh.Group{}
|
||||||
|
if opts.AppID == "" {
|
||||||
|
groups = append(groups, huh.NewGroup(
|
||||||
|
huh.NewInput().Title("App ID").Placeholder("cli_xxxx").Value(&opts.AppID),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if opts.AppSecret == "" {
|
||||||
|
groups = append(groups, huh.NewGroup(
|
||||||
|
huh.NewInput().Title("App Secret").EchoMode(huh.EchoModePassword).Value(&opts.AppSecret),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if opts.Brand == "" {
|
||||||
|
opts.Brand = "feishu"
|
||||||
|
groups = append(groups, huh.NewGroup(
|
||||||
|
huh.NewSelect[string]().Title("Brand").Options(
|
||||||
|
huh.NewOption("Feishu (cn)", "feishu"),
|
||||||
|
huh.NewOption("Lark (intl)", "lark"),
|
||||||
|
).Value(&opts.Brand),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
form := huh.NewForm(groups...).WithTheme(cmdutil.ThemeFeishu())
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
if errors.Is(err, huh.ErrUserAborted) {
|
||||||
|
return output.ErrBare(1)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerApp POSTs to /_sec/api/v1/register-app with the daemon's HMAC scheme.
|
||||||
|
// Canonical signing input is "method\npath\nsha256hex(body)\ntimestamp", per
|
||||||
|
// lark-sec-cli/internal/proxy/admin_handler.go's verifyHMAC.
|
||||||
|
func registerApp(ctx context.Context, proxyURL string, hmacKey []byte, appID, appSecret, brand string) error {
|
||||||
|
const path = "/_sec/api/v1/register-app"
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
"app_id": appID,
|
||||||
|
"app_secret": appSecret,
|
||||||
|
"brand": brand,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||||
|
bodyHash := sha256.Sum256(body)
|
||||||
|
canonical := http.MethodPost + "\n" + path + "\n" + hex.EncodeToString(bodyHash[:]) + "\n" + ts
|
||||||
|
mac := hmac.New(sha256.New, hmacKey)
|
||||||
|
mac.Write([]byte(canonical))
|
||||||
|
sig := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, proxyURL+path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Lark-Admin-Signature", sig)
|
||||||
|
req.Header.Set("X-Lark-Admin-Timestamp", ts)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
33
cmd/sec/factory.go
Normal file
33
cmd/sec/factory.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package sec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
intsec "github.com/larksuite/cli/internal/sec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// installer wires up an internal/sec.Installer using the Factory's HTTP client,
|
||||||
|
// the default platform paths, and a lazy OAPI-client provider used to fetch
|
||||||
|
// the install manifest. APIClientFunc is a method value, not an eager call —
|
||||||
|
// commands that short-circuit (or that never install, like sec status / sec
|
||||||
|
// stop) avoid decrypting credentials from the keychain. Every cmd/sec
|
||||||
|
// subcommand starts here.
|
||||||
|
func installer(f *cmdutil.Factory) (*intsec.Installer, *intsec.Paths, error) {
|
||||||
|
paths, err := intsec.DefaultPaths()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("resolve sec paths: %w", err)
|
||||||
|
}
|
||||||
|
httpClient, err := f.HttpClient()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("resolve http client: %w", err)
|
||||||
|
}
|
||||||
|
return &intsec.Installer{
|
||||||
|
Paths: paths,
|
||||||
|
HTTPClient: httpClient,
|
||||||
|
APIClientFunc: f.NewAPIClient,
|
||||||
|
}, paths, nil
|
||||||
|
}
|
||||||
127
cmd/sec/run.go
Normal file
127
cmd/sec/run.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package sec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
intsec "github.com/larksuite/cli/internal/sec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunOptions holds inputs for `lark-cli sec run`.
|
||||||
|
type RunOptions struct {
|
||||||
|
Factory *cmdutil.Factory
|
||||||
|
ProxyPort int
|
||||||
|
// AutoInstall runs `sec install` first when no binary is recorded.
|
||||||
|
AutoInstall bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCmdSecRun starts lark-sec-cli as a user-level system service so it
|
||||||
|
// persists across logins and gets restarted by the OS supervisor if it
|
||||||
|
// crashes. Under the hood it shells out to `lark-sec-cli service enable`,
|
||||||
|
// which is the recommended startup path per the lark-sec-cli manual:
|
||||||
|
//
|
||||||
|
// - macOS → user-level launchd plist with KeepAlive=true
|
||||||
|
// - Linux → user systemd unit with Restart=always
|
||||||
|
// - Windows → registry autostart + a VBS watchdog loop
|
||||||
|
//
|
||||||
|
// Switching to this from a detached `exec.Command(... Setsid:true)` spawn
|
||||||
|
// fixes two latent issues at once: (1) daemon logs survive past lark-cli
|
||||||
|
// exit because the service supervisor — not our terminated pipes — owns
|
||||||
|
// the daemon's stdout, and (2) the daemon's own self-upgrade module can
|
||||||
|
// now fire (it gates on running-under-supervisor).
|
||||||
|
func NewCmdSecRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command {
|
||||||
|
opts := &RunOptions{Factory: f, AutoInstall: true}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "run",
|
||||||
|
Short: "Enable lark-sec-cli as a user system service (the daemon runs in the background)",
|
||||||
|
Long: `Install lark-sec-cli as a user-level system service so the proxy
|
||||||
|
daemon runs automatically, persists across logins, and is restarted by the
|
||||||
|
OS if it exits. The daemon writes its own log file (default: under
|
||||||
|
~/.lark-sec-cli/logs/daemon.log) so logs persist independently of this
|
||||||
|
command.
|
||||||
|
|
||||||
|
After enabling, the daemon writes ~/.lark-cli/sec_config.json itself with
|
||||||
|
the proxy port and CA path, so subsequent lark-cli runs route through the
|
||||||
|
sidecar without any further action.
|
||||||
|
|
||||||
|
To stop and remove the service: lark-cli sec stop.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
return runRun(cmd, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "force lark-sec-cli to bind this port (default: dynamic)")
|
||||||
|
cmd.Flags().BoolVar(&opts.AutoInstall, "auto-install", true, "bootstrap-install lark-sec-cli first when no binary is recorded")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRun(cmd *cobra.Command, opts *RunOptions) error {
|
||||||
|
ctx := cmd.Context()
|
||||||
|
errOut := opts.Factory.IOStreams.ErrOut
|
||||||
|
trace := verboseOut(cmd, errOut)
|
||||||
|
|
||||||
|
tracef(trace, "sec run", "constructing installer (lazy credentials)")
|
||||||
|
inst, paths, err := installer(opts.Factory)
|
||||||
|
if err != nil {
|
||||||
|
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure we have a binary on disk before asking it to install itself
|
||||||
|
// as a service.
|
||||||
|
tracef(trace, "sec run", "loading state from %s", paths.StateFile())
|
||||||
|
state, err := intsec.LoadState(paths.StateFile())
|
||||||
|
if err != nil {
|
||||||
|
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
|
||||||
|
}
|
||||||
|
if state == nil {
|
||||||
|
tracef(trace, "sec run", "no install on disk (auto-install=%t)", opts.AutoInstall)
|
||||||
|
if !opts.AutoInstall {
|
||||||
|
return output.ErrWithHint(output.ExitValidation, "sec_not_installed",
|
||||||
|
"lark-sec-cli is not installed",
|
||||||
|
"Re-run `lark-cli sec run` with --auto-install (default on), or remove --auto-install=false.")
|
||||||
|
}
|
||||||
|
state, err = inst.Install(ctx, intsec.InstallOptions{Verbose: trace})
|
||||||
|
if err != nil {
|
||||||
|
return output.Errorf(output.ExitNetwork, "sec_install", "auto-install lark-sec-cli: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracef(trace, "sec run", "existing install: version=%s binary=%s", state.Version, state.BinaryPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"service", "enable"}
|
||||||
|
if opts.ProxyPort > 0 {
|
||||||
|
args = append(args, fmt.Sprintf("--proxy-port=%d", opts.ProxyPort))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(errOut, "Running: %s %v\n", state.BinaryPath, args)
|
||||||
|
tracef(trace, "sec run", "shelling out to %s %v", state.BinaryPath, args)
|
||||||
|
|
||||||
|
c := exec.CommandContext(ctx, state.BinaryPath, args...)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
c.Stdout = &stdout
|
||||||
|
c.Stderr = &stderr
|
||||||
|
if err := c.Run(); err != nil {
|
||||||
|
return output.Errorf(output.ExitInternal, "sec_service_enable",
|
||||||
|
"`lark-sec-cli service enable` failed: %v\nstderr: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
tracef(trace, "sec run", "service enable returned ok (%d bytes stdout)", stdout.Len())
|
||||||
|
|
||||||
|
// Forward the installer's stdout to the user — it contains the launchd /
|
||||||
|
// systemd unit name, the registered executable path, and a confirmation
|
||||||
|
// that the supervisor will respawn the daemon on exit. Useful diagnostic
|
||||||
|
// output that's better seen than swallowed.
|
||||||
|
fmt.Fprint(errOut, stdout.String())
|
||||||
|
output.PrintSuccess(errOut,
|
||||||
|
"lark-sec-cli enabled as a user system service. Run `lark-cli sec status` to verify, `lark-cli sec stop` to disable.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
49
cmd/sec/sec.go
Normal file
49
cmd/sec/sec.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package sec exposes the `lark-cli sec` command tree that bootstraps the
|
||||||
|
// lark-sec-cli sidecar daemon: install, run, stop, status, and `config init`.
|
||||||
|
// The internal/sec package owns the implementation; this package is a thin
|
||||||
|
// Cobra wrapper that mirrors the conventions in cmd/auth.
|
||||||
|
//
|
||||||
|
// After bootstrap install, lark-sec-cli handles its own upgrade lifecycle —
|
||||||
|
// lark-cli is not in the update path, which is why there's no `sec update`
|
||||||
|
// subcommand here.
|
||||||
|
package sec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewCmdSec builds the parent `sec` command and registers all subcommands.
|
||||||
|
//
|
||||||
|
// The persistent --verbose / -v flag is inherited by every subcommand:
|
||||||
|
// `sec run -v`, `sec status -v`, etc. all emit step-by-step trace output to
|
||||||
|
// stderr.
|
||||||
|
//
|
||||||
|
// There is no `sec install` subcommand — `sec run` auto-installs lark-sec-cli
|
||||||
|
// if no binary is on disk, so a separate install verb was redundant.
|
||||||
|
func NewCmdSec(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
var verbose bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "sec",
|
||||||
|
Short: "Manage the lark-sec-cli security sidecar (run, status, stop, config)",
|
||||||
|
Long: `Manage the lark-sec-cli security sidecar.
|
||||||
|
|
||||||
|
lark-sec-cli is a local HTTPS proxy daemon that intercepts lark-cli's traffic,
|
||||||
|
injects BDMS risk-control signatures, and manages credentials via the OS
|
||||||
|
keychain. These subcommands handle the runtime lifecycle from lark-cli's side:
|
||||||
|
start the daemon (auto-installing on first run), inspect its state, register
|
||||||
|
an app with it, and stop it. Updates after the first install are managed by
|
||||||
|
lark-sec-cli itself.`,
|
||||||
|
}
|
||||||
|
cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
|
||||||
|
"print step-by-step pipeline output to stderr")
|
||||||
|
cmd.AddCommand(NewCmdSecRun(f, nil))
|
||||||
|
cmd.AddCommand(NewCmdSecStop(f, nil))
|
||||||
|
cmd.AddCommand(NewCmdSecStatus(f, nil))
|
||||||
|
cmd.AddCommand(NewCmdSecConfig(f))
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
38
cmd/sec/sec_test.go
Normal file
38
cmd/sec/sec_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package sec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewCmdSec_HasAllSubcommands locks in the public command surface so a
|
||||||
|
// future refactor doesn't silently drop run/status/etc. The `update` verb
|
||||||
|
// was intentionally removed when lark-sec-cli took over its own upgrade
|
||||||
|
// lifecycle; if it ever needs to come back, add it here too. `install` was
|
||||||
|
// removed because `sec run --auto-install` (default on) makes a standalone
|
||||||
|
// install verb redundant.
|
||||||
|
func TestNewCmdSec_HasAllSubcommands(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||||
|
cmd := NewCmdSec(f)
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
for _, c := range cmd.Commands() {
|
||||||
|
got = append(got, c.Name())
|
||||||
|
}
|
||||||
|
sort.Strings(got)
|
||||||
|
want := []string{"config", "run", "status", "stop"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("subcommands = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for i, name := range want {
|
||||||
|
if got[i] != name {
|
||||||
|
t.Errorf("subcommands[%d] = %q, want %q", i, got[i], name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
cmd/sec/status.go
Normal file
115
cmd/sec/status.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package sec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
intsec "github.com/larksuite/cli/internal/sec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatusOptions holds inputs for `lark-cli sec status`.
|
||||||
|
type StatusOptions struct {
|
||||||
|
Factory *cmdutil.Factory
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCmdSecStatus shows install + runtime state. Implementation strategy:
|
||||||
|
//
|
||||||
|
// 1. Read lark-cli's local install record (state.json) — works even when the
|
||||||
|
// daemon's not installed, and gives the user a version/buildId/path
|
||||||
|
// fingerprint regardless of whether the service is up.
|
||||||
|
// 2. If the install exists, shell out to `lark-sec-cli status` for the
|
||||||
|
// live daemon view (service registration, pid liveness, proxy probe,
|
||||||
|
// sec_config.json contents). The daemon's own status command does a
|
||||||
|
// thorough check; we just pass it through.
|
||||||
|
func NewCmdSecStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
|
||||||
|
opts := &StatusOptions{Factory: f}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show lark-sec-cli install and runtime state",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
return runStatus(cmd, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStatus(cmd *cobra.Command, opts *StatusOptions) error {
|
||||||
|
errOut := opts.Factory.IOStreams.ErrOut
|
||||||
|
trace := verboseOut(cmd, errOut)
|
||||||
|
|
||||||
|
tracef(trace, "sec status", "constructing installer (lazy credentials)")
|
||||||
|
_, paths, err := installer(opts.Factory)
|
||||||
|
if err != nil {
|
||||||
|
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||||
|
}
|
||||||
|
out := opts.Factory.IOStreams.Out
|
||||||
|
tracef(trace, "sec status", "loading state from %s", paths.StateFile())
|
||||||
|
state, err := intsec.LoadState(paths.StateFile())
|
||||||
|
if err != nil {
|
||||||
|
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
|
||||||
|
}
|
||||||
|
if state == nil {
|
||||||
|
fmt.Fprintln(out, "lark-sec-cli: not installed")
|
||||||
|
fmt.Fprintln(out, " run: lark-cli sec run")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "lark-sec-cli %s\n", state.Version)
|
||||||
|
fmt.Fprintf(out, " binary: %s\n", state.BinaryPath)
|
||||||
|
|
||||||
|
// Daemon-side detail via `lark-sec-cli status`. The daemon's status
|
||||||
|
// command already covers service registration + pid + proxy reachability
|
||||||
|
// + bridge file — better than re-implementing those here.
|
||||||
|
tracef(trace, "sec status", "shelling out to %s status", state.BinaryPath)
|
||||||
|
c := exec.CommandContext(cmd.Context(), state.BinaryPath, "status")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
c.Stdout = &stdout
|
||||||
|
c.Stderr = &stderr
|
||||||
|
runErr := c.Run()
|
||||||
|
tracef(trace, "sec status", "daemon status exit=%v stdout=%d bytes stderr=%d bytes", runErr, stdout.Len(), stderr.Len())
|
||||||
|
fmt.Fprintln(out, " --- lark-sec-cli status ---")
|
||||||
|
if stdout.Len() > 0 {
|
||||||
|
fmt.Fprint(out, indent(stdout.String(), " "))
|
||||||
|
}
|
||||||
|
if stderr.Len() > 0 {
|
||||||
|
fmt.Fprint(out, indent(stderr.String(), " "))
|
||||||
|
}
|
||||||
|
// `lark-sec-cli status` exits 1 when not running — that's diagnostic
|
||||||
|
// data, not a failure of OUR command. Surface it for the user but don't
|
||||||
|
// propagate the non-zero exit upward.
|
||||||
|
_ = runErr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// indent prefixes every line of s with prefix. Cheap pass-through formatter
|
||||||
|
// used to make the embedded `lark-sec-cli status` output read as a sub-block
|
||||||
|
// under our own header.
|
||||||
|
func indent(s, prefix string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '\n' {
|
||||||
|
buf.WriteString(prefix)
|
||||||
|
buf.WriteString(s[start : i+1])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start < len(s) {
|
||||||
|
buf.WriteString(prefix)
|
||||||
|
buf.WriteString(s[start:])
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
82
cmd/sec/stop.go
Normal file
82
cmd/sec/stop.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package sec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
intsec "github.com/larksuite/cli/internal/sec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StopOptions holds inputs for `lark-cli sec stop`.
|
||||||
|
type StopOptions struct {
|
||||||
|
Factory *cmdutil.Factory
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCmdSecStop disables and removes the lark-sec-cli user system service.
|
||||||
|
// Counterpart to `sec run` — internally invokes `lark-sec-cli service disable`,
|
||||||
|
// which uninstalls the launchd / systemd / VBS-watchdog registration.
|
||||||
|
//
|
||||||
|
// The daemon itself wipes ~/.lark-cli/sec_config.json on shutdown (see its
|
||||||
|
// --disable-on-exit flag, default true), so subsequent lark-cli runs route
|
||||||
|
// directly to the upstream API instead of dangling through a dead local proxy.
|
||||||
|
func NewCmdSecStop(f *cmdutil.Factory, runF func(*StopOptions) error) *cobra.Command {
|
||||||
|
opts := &StopOptions{Factory: f}
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "stop",
|
||||||
|
Short: "Disable and remove the lark-sec-cli user system service",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
return runStop(cmd, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStop(cmd *cobra.Command, opts *StopOptions) error {
|
||||||
|
out := opts.Factory.IOStreams.ErrOut
|
||||||
|
trace := verboseOut(cmd, out)
|
||||||
|
|
||||||
|
tracef(trace, "sec stop", "constructing installer (lazy credentials)")
|
||||||
|
_, paths, err := installer(opts.Factory)
|
||||||
|
if err != nil {
|
||||||
|
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||||
|
}
|
||||||
|
tracef(trace, "sec stop", "loading state from %s", paths.StateFile())
|
||||||
|
state, err := intsec.LoadState(paths.StateFile())
|
||||||
|
if err != nil {
|
||||||
|
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
|
||||||
|
}
|
||||||
|
if state == nil {
|
||||||
|
// Nothing on disk to stop — no-op.
|
||||||
|
tracef(trace, "sec stop", "no install on disk; nothing to stop")
|
||||||
|
output.PrintSuccess(out, "lark-sec-cli not installed; nothing to stop")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"service", "disable"}
|
||||||
|
fmt.Fprintf(out, "Running: %s %v\n", state.BinaryPath, args)
|
||||||
|
tracef(trace, "sec stop", "shelling out to %s %v", state.BinaryPath, args)
|
||||||
|
|
||||||
|
c := exec.CommandContext(cmd.Context(), state.BinaryPath, args...)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
c.Stdout = &stdout
|
||||||
|
c.Stderr = &stderr
|
||||||
|
if err := c.Run(); err != nil {
|
||||||
|
return output.Errorf(output.ExitInternal, "sec_service_disable",
|
||||||
|
"`lark-sec-cli service disable` failed: %v\nstderr: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
tracef(trace, "sec stop", "service disable returned ok (%d bytes stdout)", stdout.Len())
|
||||||
|
fmt.Fprint(out, stdout.String())
|
||||||
|
output.PrintSuccess(out, "lark-sec-cli service disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
32
cmd/sec/verbose.go
Normal file
32
cmd/sec/verbose.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package sec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// verboseOut returns the trace destination for a sec subcommand: the given
|
||||||
|
// stderr writer when the inherited --verbose / -v flag is set, otherwise nil.
|
||||||
|
// Pair with tracef — a nil destination silently drops traces, so callers can
|
||||||
|
// emit unconditionally.
|
||||||
|
func verboseOut(cmd *cobra.Command, errOut io.Writer) io.Writer {
|
||||||
|
if v, _ := cmd.Flags().GetBool("verbose"); v {
|
||||||
|
return errOut
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tracef writes one trace line to w when w is non-nil. The prefix names the
|
||||||
|
// emitting subcommand (e.g. "sec run") so layered output from the install
|
||||||
|
// pipeline + the command itself stays distinguishable.
|
||||||
|
func tracef(w io.Writer, prefix, format string, args ...any) {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "[%s] "+format+"\n", append([]any{prefix}, args...)...)
|
||||||
|
}
|
||||||
@@ -9,13 +9,11 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/auth"
|
"github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/client"
|
"github.com/larksuite/cli/internal/client"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/credential"
|
"github.com/larksuite/cli/internal/credential"
|
||||||
"github.com/larksuite/cli/internal/errclass"
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
"github.com/larksuite/cli/internal/util"
|
"github.com/larksuite/cli/internal/util"
|
||||||
@@ -224,7 +222,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.PageAll && opts.Output != "" {
|
if opts.PageAll && opts.Output != "" {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --page-all are mutually exclusive").WithParam("--output")
|
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||||
}
|
}
|
||||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -273,10 +271,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
|||||||
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scope-insufficient (99991679) and all other Lark API codes route through
|
checkErr := scopeAwareChecker(scopes, opts.As.IsBot())
|
||||||
// errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError
|
|
||||||
// with MissingScopes / Identity / ConsoleURL populated from the response.
|
|
||||||
checkErr := ac.CheckResponse
|
|
||||||
|
|
||||||
if opts.PageAll {
|
if opts.PageAll {
|
||||||
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||||
@@ -285,7 +280,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
|||||||
|
|
||||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return output.ErrNetwork("API call failed: %s", err)
|
||||||
}
|
}
|
||||||
return client.HandleResponse(resp, client.ResponseOptions{
|
return client.HandleResponse(resp, client.ResponseOptions{
|
||||||
OutputPath: opts.Output,
|
OutputPath: opts.Output,
|
||||||
@@ -295,7 +290,6 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
|||||||
ErrOut: f.IOStreams.ErrOut,
|
ErrOut: f.IOStreams.ErrOut,
|
||||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||||
CommandPath: opts.Cmd.CommandPath(),
|
CommandPath: opts.Cmd.CommandPath(),
|
||||||
Identity: opts.As,
|
|
||||||
CheckError: checkErr,
|
CheckError: checkErr,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -321,7 +315,9 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||||
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing)
|
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||||
|
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||||
|
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, " ")))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -341,24 +337,9 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
recommended := registry.SelectRecommendedScope(scopes, "user")
|
recommended := registry.SelectRecommendedScope(scopes, "user")
|
||||||
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended})
|
return output.ErrWithHint(output.ExitAPI, "permission",
|
||||||
}
|
fmt.Sprintf("insufficient permissions (required scope: %s)", recommended),
|
||||||
|
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.`, recommended))
|
||||||
// newPreflightMissingScopeError constructs a PermissionError for the local
|
|
||||||
// pre-flight scope check that converges byte-for-byte with the dispatcher's
|
|
||||||
// BuildAPIError path. Uses the canonical helpers in internal/errclass so
|
|
||||||
// Hint and Message stay in lock-step with the server-response classifier.
|
|
||||||
// ConsoleURL is deliberately omitted: the dispatcher only sets it for
|
|
||||||
// SubtypeAppScopeNotApplied (bot-perspective dev-action recovery), and this
|
|
||||||
// pre-flight path is user-perspective SubtypeMissingScope whose recovery is
|
|
||||||
// `lark-cli auth login --scope ...`, not a console deep-link.
|
|
||||||
func newPreflightMissingScopeError(brand, appID, identity string, missing []string) *errs.PermissionError {
|
|
||||||
consoleURL := errclass.ConsoleURL(brand, appID, missing)
|
|
||||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
|
||||||
"%s", errclass.CanonicalPermissionMessage(errs.SubtypeMissingScope, appID, missing, "")).
|
|
||||||
WithHint("%s", errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope, consoleURL)).
|
|
||||||
WithMissingScopes(missing...).
|
|
||||||
WithIdentity(identity)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
|
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
|
||||||
@@ -380,7 +361,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
|||||||
return client.RawApiRequest{}, nil, err
|
return client.RawApiRequest{}, nil, err
|
||||||
}
|
}
|
||||||
if opts.Params == "-" && opts.Data == "-" {
|
if opts.Params == "-" && opts.Data == "-" {
|
||||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--params and --data cannot both read from stdin (-)").WithParam("--params")
|
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||||
}
|
}
|
||||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -397,14 +378,13 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
|||||||
}
|
}
|
||||||
val, ok := params[name]
|
val, ok := params[name]
|
||||||
if !ok || util.IsEmptyValue(val) {
|
if !ok || util.IsEmptyValue(val) {
|
||||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
|
||||||
"missing required path parameter: %s", name).
|
fmt.Sprintf("missing required path parameter: %s", name),
|
||||||
WithHint("lark-cli schema %s", schemaPath).
|
fmt.Sprintf("lark-cli schema %s", schemaPath))
|
||||||
WithParam(name)
|
|
||||||
}
|
}
|
||||||
valStr := fmt.Sprintf("%v", val)
|
valStr := fmt.Sprintf("%v", val)
|
||||||
if err := validate.ResourceName(valStr, name); err != nil {
|
if err := validate.ResourceName(valStr, name); err != nil {
|
||||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(name).WithCause(err)
|
return client.RawApiRequest{}, nil, output.ErrValidation("%s", err)
|
||||||
}
|
}
|
||||||
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
|
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
|
||||||
delete(params, name)
|
delete(params, name)
|
||||||
@@ -420,10 +400,9 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
|||||||
required, _ := p["required"].(bool)
|
required, _ := p["required"].(bool)
|
||||||
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
|
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
|
||||||
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
|
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
|
||||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
|
||||||
"missing required query parameter: %s", name).
|
fmt.Sprintf("missing required query parameter: %s", name),
|
||||||
WithHint("lark-cli schema %s", schemaPath).
|
fmt.Sprintf("lark-cli schema %s", schemaPath))
|
||||||
WithParam(name)
|
|
||||||
}
|
}
|
||||||
if exists && !util.IsEmptyValue(value) {
|
if exists && !util.IsEmptyValue(value) {
|
||||||
queryParams[name] = value
|
queryParams[name] = value
|
||||||
@@ -458,7 +437,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
|||||||
return client.RawApiRequest{}, nil, err
|
return client.RawApiRequest{}, nil, err
|
||||||
}
|
}
|
||||||
if _, ok := dataFields.(map[string]any); !ok {
|
if _, ok := dataFields.(map[string]any); !ok {
|
||||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a JSON object when used with --file").WithParam("--data")
|
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,10 +474,36 @@ func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *cor
|
|||||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
|
// scopeAwareChecker returns an error checker that enriches scope-related errors with login hints.
|
||||||
if pagOpts.Identity == "" {
|
func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) error {
|
||||||
pagOpts.Identity = request.As
|
return func(result interface{}) error {
|
||||||
|
resultMap, ok := result.(map[string]interface{})
|
||||||
|
if !ok || resultMap == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
code, _ := util.ToFloat64(resultMap["code"])
|
||||||
|
if code == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
larkCode := int(code)
|
||||||
|
msg := registry.GetStrFromMap(resultMap, "msg")
|
||||||
|
|
||||||
|
if larkCode == output.LarkErrUserScopeInsufficient && len(scopes) > 0 {
|
||||||
|
identity := "user"
|
||||||
|
if isBotMode {
|
||||||
|
identity = "tenant"
|
||||||
|
}
|
||||||
|
recommended := registry.SelectRecommendedScope(scopes, identity)
|
||||||
|
return output.ErrWithHint(output.ExitAPI, "permission",
|
||||||
|
fmt.Sprintf("insufficient permissions: [%d] %s", larkCode, msg),
|
||||||
|
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.`, recommended))
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
|
||||||
// When jq is set, always aggregate all pages then filter.
|
// When jq is set, always aggregate all pages then filter.
|
||||||
if jqExpr != "" {
|
if jqExpr != "" {
|
||||||
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
|
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
|
||||||
@@ -511,9 +516,9 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
|||||||
pf.FormatPage(items)
|
pf.FormatPage(items)
|
||||||
}, pagOpts)
|
}, pagOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return output.ErrNetwork("API call failed: %s", err)
|
||||||
}
|
}
|
||||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
if apiErr := checkErr(result); apiErr != nil {
|
||||||
return apiErr
|
return apiErr
|
||||||
}
|
}
|
||||||
if !hasItems {
|
if !hasItems {
|
||||||
@@ -524,9 +529,9 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
|||||||
default:
|
default:
|
||||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return output.ErrNetwork("API call failed: %s", err)
|
||||||
}
|
}
|
||||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
if apiErr := checkErr(result); apiErr != nil {
|
||||||
return apiErr
|
return apiErr
|
||||||
}
|
}
|
||||||
output.FormatValue(out, result, format)
|
output.FormatValue(out, result, format)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -411,6 +412,39 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceMethod_BotMode_APIError(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app-err", AppSecret: "test-secret-err", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "/open-apis/svc/v1/items",
|
||||||
|
Body: map[string]interface{}{"code": 40003, "msg": "invalid token"},
|
||||||
|
})
|
||||||
|
|
||||||
|
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||||
|
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||||
|
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||||
|
cmd.SetArgs([]string{"--as", "bot"})
|
||||||
|
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected API error")
|
||||||
|
}
|
||||||
|
var exitErr *output.ExitError
|
||||||
|
if !isExitError(err, &exitErr) {
|
||||||
|
t.Fatalf("expected ExitError, got: %T %v", err, err)
|
||||||
|
}
|
||||||
|
if exitErr.Code != output.ExitAPI {
|
||||||
|
t.Errorf("expected ExitAPI code, got %d", exitErr.Code)
|
||||||
|
}
|
||||||
|
// stdout must be empty on API error — error details belong in stderr envelope only.
|
||||||
|
// This guards against re-introducing duplicate output (see commit 86215a10).
|
||||||
|
if stdout.Len() > 0 {
|
||||||
|
t.Errorf("expected no stdout on API error, got: %s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
|
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
|
||||||
@@ -628,6 +662,73 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── scopeAwareChecker ──
|
||||||
|
|
||||||
|
func TestScopeAwareChecker_Success(t *testing.T) {
|
||||||
|
checker := scopeAwareChecker(nil, false)
|
||||||
|
err := checker(map[string]interface{}{"code": 0.0, "msg": "ok"})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected nil error for code=0, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopeAwareChecker_NonMapResult(t *testing.T) {
|
||||||
|
checker := scopeAwareChecker(nil, false)
|
||||||
|
err := checker("not a map")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected nil for non-map result, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopeAwareChecker_APIError(t *testing.T) {
|
||||||
|
checker := scopeAwareChecker(nil, false)
|
||||||
|
err := checker(map[string]interface{}{"code": 40003.0, "msg": "bad request"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-zero code")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "API error: [40003]") {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopeAwareChecker_ScopeError_UserMode(t *testing.T) {
|
||||||
|
scopes := []interface{}{"calendar:read"}
|
||||||
|
checker := scopeAwareChecker(scopes, false)
|
||||||
|
err := checker(map[string]interface{}{
|
||||||
|
"code": float64(output.LarkErrUserScopeInsufficient),
|
||||||
|
"msg": "scope insufficient",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected permission error")
|
||||||
|
}
|
||||||
|
var exitErr *output.ExitError
|
||||||
|
if !isExitError(err, &exitErr) {
|
||||||
|
t.Fatalf("expected ExitError, got %T", err)
|
||||||
|
}
|
||||||
|
if exitErr.Detail.Type != "permission" {
|
||||||
|
t.Errorf("expected type=permission, got %s", exitErr.Detail.Type)
|
||||||
|
}
|
||||||
|
if !strings.Contains(exitErr.Detail.Hint, "auth login") {
|
||||||
|
t.Errorf("expected auth login hint, got %s", exitErr.Detail.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopeAwareChecker_ScopeError_BotMode(t *testing.T) {
|
||||||
|
scopes := []interface{}{"calendar:read"}
|
||||||
|
checker := scopeAwareChecker(scopes, true)
|
||||||
|
err := checker(map[string]interface{}{
|
||||||
|
"code": float64(output.LarkErrUserScopeInsufficient),
|
||||||
|
"msg": "scope insufficient",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected permission error")
|
||||||
|
}
|
||||||
|
// Bot mode should still include the scope hint
|
||||||
|
if !strings.Contains(err.Error(), "insufficient permissions") {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── file upload ──
|
// ── file upload ──
|
||||||
|
|
||||||
func imImageMethod() map[string]interface{} {
|
func imImageMethod() map[string]interface{} {
|
||||||
@@ -765,3 +866,13 @@ func TestDetectFileFields(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── helpers ──
|
||||||
|
|
||||||
|
func isExitError(err error, target **output.ExitError) bool {
|
||||||
|
ee, ok := err.(*output.ExitError)
|
||||||
|
if ok && target != nil {
|
||||||
|
*target = ee
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,18 +31,15 @@ var (
|
|||||||
currentVersion = func() string { return build.Version }
|
currentVersion = func() string { return build.Version }
|
||||||
currentOS = runtime.GOOS
|
currentOS = runtime.GOOS
|
||||||
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
|
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
|
||||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { return skillscheck.SyncSkills(opts) }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func isWindows() bool { return currentOS == osWindows }
|
func isWindows() bool { return currentOS == osWindows }
|
||||||
|
|
||||||
// normalizeVersion canonicalizes a version string for state comparison.
|
// normalizeVersion canonicalizes a version string for stamp comparison.
|
||||||
// Strips a leading "v" so versions written from Makefile (git describe →
|
// Strips a leading "v" so versions written from Makefile (git describe →
|
||||||
// "v1.0.0") and npm (no prefix → "1.0.0") compare equal.
|
// "v1.0.0") and npm (no prefix → "1.0.0") compare equal.
|
||||||
func normalizeVersion(s string) string {
|
func normalizeVersion(s string) string {
|
||||||
s = strings.TrimSpace(s)
|
return strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||||
s = strings.TrimPrefix(s, "v")
|
|
||||||
return strings.TrimPrefix(s, "V")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func releaseURL(version string) string {
|
func releaseURL(version string) string {
|
||||||
@@ -124,9 +121,7 @@ func updateRun(opts *UpdateOptions) error {
|
|||||||
cur := currentVersion()
|
cur := currentVersion()
|
||||||
updater := newUpdater()
|
updater := newUpdater()
|
||||||
|
|
||||||
if !opts.Check {
|
updater.CleanupStaleFiles()
|
||||||
updater.CleanupStaleFiles()
|
|
||||||
}
|
|
||||||
output.PendingNotice = nil
|
output.PendingNotice = nil
|
||||||
|
|
||||||
// 1. Fetch latest version
|
// 1. Fetch latest version
|
||||||
@@ -142,9 +137,13 @@ func updateRun(opts *UpdateOptions) error {
|
|||||||
|
|
||||||
// 3. Compare versions
|
// 3. Compare versions
|
||||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||||
var skillsResult *skillscheck.SyncResult
|
// Run skills sync before returning — covers the case where the
|
||||||
|
// binary is already current but skills were never synced.
|
||||||
|
// Stamp dedup makes this a no-op if skills are already in sync.
|
||||||
|
// Skip side-effects under --check (pure report path per spec §3.6).
|
||||||
|
var skillsResult *selfupdate.NpmResult
|
||||||
if !opts.Check {
|
if !opts.Check {
|
||||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
|
skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||||
}
|
}
|
||||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||||
}
|
}
|
||||||
@@ -186,7 +185,16 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
|||||||
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
|
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
|
||||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||||
}
|
}
|
||||||
applySkillsStatus(out, cur)
|
// skills_status: pure report, no side effect, no stamp write.
|
||||||
|
// ReadStamp errors are silently swallowed — if we can't read the
|
||||||
|
// stamp we just omit the block rather than fail the --check.
|
||||||
|
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||||
|
out["skills_status"] = map[string]interface{}{
|
||||||
|
"current": stamp,
|
||||||
|
"target": cur,
|
||||||
|
"in_sync": stamp == cur,
|
||||||
|
}
|
||||||
|
}
|
||||||
output.PrintJson(io.Out, out)
|
output.PrintJson(io.Out, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -202,7 +210,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
|
skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||||
|
|
||||||
reason := detect.ManualReason()
|
reason := detect.ManualReason()
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
@@ -280,7 +288,10 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
}
|
}
|
||||||
|
|
||||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
|
// Skills update (best-effort) — uses runSkillsAndStamp so the
|
||||||
|
// stamp gets persisted on success and dedup applies if a previous
|
||||||
|
// run already stamped this version.
|
||||||
|
skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force)
|
||||||
|
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
@@ -317,21 +328,27 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
|
|||||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
|
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
|
||||||
|
// stamp on success. Skips the npx invocation when the stamp already
|
||||||
|
// matches stampVersion (unless force is true). The stamp write failure
|
||||||
|
// emits a warning to io.ErrOut but does NOT fail the update command —
|
||||||
|
// best-effort. ReadStamp errors are swallowed (fail-closed: treated as
|
||||||
|
// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp
|
||||||
|
// dedup; otherwise returns the underlying *NpmResult with Err semantics
|
||||||
|
// from RunSkillsUpdate.
|
||||||
|
func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult {
|
||||||
if !force {
|
if !force {
|
||||||
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result := syncSkills(skillscheck.SyncOptions{
|
r := updater.RunSkillsUpdate()
|
||||||
Version: stateVersion,
|
if r.Err == nil {
|
||||||
Force: force,
|
if err := skillscheck.WriteStamp(stampVersion); err != nil {
|
||||||
Runner: updater,
|
fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err)
|
||||||
})
|
}
|
||||||
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
|
|
||||||
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
|
|
||||||
}
|
}
|
||||||
return result
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
||||||
@@ -339,7 +356,7 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
|
|||||||
// fields derived from skillsResult. When check is true, this is the pure
|
// fields derived from skillsResult. When check is true, this is the pure
|
||||||
// report path (spec §3.6): no side-effects, JSON envelope uses
|
// report path (spec §3.6): no side-effects, JSON envelope uses
|
||||||
// skills_status (spec §4.2) instead of skills_action.
|
// skills_status (spec §4.2) instead of skills_action.
|
||||||
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error {
|
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error {
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
out := map[string]interface{}{
|
out := map[string]interface{}{
|
||||||
"ok": true, "previous_version": cur, "current_version": cur,
|
"ok": true, "previous_version": cur, "current_version": cur,
|
||||||
@@ -347,7 +364,16 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
|
|||||||
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
|
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
|
||||||
}
|
}
|
||||||
if check {
|
if check {
|
||||||
applySkillsStatus(out, cur)
|
// Pure report — read stamp directly, emit skills_status block.
|
||||||
|
// ReadStamp errors are silently swallowed — if we can't read
|
||||||
|
// the stamp we just omit the block rather than fail the --check.
|
||||||
|
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||||
|
out["skills_status"] = map[string]interface{}{
|
||||||
|
"current": stamp,
|
||||||
|
"target": cur,
|
||||||
|
"in_sync": stamp == cur,
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
applySkillsResult(out, skillsResult)
|
applySkillsResult(out, skillsResult)
|
||||||
}
|
}
|
||||||
@@ -361,70 +387,36 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applySkillsStatus(env map[string]interface{}, target string) {
|
// applySkillsResult mutates the JSON envelope to include skills_action
|
||||||
state, readable, err := skillscheck.ReadState()
|
// (and skills_warning when failed). nil result = "in_sync" (dedup hit).
|
||||||
if err != nil || !readable || state.Version == "" {
|
func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) {
|
||||||
return
|
|
||||||
}
|
|
||||||
status := map[string]interface{}{
|
|
||||||
"current": state.Version,
|
|
||||||
"target": target,
|
|
||||||
"in_sync": normalizeVersion(state.Version) == normalizeVersion(target),
|
|
||||||
}
|
|
||||||
if len(state.OfficialSkills) > 0 {
|
|
||||||
status["official"] = len(state.OfficialSkills)
|
|
||||||
}
|
|
||||||
if len(state.UpdatedSkills) > 0 {
|
|
||||||
status["updated"] = len(state.UpdatedSkills)
|
|
||||||
}
|
|
||||||
if len(state.SkippedDeletedSkills) > 0 {
|
|
||||||
status["skipped_deleted"] = state.SkippedDeletedSkills
|
|
||||||
}
|
|
||||||
env["skills_status"] = status
|
|
||||||
}
|
|
||||||
|
|
||||||
func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
|
|
||||||
switch {
|
switch {
|
||||||
case r == nil:
|
case r == nil:
|
||||||
env["skills_action"] = "in_sync"
|
env["skills_action"] = "in_sync"
|
||||||
case r.Err != nil:
|
case r.Err != nil:
|
||||||
env["skills_action"] = "failed"
|
env["skills_action"] = "failed"
|
||||||
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
|
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
|
||||||
env["skills_summary"] = skillsSummary(r)
|
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||||
|
env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
env["skills_action"] = "synced"
|
env["skills_action"] = "synced"
|
||||||
env["skills_summary"] = skillsSummary(r)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
|
// emitSkillsTextHints prints human-readable feedback about the skills
|
||||||
summary := map[string]interface{}{
|
// sync result for non-JSON output.
|
||||||
"official": len(r.Official),
|
func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) {
|
||||||
"updated": len(r.Updated),
|
|
||||||
"added": len(r.Added),
|
|
||||||
"skipped_deleted": len(r.SkippedDeleted),
|
|
||||||
}
|
|
||||||
if len(r.Failed) > 0 {
|
|
||||||
summary["failed"] = r.Failed
|
|
||||||
}
|
|
||||||
return summary
|
|
||||||
}
|
|
||||||
|
|
||||||
func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
|
|
||||||
switch {
|
switch {
|
||||||
case r == nil:
|
case r == nil:
|
||||||
|
// dedup hit — silent (already up to date)
|
||||||
case r.Err != nil:
|
case r.Err != nil:
|
||||||
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err)
|
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err)
|
||||||
if len(r.Failed) > 0 {
|
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||||
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
|
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail))
|
||||||
}
|
}
|
||||||
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
|
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
|
||||||
case r.Force:
|
|
||||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
|
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
|
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
|
||||||
if len(r.SkippedDeleted) > 0 {
|
|
||||||
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ package cmdupdate
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -29,6 +28,7 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||||
|
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
|
||||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
origNew := newUpdater
|
origNew := newUpdater
|
||||||
@@ -41,53 +41,22 @@ func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
|
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
|
||||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(string) *selfupdate.NpmResult) {
|
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
|
||||||
|
npmFn func(string) *selfupdate.NpmResult,
|
||||||
|
skillsFn func() *selfupdate.NpmResult) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
origNew := newUpdater
|
origNew := newUpdater
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
u := selfupdate.New()
|
u := selfupdate.New()
|
||||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||||
u.NpmInstallOverride = npmFn
|
u.NpmInstallOverride = npmFn
|
||||||
|
u.SkillsUpdateOverride = skillsFn
|
||||||
u.VerifyOverride = func(string) error { return nil }
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
}
|
}
|
||||||
|
|
||||||
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
|
||||||
return func(args ...string) *selfupdate.NpmResult {
|
|
||||||
r := &selfupdate.NpmResult{}
|
|
||||||
switch strings.Join(args, " ") {
|
|
||||||
case "-y skills add https://open.feishu.cn --list":
|
|
||||||
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
|
|
||||||
case "-y skills ls -g":
|
|
||||||
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeVersion(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{input: "1.2.3", want: "1.2.3"},
|
|
||||||
{input: "v1.2.3", want: "1.2.3"},
|
|
||||||
{input: "V1.2.3", want: "1.2.3"},
|
|
||||||
{input: " v1.2.3 ", want: "1.2.3"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.input, func(t *testing.T) {
|
|
||||||
if got := normalizeVersion(tt.input); got != tt.want {
|
|
||||||
t.Fatalf("normalizeVersion(%q) = %q, want %q", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||||
f, stdout, _ := newTestFactory(t)
|
f, stdout, _ := newTestFactory(t)
|
||||||
|
|
||||||
@@ -199,7 +168,9 @@ func TestUpdateManual_Human(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateNpm_JSON(t *testing.T) {
|
func TestUpdateNpm_JSON(t *testing.T) {
|
||||||
// Isolate config dir because skills sync writes skills-state.json.
|
// Isolate config dir: this test mocks fetchLatest="2.0.0" and lets
|
||||||
|
// runSkillsAndStamp → WriteStamp succeed, which without isolation would
|
||||||
|
// clobber the real ~/.lark-cli/skills.stamp with "2.0.0".
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
f, stdout, _ := newTestFactory(t)
|
f, stdout, _ := newTestFactory(t)
|
||||||
@@ -215,6 +186,7 @@ func TestUpdateNpm_JSON(t *testing.T) {
|
|||||||
mockDetectAndNpm(t,
|
mockDetectAndNpm(t,
|
||||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
)
|
)
|
||||||
|
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
@@ -244,6 +216,7 @@ func TestUpdateNpm_Human(t *testing.T) {
|
|||||||
mockDetectAndNpm(t,
|
mockDetectAndNpm(t,
|
||||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
)
|
)
|
||||||
|
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
@@ -257,7 +230,7 @@ func TestUpdateNpm_Human(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateForce_JSON(t *testing.T) {
|
func TestUpdateForce_JSON(t *testing.T) {
|
||||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
f, stdout, _ := newTestFactory(t)
|
f, stdout, _ := newTestFactory(t)
|
||||||
@@ -273,6 +246,7 @@ func TestUpdateForce_JSON(t *testing.T) {
|
|||||||
mockDetectAndNpm(t,
|
mockDetectAndNpm(t,
|
||||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
)
|
)
|
||||||
|
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
@@ -349,7 +323,7 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateDevVersion_JSON(t *testing.T) {
|
func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
f, stdout, _ := newTestFactory(t)
|
f, stdout, _ := newTestFactory(t)
|
||||||
@@ -365,6 +339,7 @@ func TestUpdateDevVersion_JSON(t *testing.T) {
|
|||||||
mockDetectAndNpm(t,
|
mockDetectAndNpm(t,
|
||||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
)
|
)
|
||||||
|
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
@@ -476,8 +451,8 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
|||||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||||
u.RestoreAvailableOverride = func() bool { return false }
|
u.RestoreAvailableOverride = func() bool { return false }
|
||||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||||
t.Fatal("skills sync should not run when binary verification fails")
|
t.Fatal("skills update should not run when binary verification fails")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return u
|
return u
|
||||||
@@ -674,7 +649,7 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||||
// With the rename trick, Windows npm installs can now auto-update.
|
// With the rename trick, Windows npm installs can now auto-update.
|
||||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
f, stdout, _ := newTestFactory(t)
|
f, stdout, _ := newTestFactory(t)
|
||||||
@@ -693,6 +668,7 @@ func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
|||||||
mockDetectAndNpm(t,
|
mockDetectAndNpm(t,
|
||||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
|
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
|
||||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
)
|
)
|
||||||
|
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
@@ -774,6 +750,7 @@ func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
|
|||||||
mockDetectAndNpm(t,
|
mockDetectAndNpm(t,
|
||||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
)
|
)
|
||||||
|
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
@@ -808,7 +785,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||||
u.VerifyOverride = func(string) error { return nil }
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
// Skills update fails
|
||||||
|
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
r.Stderr.WriteString("npx: command not found")
|
r.Stderr.WriteString("npx: command not found")
|
||||||
r.Err = fmt.Errorf("exit status 127")
|
r.Err = fmt.Errorf("exit status 127")
|
||||||
@@ -834,8 +812,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
|||||||
if !strings.Contains(out, "skills_warning") {
|
if !strings.Contains(out, "skills_warning") {
|
||||||
t.Errorf("expected skills_warning in output, got: %s", out)
|
t.Errorf("expected skills_warning in output, got: %s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "skills_summary") {
|
if !strings.Contains(out, "skills_detail") {
|
||||||
t.Errorf("expected skills_summary in output, got: %s", out)
|
t.Errorf("expected skills_detail in output, got: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -860,7 +838,7 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
|||||||
}
|
}
|
||||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||||
u.VerifyOverride = func(string) error { return nil }
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
r.Stderr.WriteString("npx: command not found")
|
r.Stderr.WriteString("npx: command not found")
|
||||||
r.Err = fmt.Errorf("exit status 127")
|
r.Err = fmt.Errorf("exit status 127")
|
||||||
@@ -883,96 +861,100 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
|||||||
if !strings.Contains(out, "Skills update failed") {
|
if !strings.Contains(out, "Skills update failed") {
|
||||||
t.Errorf("expected skills failure warning, got: %s", out)
|
t.Errorf("expected skills failure warning, got: %s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "lark-cli update --force") {
|
if !strings.Contains(out, "npx -y skills add") {
|
||||||
t.Errorf("expected force retry hint, got: %s", out)
|
t.Errorf("expected manual skills command hint, got: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers.
|
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable
|
||||||
|
// for direct calls to internals like runSkillsAndStamp that write to
|
||||||
|
// io.ErrOut.
|
||||||
func newTestIO() *cmdutil.IOStreams {
|
func newTestIO() *cmdutil.IOStreams {
|
||||||
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
|
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
func TestRunSkillsAndStamp_DedupHit(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
dir := t.TempDir()
|
||||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
called := false
|
called := false
|
||||||
updater := &selfupdate.Updater{
|
updater := &selfupdate.Updater{
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||||
called = true
|
called = true
|
||||||
return &selfupdate.NpmResult{}
|
return &selfupdate.NpmResult{}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||||
if got != nil {
|
if got != nil {
|
||||||
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
|
t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got)
|
||||||
}
|
}
|
||||||
if called {
|
if called {
|
||||||
t.Error("SkillsCommandOverride called, want skipped due to dedup")
|
t.Error("SkillsUpdateOverride called, want skipped due to dedup")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
func TestRunSkillsAndStamp_DedupForceBypass(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
dir := t.TempDir()
|
||||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
called := false
|
called := false
|
||||||
updater := &selfupdate.Updater{
|
updater := &selfupdate.Updater{
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||||
called = true
|
called = true
|
||||||
return successfulSkillsCommand()(args...)
|
return &selfupdate.NpmResult{}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
|
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true)
|
||||||
if got == nil || got.Err != nil {
|
if got == nil {
|
||||||
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
|
t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil")
|
||||||
}
|
}
|
||||||
if !called {
|
if !called {
|
||||||
t.Error("SkillsCommandOverride not called with force=true")
|
t.Error("SkillsUpdateOverride not called with force=true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
func TestRunSkillsAndStamp_SuccessWritesStamp(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
dir := t.TempDir()
|
||||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
updater := &selfupdate.Updater{
|
||||||
|
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||||
|
return &selfupdate.NpmResult{}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||||
if got == nil || got.Err != nil {
|
if got == nil || got.Err != nil {
|
||||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
|
||||||
}
|
}
|
||||||
state, readable, err := skillscheck.ReadState()
|
stamp, _ := skillscheck.ReadStamp()
|
||||||
if err != nil || !readable {
|
if stamp != "1.0.21" {
|
||||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
|
||||||
}
|
|
||||||
if state.Version != "1.0.21" {
|
|
||||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
func TestRunSkillsAndStamp_FailureKeepsOldStamp(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
dir := t.TempDir()
|
||||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
updater := &selfupdate.Updater{
|
updater := &selfupdate.Updater{
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
r.Err = fmt.Errorf("npx failed")
|
r.Err = fmt.Errorf("npx failed")
|
||||||
return r
|
return r
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||||
if got == nil || got.Err == nil {
|
if got == nil || got.Err == nil {
|
||||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
|
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got)
|
||||||
}
|
}
|
||||||
state, readable, err := skillscheck.ReadState()
|
stamp, _ := skillscheck.ReadStamp()
|
||||||
if err != nil || !readable {
|
if stamp != "1.0.20" {
|
||||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp)
|
||||||
}
|
|
||||||
if state.Version != "1.0.20" {
|
|
||||||
t.Errorf("state.Version = %q, want \"1.0.20\" (failure must not overwrite)", state.Version)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -991,7 +973,8 @@ func TestTruncate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
|
||||||
origFetch := fetchLatest
|
origFetch := fetchLatest
|
||||||
origCur := currentVersion
|
origCur := currentVersion
|
||||||
@@ -1004,9 +987,9 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
return &selfupdate.Updater{
|
return &selfupdate.Updater{
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||||
skillsCalled = true
|
skillsCalled = true
|
||||||
return successfulSkillsCommand()(args...)
|
return &selfupdate.NpmResult{}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1017,19 +1000,17 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
|||||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||||
}
|
}
|
||||||
if !skillsCalled {
|
if !skillsCalled {
|
||||||
t.Error("skills sync not called in already-up-to-date branch")
|
t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called")
|
||||||
}
|
}
|
||||||
state, readable, err := skillscheck.ReadState()
|
stamp, _ := skillscheck.ReadStamp()
|
||||||
if err != nil || !readable {
|
if stamp != "1.0.21" {
|
||||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
|
||||||
}
|
|
||||||
if state.Version != "1.0.21" {
|
|
||||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
|
||||||
origFetch := fetchLatest
|
origFetch := fetchLatest
|
||||||
origCur := currentVersion
|
origCur := currentVersion
|
||||||
@@ -1048,9 +1029,9 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
|||||||
ResolvedPath: "/usr/local/bin/lark-cli",
|
ResolvedPath: "/usr/local/bin/lark-cli",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||||
skillsCalled = true
|
skillsCalled = true
|
||||||
return successfulSkillsCommand()(args...)
|
return &selfupdate.NpmResult{}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1061,19 +1042,17 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
|||||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||||
}
|
}
|
||||||
if !skillsCalled {
|
if !skillsCalled {
|
||||||
t.Error("skills sync not called in manual branch")
|
t.Error("RunSkillsUpdate not called in manual branch, want called")
|
||||||
}
|
}
|
||||||
state, readable, err := skillscheck.ReadState()
|
stamp, _ := skillscheck.ReadStamp()
|
||||||
if err != nil || !readable {
|
if stamp != "1.0.21" {
|
||||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp)
|
||||||
}
|
|
||||||
if state.Version != "1.0.21" {
|
|
||||||
t.Errorf("state.Version = %q, want \"1.0.21\" (manual path records current binary)", state.Version)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
|
||||||
origFetch := fetchLatest
|
origFetch := fetchLatest
|
||||||
origCur := currentVersion
|
origCur := currentVersion
|
||||||
@@ -1096,9 +1075,9 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
|||||||
return &selfupdate.NpmResult{}
|
return &selfupdate.NpmResult{}
|
||||||
},
|
},
|
||||||
VerifyOverride: func(expectedVersion string) error { return nil },
|
VerifyOverride: func(expectedVersion string) error { return nil },
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||||
skillsCalled = true
|
skillsCalled = true
|
||||||
return successfulSkillsCommand()(args...)
|
return &selfupdate.NpmResult{}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1109,25 +1088,18 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
|||||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||||
}
|
}
|
||||||
if !skillsCalled {
|
if !skillsCalled {
|
||||||
t.Error("skills sync not called in npm branch")
|
t.Error("RunSkillsUpdate not called in npm branch")
|
||||||
}
|
}
|
||||||
state, readable, err := skillscheck.ReadState()
|
stamp, _ := skillscheck.ReadStamp()
|
||||||
if err != nil || !readable {
|
if stamp != "1.0.22" {
|
||||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp)
|
||||||
}
|
|
||||||
if state.Version != "1.0.22" {
|
|
||||||
t.Errorf("state.Version = %q, want \"1.0.22\" (npm path records latest binary)", state.Version)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
dir := t.TempDir()
|
||||||
if err := skillscheck.WriteState(skillscheck.SkillsState{
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
Version: "1.0.20",
|
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||||
OfficialSkills: []string{"lark-calendar", "lark-mail"},
|
|
||||||
UpdatedSkills: []string{"lark-calendar"},
|
|
||||||
SkippedDeletedSkills: []string{"lark-mail"},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1145,9 +1117,9 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
|||||||
DetectOverride: func() selfupdate.DetectResult {
|
DetectOverride: func() selfupdate.DetectResult {
|
||||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
||||||
},
|
},
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||||
skillsCalled = true
|
skillsCalled = true
|
||||||
return successfulSkillsCommand()(args...)
|
return &selfupdate.NpmResult{}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1158,7 +1130,7 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
|||||||
t.Fatalf("updateRun(--check) err = %v, want nil", err)
|
t.Fatalf("updateRun(--check) err = %v, want nil", err)
|
||||||
}
|
}
|
||||||
if skillsCalled {
|
if skillsCalled {
|
||||||
t.Error("skills sync called under --check, want skipped")
|
t.Error("RunSkillsUpdate called under --check, want skipped (pure report)")
|
||||||
}
|
}
|
||||||
|
|
||||||
var env map[string]interface{}
|
var env map[string]interface{}
|
||||||
@@ -1172,14 +1144,12 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
|||||||
if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false {
|
if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false {
|
||||||
t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status)
|
t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status)
|
||||||
}
|
}
|
||||||
if status["official"] != float64(2) || status["updated"] != float64(1) {
|
|
||||||
t.Errorf("skills_status counts = %+v, want official:2 updated:1", status)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
dir := t.TempDir()
|
||||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1194,9 +1164,9 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
return &selfupdate.Updater{
|
return &selfupdate.Updater{
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||||
skillsCalled = true
|
skillsCalled = true
|
||||||
return successfulSkillsCommand()(args...)
|
return &selfupdate.NpmResult{}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1207,15 +1177,12 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
|||||||
t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err)
|
t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err)
|
||||||
}
|
}
|
||||||
if skillsCalled {
|
if skillsCalled {
|
||||||
t.Error("skills sync called under --check (already-latest), want skipped")
|
t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)")
|
||||||
}
|
}
|
||||||
|
|
||||||
state, readable, err := skillscheck.ReadState()
|
stamp, _ := skillscheck.ReadStamp()
|
||||||
if err != nil || !readable {
|
if stamp != "1.0.20" {
|
||||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
t.Errorf("stamp mutated to %q under --check, want \"1.0.20\" (pure report must not write stamp)", stamp)
|
||||||
}
|
|
||||||
if state.Version != "1.0.20" {
|
|
||||||
t.Errorf("state.Version mutated to %q under --check, want \"1.0.20\"", state.Version)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var env map[string]interface{}
|
var env map[string]interface{}
|
||||||
@@ -1237,248 +1204,39 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
|
// TestRunSkillsAndStamp_StampWriteFailureWarns verifies the stderr warning
|
||||||
origSync := syncSkills
|
// emission when RunSkillsUpdate succeeds but WriteStamp fails.
|
||||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
|
func TestRunSkillsAndStamp_StampWriteFailureWarns(t *testing.T) {
|
||||||
return &skillscheck.SyncResult{Err: fmt.Errorf("skills synced but state not written: denied")}
|
// Force WriteStamp to fail by pointing config dir at a path that exists
|
||||||
|
// as a regular file (so MkdirAll fails).
|
||||||
|
tmp := t.TempDir()
|
||||||
|
badPath := filepath.Join(tmp, "blocker")
|
||||||
|
if err := os.WriteFile(badPath, []byte("not-a-dir"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { syncSkills = origSync })
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath)
|
||||||
|
|
||||||
f, _, stderr := newTestFactory(t)
|
f, _, stderr := newTestFactory(t)
|
||||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
|
updater := &selfupdate.Updater{
|
||||||
if got == nil || got.Err == nil {
|
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
|
return &selfupdate.NpmResult{} // success
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), "warning: skills synced but state not written") {
|
got := runSkillsAndStamp(updater, f.IOStreams, "1.0.21", false)
|
||||||
|
if got == nil || got.Err != nil {
|
||||||
|
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "warning: skills synced but stamp not written") {
|
||||||
t.Errorf("stderr does not contain warning: %q", stderr.String())
|
t.Errorf("stderr does not contain warning: %q", stderr.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEmitSkillsTextHints_Success verifies the "Skills updated" success
|
||||||
|
// message is printed to ErrOut on a successful (Err == nil) result.
|
||||||
func TestEmitSkillsTextHints_Success(t *testing.T) {
|
func TestEmitSkillsTextHints_Success(t *testing.T) {
|
||||||
f, _, stderr := newTestFactory(t)
|
f, _, stderr := newTestFactory(t)
|
||||||
emitSkillsTextHints(f.IOStreams, &skillscheck.SyncResult{Official: []string{"lark-calendar"}, Updated: []string{"lark-calendar"}})
|
emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success
|
||||||
if !strings.Contains(stderr.String(), "Skills updated") {
|
if !strings.Contains(stderr.String(), "Skills updated") {
|
||||||
t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String())
|
t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
|
|
||||||
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
|
|
||||||
// state file. It calls the real npx skills CLI, so the test is skipped when
|
|
||||||
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
|
|
||||||
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
|
||||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
|
||||||
if _, err := exec.LookPath("npx"); err != nil {
|
|
||||||
t.Skipf("npx not found in PATH: %v", err)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
|
||||||
t.Skipf("real skills CLI unavailable: %v", err)
|
|
||||||
}
|
|
||||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
|
||||||
}
|
|
||||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
|
|
||||||
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
|
|
||||||
// intentionally removed them while they were still official skills.
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
before := skillscheck.SkillsState{
|
|
||||||
Version: "1.0.19",
|
|
||||||
OfficialSkills: []string{"lark-approval", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"},
|
|
||||||
UpdatedSkills: []string{"lark-approval", "lark-apps", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"},
|
|
||||||
AddedOfficialSkills: []string{},
|
|
||||||
SkippedDeletedSkills: []string{},
|
|
||||||
UpdatedAt: "2026-05-20T00:00:00Z",
|
|
||||||
}
|
|
||||||
if err := skillscheck.WriteState(before); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
state, readable, err := skillscheck.ReadState()
|
|
||||||
if err != nil || !readable {
|
|
||||||
t.Fatalf("ReadState() before update = (_, %v, %v), want readable", readable, err)
|
|
||||||
}
|
|
||||||
if state.Version != "1.0.19" {
|
|
||||||
t.Fatalf("state.Version before update = %q, want 1.0.19", state.Version)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3: Mock version functions so the update command believes it has
|
|
||||||
// upgraded from 1.0.19 to 1.0.20, then execute "lark-cli update --json".
|
|
||||||
// This triggers SyncSkills which calls the real npx skills add command.
|
|
||||||
origFetch := fetchLatest
|
|
||||||
origVersion := currentVersion
|
|
||||||
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion })
|
|
||||||
fetchLatest = func() (string, error) { return "1.0.20", nil }
|
|
||||||
currentVersion = func() string { return "1.0.20" }
|
|
||||||
|
|
||||||
f, stdout, _ := newTestFactory(t)
|
|
||||||
cmd := NewCmdUpdate(f)
|
|
||||||
cmd.SetArgs([]string{"--json"})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("lark-cli update --json err = %v, want nil", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 4: Verify the state file was rewritten with the new version,
|
|
||||||
// non-empty official/updated skill lists, and a refreshed timestamp.
|
|
||||||
state, readable, err = skillscheck.ReadState()
|
|
||||||
if err != nil || !readable {
|
|
||||||
t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err)
|
|
||||||
}
|
|
||||||
if state.Version != "1.0.20" {
|
|
||||||
t.Errorf("state.Version after update = %q, want 1.0.20", state.Version)
|
|
||||||
}
|
|
||||||
if len(state.OfficialSkills) == 0 {
|
|
||||||
t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state)
|
|
||||||
}
|
|
||||||
if len(state.UpdatedSkills) == 0 {
|
|
||||||
t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state)
|
|
||||||
}
|
|
||||||
if state.UpdatedAt == "" || state.UpdatedAt == before.UpdatedAt {
|
|
||||||
t.Errorf("state.UpdatedAt = %q, want refreshed non-empty timestamp", state.UpdatedAt)
|
|
||||||
}
|
|
||||||
// Verify that previously-skipped skills are handled correctly:
|
|
||||||
// - If locally installed → should appear in UpdatedSkills (updated to latest)
|
|
||||||
// - If locally absent → should NOT be force-restored in UpdatedSkills,
|
|
||||||
// and should remain in SkippedDeletedSkills
|
|
||||||
for _, skill := range []string{"lark-doc", "lark-mail"} {
|
|
||||||
if containsString(localSkills, skill) {
|
|
||||||
if !containsString(state.UpdatedSkills, skill) {
|
|
||||||
t.Errorf("state.UpdatedSkills = %v, want installed skill %q updated", state.UpdatedSkills, skill)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if containsString(state.UpdatedSkills, skill) {
|
|
||||||
t.Errorf("state.UpdatedSkills = %v, want deleted skill %q not restored without --force", state.UpdatedSkills, skill)
|
|
||||||
}
|
|
||||||
if !containsString(state.SkippedDeletedSkills, skill) {
|
|
||||||
t.Errorf("state.SkippedDeletedSkills = %v, want deleted skill %q preserved when still official", state.SkippedDeletedSkills, skill)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 5: Verify the JSON output structure is parseable and contains
|
|
||||||
// the expected action fields for AI agent consumption.
|
|
||||||
var env map[string]interface{}
|
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
||||||
t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String())
|
|
||||||
}
|
|
||||||
if env["action"] != "already_up_to_date" {
|
|
||||||
t.Errorf("action = %v, want already_up_to_date", env["action"])
|
|
||||||
}
|
|
||||||
if env["skills_action"] != "synced" {
|
|
||||||
t.Errorf("skills_action = %v, want synced", env["skills_action"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateCommand_SkillsSyncColdStart verifies that when skills-state.json does
|
|
||||||
// not exist (cold start), the update command installs all official skills and
|
|
||||||
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
|
|
||||||
// because there is no previous state to preserve user deletions from.
|
|
||||||
// This is a live integration test that calls the real npx skills CLI; it is
|
|
||||||
// skipped when npx or the skills registry is unavailable.
|
|
||||||
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
|
|
||||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
|
||||||
if _, err := exec.LookPath("npx"); err != nil {
|
|
||||||
t.Skipf("npx not found in PATH: %v", err)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
|
||||||
t.Skipf("real skills CLI unavailable: %v", err)
|
|
||||||
}
|
|
||||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
|
||||||
}
|
|
||||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
if _, readable, _ := skillscheck.ReadState(); readable {
|
|
||||||
t.Fatal("skills-state.json should not exist before update")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3: Mock version functions so the update command believes it is at
|
|
||||||
// v1.0.20, then execute "lark-cli update --json". This triggers SyncSkills
|
|
||||||
// which calls the real npx skills add command.
|
|
||||||
origFetch := fetchLatest
|
|
||||||
origVersion := currentVersion
|
|
||||||
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion })
|
|
||||||
fetchLatest = func() (string, error) { return "1.0.20", nil }
|
|
||||||
currentVersion = func() string { return "1.0.20" }
|
|
||||||
|
|
||||||
f, stdout, _ := newTestFactory(t)
|
|
||||||
cmd := NewCmdUpdate(f)
|
|
||||||
cmd.SetArgs([]string{"--json"})
|
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
t.Fatalf("lark-cli update --json err = %v, want nil", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 4: Verify the state file was created with all official skills in
|
|
||||||
// UpdatedSkills and nothing in SkippedDeletedSkills (cold start = no prior
|
|
||||||
// deletions to honor). Locally installed skills should appear in UpdatedSkills.
|
|
||||||
state, readable, err := skillscheck.ReadState()
|
|
||||||
if err != nil || !readable {
|
|
||||||
t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err)
|
|
||||||
}
|
|
||||||
if state.Version != "1.0.20" {
|
|
||||||
t.Errorf("state.Version = %q, want 1.0.20", state.Version)
|
|
||||||
}
|
|
||||||
if len(state.OfficialSkills) == 0 {
|
|
||||||
t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state)
|
|
||||||
}
|
|
||||||
if len(state.UpdatedSkills) == 0 {
|
|
||||||
t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state)
|
|
||||||
}
|
|
||||||
if state.UpdatedAt == "" {
|
|
||||||
t.Error("state.UpdatedAt is empty, want non-empty timestamp")
|
|
||||||
}
|
|
||||||
// All locally installed official skills must appear in UpdatedSkills.
|
|
||||||
officialSet := map[string]bool{}
|
|
||||||
for _, s := range state.OfficialSkills {
|
|
||||||
officialSet[s] = true
|
|
||||||
}
|
|
||||||
for _, skill := range localSkills {
|
|
||||||
if !officialSet[skill] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !containsString(state.UpdatedSkills, skill) {
|
|
||||||
t.Errorf("state.UpdatedSkills = %v, want locally installed official skill %q updated", state.UpdatedSkills, skill)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No skill should be in SkippedDeletedSkills on cold start — there is no
|
|
||||||
// previous state recording a user deletion to preserve.
|
|
||||||
if len(state.SkippedDeletedSkills) != 0 {
|
|
||||||
t.Errorf("state.SkippedDeletedSkills = %v, want empty on cold start", state.SkippedDeletedSkills)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 5: Verify the JSON output structure is parseable and contains
|
|
||||||
// the expected action fields for AI agent consumption.
|
|
||||||
var env map[string]interface{}
|
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
||||||
t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String())
|
|
||||||
}
|
|
||||||
if env["action"] != "already_up_to_date" {
|
|
||||||
t.Errorf("action = %v, want already_up_to_date", env["action"])
|
|
||||||
}
|
|
||||||
if env["skills_action"] != "synced" {
|
|
||||||
t.Errorf("skills_action = %v, want synced", env["skills_action"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsString(values []string, target string) bool {
|
|
||||||
for _, value := range values {
|
|
||||||
if value == target {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,597 +0,0 @@
|
|||||||
# lark-cli Error Contract
|
|
||||||
|
|
||||||
`errs/` defines a typed, RFC 7807–aligned error taxonomy for the CLI. Three
|
|
||||||
audiences depend on it: **AI agents and shell scripts** parsing the JSON
|
|
||||||
envelope on stderr; **protocol adapters** mapping CLI errors into MCP /
|
|
||||||
OAuth shapes; and **framework + business code** producing errors. This file
|
|
||||||
is the single source of truth for all three.
|
|
||||||
|
|
||||||
This document describes the **typed authoring target**. The refactor lands
|
|
||||||
in stages; some boundaries (e.g. `client.WrapDoAPIError`) still operate on
|
|
||||||
legacy shapes today — see **Migration** for what is live in each stage.
|
|
||||||
|
|
||||||
Migrating an `*output.ExitError` call site? See **Migration**. Something off
|
|
||||||
in production? See **Troubleshooting**.
|
|
||||||
|
|
||||||
## Invariants
|
|
||||||
|
|
||||||
1. Every error belongs to exactly one **Category**. The set is closed
|
|
||||||
(`errs/category.go`); adding a member requires deliberate review.
|
|
||||||
2. Every **newly constructed** typed error has a **Subtype** — a stable
|
|
||||||
lowercase-with-underscores identifier declared in `errs/subtypes*.go`.
|
|
||||||
Undeclared subtypes fail CI. The constraint applies only to typed
|
|
||||||
`*errs.*` literals; stage-1 legacy `*core.ConfigError` flows via the
|
|
||||||
dispatcher's `asExitError` → legacy envelope path (not the typed
|
|
||||||
taxonomy) and is unaffected. `errcompat.PromoteConfigError` is a
|
|
||||||
stage-1 passthrough; its stage-2+ typed migration will subject the
|
|
||||||
promoted typed error to this Subtype constraint at that time.
|
|
||||||
3. **`Category` + `Subtype`** are wire-stable identifiers consumers may
|
|
||||||
branch on. Renaming either is a breaking change.
|
|
||||||
4. `Code` is the upstream numeric code when known (e.g. Lark API code).
|
|
||||||
It is `omitempty` and never carries CLI-internal meaning.
|
|
||||||
5. Every typed error embeds `errs.Problem`. `CheckProblemEmbed` rejects
|
|
||||||
exported `*Error` structs that do not.
|
|
||||||
6. Wrapping is idempotent: re-wrapping an already-typed error returns it
|
|
||||||
unchanged across the `errors.As` / `errors.Unwrap` chain.
|
|
||||||
7. For the typed-envelope path, exit codes derive from `Category` only
|
|
||||||
via `output.ExitCodeForCategory` — including `SecurityPolicyError`,
|
|
||||||
which exits `6` via `CategoryPolicy`. Unmigrated `*output.ExitError`
|
|
||||||
producers still carry a hand-set `Code` until they finish migrating.
|
|
||||||
`output.ErrBare(code)` is the lone exception: a deliberate
|
|
||||||
predicate-command signal that bypasses the envelope (see
|
|
||||||
**Predicate commands** below).
|
|
||||||
|
|
||||||
## Wire format
|
|
||||||
|
|
||||||
Typed errors render to **stderr** as one JSON object per process exit:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": false,
|
|
||||||
"identity": "user",
|
|
||||||
"error": {
|
|
||||||
"type": "authorization",
|
|
||||||
"subtype": "missing_scope",
|
|
||||||
"code": 99991679,
|
|
||||||
"message": "missing scope `calendar:event:create` for app cli_xxx",
|
|
||||||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
|
||||||
"log_id": "20260520-0a1b2c3d",
|
|
||||||
"missing_scopes": ["calendar:event:create"],
|
|
||||||
"console_url": "https://open.feishu.cn/app/cli_xxx/auth?q=..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Stability | Notes |
|
|
||||||
|-------|-----------|-------|
|
|
||||||
| `ok` | wire-stable | always `false` for errors |
|
|
||||||
| `identity` | wire-stable | `user` \| `bot` — caller identity; omitted when not resolved |
|
|
||||||
| `error.type` | **wire-stable** | one of the 9 Categories |
|
|
||||||
| `error.subtype` | **wire-stable** | declared Subtype constant |
|
|
||||||
| `error.code` | wire-stable | upstream numeric code, omitted when zero |
|
|
||||||
| `error.message` | informational | not safe to branch on |
|
|
||||||
| `error.hint` | informational | actionable recovery guidance |
|
|
||||||
| `error.log_id` | informational | upstream request id (server-side trace) |
|
|
||||||
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
|
|
||||||
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
|
|
||||||
|
|
||||||
`SecurityPolicyError` renders through the same typed envelope as every
|
|
||||||
other category. `error.type` is `"policy"`, `error.subtype` is one of
|
|
||||||
`challenge_required` / `access_denied`, and process exit is `6` via
|
|
||||||
`CategoryPolicy`. The legacy `auth_error` envelope at exit `1` has been
|
|
||||||
retired.
|
|
||||||
|
|
||||||
## Categories
|
|
||||||
|
|
||||||
| Category | When | Exit | Typed struct |
|
|
||||||
|----------|------|------|--------------|
|
|
||||||
| `validation` | malformed user input | 2 | `ValidationError` |
|
|
||||||
| `authentication` | no valid token / login required | 3 | `AuthenticationError` |
|
|
||||||
| `authorization` | token lacks scope / app permission denied | 3 | `PermissionError` |
|
|
||||||
| `config` | local config missing / unbound | 3 | `ConfigError` |
|
|
||||||
| `network` | DNS, refused, timeout, transport | 4 | `NetworkError` |
|
|
||||||
| `api` | server-side Lark error w/o specific bucket | 1 | `APIError` |
|
|
||||||
| `policy` | content safety / security challenge | 6 | `SecurityPolicyError`, `ContentSafetyError` |
|
|
||||||
| `internal` | SDK contract violation / decode failure | 5 | `InternalError` |
|
|
||||||
| `confirmation` | high-risk action needs `--yes` | 10 | `ConfirmationRequiredError` |
|
|
||||||
|
|
||||||
Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
|
|
||||||
|
|
||||||
> **Note on the `authorization` / `PermissionError` asymmetry.** The wire
|
|
||||||
> `type` field uses the RFC 7807 / taxonomy-formal name `"authorization"`,
|
|
||||||
> but the Go type is named `PermissionError`. This is deliberate, following
|
|
||||||
> the gRPC / Google APIs convention (`codes.Unauthenticated` +
|
|
||||||
> `codes.PermissionDenied`): each name is chosen to be **maximally
|
|
||||||
> distinct and readable on its own**, not to be perfectly symmetric.
|
|
||||||
> `AuthenticationError` and `AuthorizationError` differ visually only at
|
|
||||||
> the 5th character and are easy to confuse in code review;
|
|
||||||
> `AuthenticationError` and `PermissionError` cannot be confused. The wire
|
|
||||||
> field stays formal because it is the protocol-level taxonomy; the Go
|
|
||||||
> type favors call-site readability.
|
|
||||||
|
|
||||||
## Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
call site
|
|
||||||
│ constructs typed error (e.g. *errs.ValidationError)
|
|
||||||
▼
|
|
||||||
command runE returns err
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
cmd/root.go handleRootError dispatches:
|
|
||||||
├─ output.ErrBare(code) → no envelope (stdout already written); exit = code
|
|
||||||
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
|
|
||||||
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6)
|
|
||||||
├─ *core.ConfigError → promoted to typed via errcompat ↑
|
|
||||||
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
|
|
||||||
└─ untyped / Cobra error → plain "Error: <msg>" (no envelope); exit 1
|
|
||||||
```
|
|
||||||
|
|
||||||
Only the typed and `*output.ExitError` branches emit a JSON envelope on
|
|
||||||
stderr. Untyped errors (including Cobra's "required flag missing" / unknown
|
|
||||||
subcommand messages) print plain text and exit `1` — consumers must
|
|
||||||
tolerate that fallback.
|
|
||||||
|
|
||||||
### Predicate commands (`output.ErrBare`)
|
|
||||||
|
|
||||||
A small class of commands is **predicates**: they answer a yes/no
|
|
||||||
question and signal the answer through the shell exit code so callers
|
|
||||||
can write `if cmd; then ... fi`. `lark-cli auth check` is the canonical
|
|
||||||
example — its `README` contract is `exit 0 = ok, 1 = missing`.
|
|
||||||
|
|
||||||
These commands deliberately:
|
|
||||||
|
|
||||||
1. write a structured JSON answer to **stdout** themselves, and
|
|
||||||
2. return `output.ErrBare(exitCode)` to communicate the exit code to
|
|
||||||
the dispatcher without producing a `stderr` envelope.
|
|
||||||
|
|
||||||
`output.ErrBare` is **not** an error in the typed-envelope sense — it
|
|
||||||
carries no category, subtype, or message. It is a one-bit output-
|
|
||||||
control signal that lives outside the contract for the same reason
|
|
||||||
`grep -q` / `diff` / `systemctl is-active` set non-zero exit codes
|
|
||||||
without printing anything to stderr: pollution of stderr by a
|
|
||||||
predicate's negative answer would break `2>/dev/null` log hygiene in
|
|
||||||
caller scripts.
|
|
||||||
|
|
||||||
New code should not reach for `ErrBare` unless the command is
|
|
||||||
genuinely a predicate. Anything carrying recoverable error content
|
|
||||||
belongs in a typed `*errs.XxxError`.
|
|
||||||
|
|
||||||
## Consumers
|
|
||||||
|
|
||||||
### Go (in-process)
|
|
||||||
|
|
||||||
```go
|
|
||||||
var pe *errs.PermissionError
|
|
||||||
if errors.As(err, &pe) {
|
|
||||||
fmt.Println("missing:", pe.MissingScopes)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Predicates cover the common categories (`errs/predicates.go`):
|
|
||||||
|
|
||||||
```go
|
|
||||||
if errs.IsAuthentication(err) { ... }
|
|
||||||
if errs.IsPermission(err) { ... }
|
|
||||||
if errs.IsValidation(err) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
Type-agnostic field access:
|
|
||||||
|
|
||||||
```go
|
|
||||||
if p, ok := errs.ProblemOf(err); ok {
|
|
||||||
log.Printf("cat=%s subtype=%s retryable=%t", p.Category, p.Subtype, p.Retryable)
|
|
||||||
}
|
|
||||||
exitCode := output.ExitCodeOf(err) // ExitInternal for non-typed errors
|
|
||||||
```
|
|
||||||
|
|
||||||
### Shell / AI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
out=$(lark-cli ... 2>&1)
|
|
||||||
code=$?
|
|
||||||
|
|
||||||
# Untyped / Cobra errors print plain text — guard before jq.
|
|
||||||
if ! jq -e . >/dev/null 2>&1 <<<"$out"; then
|
|
||||||
printf '%s\n' "$out" >&2
|
|
||||||
exit "$code"
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$(jq -r '.error.type // empty' <<<"$out")" in
|
|
||||||
authorization) jq -r '.error.missing_scopes[]' <<<"$out" ;;
|
|
||||||
network) echo "transport failure, safe to retry" ;;
|
|
||||||
internal) echo "bug — file an issue with log_id $(jq -r '.error.log_id // "n/a"' <<<"$out")" ;;
|
|
||||||
esac
|
|
||||||
```
|
|
||||||
|
|
||||||
Unknown fields are forward-compatible additions: ignore, don't fail.
|
|
||||||
Branch only on `type`, `subtype`, `code`, `retryable`, and declared
|
|
||||||
extension fields — `message` is human-readable prose that may be
|
|
||||||
reworded without notice.
|
|
||||||
|
|
||||||
## Producers
|
|
||||||
|
|
||||||
### Quick reference
|
|
||||||
|
|
||||||
The canonical producer surface is the **builder API in `errs/types.go`** (per type: struct + `NewXxxError` + chained `WithX` setters live in one place):
|
|
||||||
each `NewXxxError(subtype, format, args...)` locks `Category` at the
|
|
||||||
constructor name, requires `Subtype` + `Message` positionally, and exposes
|
|
||||||
optional fields via chained `.WithX(...)` setters. Struct literals remain
|
|
||||||
legal for framework dynamic paths (e.g. classifier fanout) but the lint
|
|
||||||
`CheckTypedErrorCompleteness` still requires `Category` + `Subtype` +
|
|
||||||
`Message` on any literal it sees.
|
|
||||||
|
|
||||||
| Situation | Use |
|
|
||||||
|-----------|-----|
|
|
||||||
| Bad user input | `errs.NewValidationError(subtype, msg).WithParam("--flag")` |
|
|
||||||
| Login required | `errs.NewAuthenticationError(errs.SubtypeTokenMissing, msg)` |
|
|
||||||
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
|
|
||||||
| Local config missing | `errs.NewConfigError(errs.SubtypeNotConfigured, msg)` |
|
|
||||||
| Transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTimeout, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport`) |
|
|
||||||
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
|
|
||||||
| SDK / decode bug | `errs.NewInternalError(errs.SubtypeSDKError, msg).WithCause(err)` |
|
|
||||||
| Policy block | `errs.NewSecurityPolicyError(subtype, msg).WithChallengeURL(url)` or `errs.NewContentSafetyError(subtype, msg).WithRules(...)` |
|
|
||||||
| Needs `--yes` | `errs.NewConfirmationRequiredError(risk, action, msg)` |
|
|
||||||
|
|
||||||
### Authoring discipline
|
|
||||||
|
|
||||||
Five rules every producer follows. Some are enforced by `lint/errscontract`
|
|
||||||
AST guards (`go run -C lint . ..`); the rest by code review.
|
|
||||||
|
|
||||||
#### Propagate typed errors unchanged
|
|
||||||
|
|
||||||
A function that receives an error already carrying `errs.Problem`
|
|
||||||
returns it as-is up the stack. Reclassification at non-boundary frames
|
|
||||||
(e.g., wrapping a `*ValidationError` into `*InternalError`) defeats the
|
|
||||||
single-source taxonomy and silently downgrades typed signals.
|
|
||||||
|
|
||||||
Conforming:
|
|
||||||
|
|
||||||
```go
|
|
||||||
_, err := runtime.DoAPI(req, opts)
|
|
||||||
if err != nil {
|
|
||||||
return err // already typed by the framework boundary
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Non-conforming:
|
|
||||||
|
|
||||||
```go
|
|
||||||
return fmt.Errorf("calling /open-apis: %v", err) // %v strips the typed shape
|
|
||||||
return &errs.InternalError{Cause: err} // re-decides category
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Never return a typed-nil pointer
|
|
||||||
|
|
||||||
A typed-nil pointer (`var pe *errs.PermissionError; return pe`) wraps as
|
|
||||||
a non-nil interface — `errors.As` matches and `.Error()` may panic.
|
|
||||||
Return interface `nil` literally.
|
|
||||||
|
|
||||||
Non-conforming:
|
|
||||||
|
|
||||||
```go
|
|
||||||
var e *errs.ValidationError // nil pointer
|
|
||||||
return e // non-nil interface holding nil pointer
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Let `Category` derive the exit code
|
|
||||||
|
|
||||||
Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory`
|
|
||||||
maps `Category` to the shell code. A new exit-code requirement means a
|
|
||||||
new `Category`, not a one-off override at the call site.
|
|
||||||
|
|
||||||
(Legacy `*output.ExitError` retains hand-set codes until removal;
|
|
||||||
`SecurityPolicyError` retains a hand-set code on main until the framework
|
|
||||||
migration PR retires the carve-out — see **Migration**.)
|
|
||||||
|
|
||||||
#### Split `Message`, `Hint`, and `Cause`
|
|
||||||
|
|
||||||
Each field carries a distinct role:
|
|
||||||
|
|
||||||
| Field | Carries | Style |
|
|
||||||
|-------|---------|-------|
|
|
||||||
| `Message` | What is wrong | Direct, lowercase first letter, no trailing period |
|
|
||||||
| `Hint` | What to do next | Imperative ("run `lark-cli auth login`", "use `--as user`") |
|
|
||||||
| `Cause` | The wrapped upstream `error`, not a stringified copy | Typed; serialized as `json:"-"` |
|
|
||||||
|
|
||||||
`Hint` must not be merged into `Message`. AI agents and humans read them
|
|
||||||
on separate channels; merging defeats both.
|
|
||||||
|
|
||||||
`Cause` must be a real `error`. If the upstream returned an `error`,
|
|
||||||
place it in `Cause` so `errors.Is` and `errors.Unwrap` walk the chain —
|
|
||||||
do not inline its `.Error()` into `Message`.
|
|
||||||
|
|
||||||
Conforming:
|
|
||||||
|
|
||||||
```go
|
|
||||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
|
||||||
"request to /open-apis failed after 3 retries").
|
|
||||||
WithHint("check connectivity and retry; set --log-level debug if it persists").
|
|
||||||
WithCause(ioErr)
|
|
||||||
```
|
|
||||||
|
|
||||||
Non-conforming:
|
|
||||||
|
|
||||||
```go
|
|
||||||
Message: fmt.Sprintf("request failed: %v — retry later", ioErr)
|
|
||||||
// conflates what + what-to-do + cause into one string
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `ValidationError.Param` uses the `--flag` form
|
|
||||||
|
|
||||||
When a `*ValidationError` originates from a flag value, `Param` holds the
|
|
||||||
flag name with leading dashes (`"--priority"`, not `"priority"`). AI
|
|
||||||
agents grep this field literally to surface "the bad flag was `--X`".
|
|
||||||
|
|
||||||
For positional arguments, use the canonical name without dashes
|
|
||||||
(`"target_user_id"`).
|
|
||||||
|
|
||||||
### Constructing typed errors
|
|
||||||
|
|
||||||
Prefer the **builder API**. The constructor pins `Category` + `Subtype` +
|
|
||||||
`Message`, the chained setters fill optional fields, and the resulting
|
|
||||||
value retains its concrete `*XxxError` pointer through the chain so
|
|
||||||
type-specific setters remain reachable to the end:
|
|
||||||
|
|
||||||
```go
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"--data must be a valid JSON object: %v", parseErr).
|
|
||||||
WithParam("--data")
|
|
||||||
```
|
|
||||||
|
|
||||||
Why builder over struct literal:
|
|
||||||
|
|
||||||
- `Category` is locked at the function name — caller cannot mis-specify it
|
|
||||||
- `Subtype` and `Message` are positional arguments — `go build` rejects
|
|
||||||
the call site if either is missing
|
|
||||||
- The chain reads top-down: required identity first, optional fields after
|
|
||||||
- Message is `fmt.Sprintf`-formatted from `(format, args...)`, matching
|
|
||||||
`fmt.Errorf` muscle memory and avoiding a separate `Sprintf` line
|
|
||||||
|
|
||||||
Struct literals remain legal — `CheckTypedErrorCompleteness` continues to
|
|
||||||
enforce `Category` + `Subtype` + `Message` on any literal it sees — and
|
|
||||||
the framework classifier (`internal/errclass/classify.go`) still uses
|
|
||||||
them on the dynamic dispatch path where a `Problem` value is composed
|
|
||||||
once and wrapped per Category branch. Outside that pattern, new code
|
|
||||||
should reach for the builder.
|
|
||||||
|
|
||||||
Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`)
|
|
||||||
remain callable during migration but are `// Deprecated:` — new code goes
|
|
||||||
through the builder.
|
|
||||||
|
|
||||||
#### Shortcut `Execute` walkthrough
|
|
||||||
|
|
||||||
Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy
|
|
||||||
form is `output.ErrValidation("--duration-minutes must be between 1 and
|
|
||||||
1440")`. The typed migration target (builder form):
|
|
||||||
|
|
||||||
```go
|
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
||||||
duration := runtime.Int("duration-minutes")
|
|
||||||
if duration < 1 || duration > 1440 {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"--duration-minutes must be between 1 and 1440, got %d", duration).
|
|
||||||
WithHint("pass a value in [1, 1440]").
|
|
||||||
WithParam("--duration-minutes")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := runtime.DoAPI(req, opts)
|
|
||||||
if err != nil {
|
|
||||||
return err // already typed by the framework boundary; propagate
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Two patterns visible: a producer site (the typed `*errs.ValidationError`
|
|
||||||
above) and a propagation site (the `return err` after `runtime.DoAPI`,
|
|
||||||
applying [Propagate typed errors unchanged](#propagate-typed-errors-unchanged)).
|
|
||||||
|
|
||||||
When the validation logic outgrows a single range check — multiple
|
|
||||||
flags, format parsing, conditional rules — extract it into a helper that
|
|
||||||
also returns the typed `*errs.ValidationError`. The helper, not
|
|
||||||
`Execute`, sets `Param` (a helper bound to one shortcut is normal in
|
|
||||||
this codebase; see `parseTimeRange` in
|
|
||||||
`shortcuts/calendar/calendar_agenda.go:144`).
|
|
||||||
|
|
||||||
### Wrapping upstream errors
|
|
||||||
|
|
||||||
When a producer receives an error from a function it called, four cases
|
|
||||||
cover the decision:
|
|
||||||
|
|
||||||
| Source | Decision | Example |
|
|
||||||
|--------|----------|---------|
|
|
||||||
| Helper returned a typed `*errs.*Error` | Return unchanged | `return err` |
|
|
||||||
| Helper returned an untyped error tied to user input (`strconv.Atoi`, `json.Unmarshal`, …) | Construct a typed error; put the untyped error in `Cause` | `return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --data: %v", jsonErr).WithCause(jsonErr)` |
|
|
||||||
| SDK call via `runtime.DoAPI` failed | Return unchanged — the framework boundary already wrapped it | `return err` |
|
|
||||||
| Invariant broken (must-not-happen state) | Lift with `errs.WrapInternal`, set a `Message` describing the invariant | `return errs.WrapInternal(fmt.Errorf("identity resolver returned nil: %w", err))` |
|
|
||||||
|
|
||||||
Prefer the `Cause` field over `fmt.Errorf("ctx: %w", err)` when
|
|
||||||
attaching an upstream error to a typed one. `Cause` is the chain
|
|
||||||
`errs.UnwrapTypedError` walks and the chain consumer code expects;
|
|
||||||
`fmt.Errorf("...: %w", err)` only affects `.Error()` output, which the
|
|
||||||
wire envelope does not surface.
|
|
||||||
|
|
||||||
#### Boundary helpers (framework-internal)
|
|
||||||
|
|
||||||
These helpers are called from framework boundaries, not from domain
|
|
||||||
code:
|
|
||||||
|
|
||||||
- `errs.WrapInternal(err)` — lifts an untyped error to `*InternalError`;
|
|
||||||
already-typed errors pass through unchanged.
|
|
||||||
- `client.WrapDoAPIError(err)` — classifies SDK transport / decode
|
|
||||||
failures into `*errs.NetworkError` / `*errs.InternalError` at the SDK
|
|
||||||
boundary.
|
|
||||||
- `client.WrapJSONResponseParseError(body, err)` — lifts response-layer
|
|
||||||
JSON parse failures to `*errs.InternalError`.
|
|
||||||
|
|
||||||
If you find yourself reaching for `WrapDoAPIError` from a `shortcuts/**`
|
|
||||||
package, you are probably calling the SDK at the wrong layer — go
|
|
||||||
through `runtime.DoAPI`.
|
|
||||||
|
|
||||||
### Extending the taxonomy
|
|
||||||
|
|
||||||
#### Add a Subtype
|
|
||||||
|
|
||||||
1. Add a constant in `errs/subtypes.go` under the right Category block.
|
|
||||||
Subtypes are framework-shared — service-specific Subtypes are an
|
|
||||||
anti-pattern (the wire `code` field already identifies the source
|
|
||||||
service; Subtype encodes cross-service semantics like `not_found`,
|
|
||||||
`quota_exceeded`).
|
|
||||||
2. If it maps from a Lark code, register the mapping in
|
|
||||||
`internal/errclass/codemeta_<service>.go`.
|
|
||||||
3. Add a dispatch test in `internal/errclass/classify_test.go`.
|
|
||||||
4. Reference the constant from a producer.
|
|
||||||
5. `go run -C lint . ..` — `CheckDeclaredSubtype` fails until the
|
|
||||||
constant is wired through.
|
|
||||||
|
|
||||||
`ad_hoc_*` subtypes are a temporary unblocker that label a value for
|
|
||||||
follow-up, not a permanent identifier. Resolve any `ad_hoc_*` to a
|
|
||||||
declared constant within one week of introduction; `CheckAdHocSubtype`
|
|
||||||
emits a warning to keep them visible.
|
|
||||||
|
|
||||||
#### Add a typed Error struct
|
|
||||||
|
|
||||||
Rare; the existing structs cover the 9 Categories with room. If you must:
|
|
||||||
|
|
||||||
1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field.
|
|
||||||
2. Add an `IsXxx` predicate in `errs/predicates.go`.
|
|
||||||
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_builder_test.go`.
|
|
||||||
|
|
||||||
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
|
|
||||||
top-level wire fields are forbidden — per-Subtype data goes into the
|
|
||||||
typed struct as a documented extension field, not into the envelope's
|
|
||||||
top level.
|
|
||||||
|
|
||||||
## CI guards
|
|
||||||
|
|
||||||
| Check | Enforces | Where |
|
|
||||||
|-------|----------|-------|
|
|
||||||
| forbidigo | business path (`shortcuts/**`, `cmd/service/**`) must not call legacy `output.*` error constructors — route through the typed classifier | `.golangci.yml` |
|
|
||||||
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | `lint/errscontract/` AST |
|
|
||||||
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | `lint/errscontract/` AST |
|
|
||||||
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes labeled for promotion (warn) | `lint/errscontract/` AST |
|
|
||||||
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant or `ad_hoc_*` | `lint/errscontract/` AST |
|
|
||||||
| `CheckTypedErrorCompleteness` | every `*errs.<X>Error{Problem: errs.Problem{...}}` literal must set `Category`, `Subtype`, and `Message` | `lint/errscontract/` AST |
|
|
||||||
|
|
||||||
CI runs `lint/` on every PR. Locally: `go run -C lint . ..`. The
|
|
||||||
lintcheck CLI lives in its own Go module so its `golang.org/x/tools`
|
|
||||||
dependency stays out of the shipped `lark-cli` binary's module graph;
|
|
||||||
see `lint/README.md` for how to add a new lint domain.
|
|
||||||
|
|
||||||
## Stability
|
|
||||||
|
|
||||||
| Tier | Surface | Change policy |
|
|
||||||
|------|---------|---------------|
|
|
||||||
| Wire-stable | `error.type`, `error.subtype`, `error.code`, `error.retryable`, declared extension fields, `Category` enum values | breaking change ⇒ semver major; deprecation window required |
|
|
||||||
| Additive | new Category, new declared Subtype, new extension field on an existing struct | minor release; consumers ignore unknown fields by contract |
|
|
||||||
| Experimental | `ad_hoc_*` Subtypes; fields documented as such in `errs/types.go` | may change or be promoted/removed within one release |
|
|
||||||
|
|
||||||
The deprecated `*output.ExitError` surface is outside these tiers — it
|
|
||||||
will be removed once business migration completes.
|
|
||||||
|
|
||||||
## Migration
|
|
||||||
|
|
||||||
**Strategy shift (2026-05-26).** The original plan (`docs/design/errors-refactor/spec.md` v2.12 §9) was a centrally-driven 4-PR rollout — framework → auth domain → multi-pilot → full-repo + legacy removal. That plan is **superseded** by a hybrid model: framework owner ships framework-level hardening (including a typed `*errs.*Error` migration of `internal/**`) as one focused PR; business-domain typed migration is **self-service** via [`docs/errors-guide.md`](../docs/errors-guide.md) and the builder API, with no central sweep timeline.
|
|
||||||
|
|
||||||
Why the shift: 800+ legacy call sites split across 8+ business domains do not all share a single reviewer's bandwidth, and the contract is now expressive enough that each domain owner can migrate their own code from the guide without coordinating with framework owner.
|
|
||||||
|
|
||||||
### Current state
|
|
||||||
|
|
||||||
1. **Framework slice — ✅ shipped (PR #984).** The `errs/` typed taxonomy, classifier (`internal/errclass`), promotion stub (`internal/errcompat`, passthrough), dispatcher hook (`WriteTypedErrorEnvelope`), and the `lint/errscontract` AST guards. Wire shapes preserved byte-for-byte versus pre-PR, with **one intentional semantic fix**: config-class errors (`*core.ConfigError`) now exit `3` instead of `2`, aligning with `ExitCodeForCategory` (config errors share the auth exit slot per the taxonomy). The classifier and promote helpers are *shipped but unused* in production paths — they exist so framework migration can plug in without re-architecting.
|
|
||||||
|
|
||||||
2. **Builder API — ✅ shipped (this branch).** `errs/types.go` adds the canonical producer surface (`errs.NewXxxError(subtype, format, args...).WithX(...)`) for all 10 typed types, alongside each struct declaration. Constructor signature pins `Category` (via function name) and `Subtype` + `Message` (positional), so the producer cannot mis-specify any of the three identity fields. Optional fields chain through `.WithX(...)` setters that preserve the concrete pointer type.
|
|
||||||
|
|
||||||
### Next: framework migration PR (planned)
|
|
||||||
|
|
||||||
A single PR consolidates the work the original §9 spec split across PRs 2–4 — restricted to framework code, no business sweep:
|
|
||||||
|
|
||||||
- **Migrate `internal/**` typed construction to the builder API.** ~16 call sites in `internal/errclass/classify.go` (BuildAPIError fanout), `internal/auth/transport.go` (SecurityPolicy), `internal/auth/uat_client.go`, `internal/errcompat/promote*.go`, `internal/client/client.go`, `internal/client/api_errors.go`.
|
|
||||||
- **Land the framework-side semantic changes** previously scoped to spec §9 PR 2: `SecurityPolicyError` exit `1→6`, `WrapDoAPIError` typed (`*NetworkError` with subtype timeout/tls/dns/server_error/transport, `*InternalError` for JSON-decode), `WrapJSONResponseParseError` typed, `errcompat.PromoteConfigError` real Type routing, `PromoteAuthError` helper + dispatcher wiring, 10 credential Lark codes registered in codeMeta, 99991543 config classification, `resolveAccessToken` typed `*AuthenticationError`, `BuildAPIError` filling `*PermissionError.MissingScopes` / `Identity` / `ConsoleURL`, deletion of `scopeAwareChecker`.
|
|
||||||
- **Add `forbidigo` rule** banning `output.Err*` constructors in `shortcuts/**` and `cmd/**` (mirrors the contract that new business code must use the builder).
|
|
||||||
- **CHANGELOG** lists the resulting ~10 shell-exit-code shifts in one release entry (vs the spec §1 spread of 11 — the remaining one site lives in `task` business code).
|
|
||||||
|
|
||||||
### Business-domain migration (self-service, no central timeline)
|
|
||||||
|
|
||||||
Each business package migrates its own `output.Err*` call sites to the builder when convenient — typically batched within one domain. The guide at [`docs/errors-guide.md`](../docs/errors-guide.md) walks owners through the 8 typical error modes (validation / authorization / authentication / config / network / api / internal / policy) with real `file:line` examples from main. The three-layer extension model (add Subtype / add field / add Category) handles cases the existing taxonomy does not cover.
|
|
||||||
|
|
||||||
Helper assertions accept both shapes during migration (see `shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) so domain migrations stay green incrementally.
|
|
||||||
|
|
||||||
### Legacy removal
|
|
||||||
|
|
||||||
Deferred until business migration completion approaches the asymptote. `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and `ErrorEnvelope` are `// Deprecated:` today and stay callable. No fixed removal date.
|
|
||||||
|
|
||||||
### Before / after at a call site
|
|
||||||
|
|
||||||
```go
|
|
||||||
// before (legacy)
|
|
||||||
return output.ErrAPI(larkCode, "create event failed", resp.RawBody())
|
|
||||||
|
|
||||||
// after (typed) — cc carries Brand / AppID / Identity from the caller's context
|
|
||||||
return errclass.BuildAPIError(parsedResp, cc)
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
// before (legacy validation)
|
|
||||||
return output.ErrValidation("--duration-minutes must be between 1 and 1440")
|
|
||||||
|
|
||||||
// after (builder)
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"--duration-minutes must be between 1 and 1440, got %d", duration).
|
|
||||||
WithParam("--duration-minutes")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**Envelope shows `type=api subtype=unknown` for what should be a more
|
|
||||||
specific category.** The Lark code is unknown to `LookupCodeMeta` and fell
|
|
||||||
through to the generic bucket (`internal/errclass/classify.go`). Add the
|
|
||||||
code to `internal/errclass/codemeta_<service>.go` with the right Category
|
|
||||||
and Subtype, plus a dispatch test in `classify_test.go`.
|
|
||||||
|
|
||||||
**Envelope shows `type=internal subtype=sdk_error`.** Origin is
|
|
||||||
`client.WrapDoAPIError` taking the non-transport branch
|
|
||||||
(`internal/client/api_errors.go`). Check: did the SDK fail to decode the
|
|
||||||
response (look for `subtype=invalid_response` in the wrapped chain)? Was the
|
|
||||||
transport detection too narrow for this error (e.g. a `*url.Error` with an
|
|
||||||
inner that does not satisfy `net.Error`)? Either widen the transport
|
|
||||||
predicate or add an explicit typed wrap upstream.
|
|
||||||
|
|
||||||
**`CheckDeclaredSubtype` rejects my Subtype.** The constant must be
|
|
||||||
declared in `errs/subtypes*.go` *and* referenced from the dispatch path.
|
|
||||||
Bare string literals trip `CheckDeclaredSubtype` unless they match the
|
|
||||||
`ad_hoc_*` prefix; `ad_hoc_*` then trips `CheckAdHocSubtype` as a
|
|
||||||
follow-up warning.
|
|
||||||
|
|
||||||
**`errors.As(&typedErr)` panics with a nil-pointer receiver.** A typed-nil
|
|
||||||
slipped through. All typed errors define nil-safe `Unwrap()`, but
|
|
||||||
returning a typed-nil pointer up the stack still defeats `errors.As`.
|
|
||||||
Return interface `nil` from constructors, never a typed-nil pointer.
|
|
||||||
|
|
||||||
**Exit code is 5 (internal) when I expected 3 (auth).** The error was not
|
|
||||||
typed before reaching `handleRootError`. Wrap at the boundary
|
|
||||||
(`client.WrapDoAPIError` or a typed constructor) — the bare `error.Error()`
|
|
||||||
string cannot be classified retroactively.
|
|
||||||
|
|
||||||
## Security & privacy
|
|
||||||
|
|
||||||
- `log_id` is a server-side trace token. Safe to surface; it does not
|
|
||||||
carry user content.
|
|
||||||
- `missing_scopes` is app configuration, not user data.
|
|
||||||
- `Message` and `Hint` must not contain tokens, JWTs, or personally
|
|
||||||
identifying values. CI does not catch this — producer responsibility.
|
|
||||||
- Wrapped `Cause` is **not** serialized to the wire (`json:"-"`). It is
|
|
||||||
retained for in-process `errors.Is` / `errors.Unwrap` traversal and
|
|
||||||
optional debug logging only.
|
|
||||||
|
|
||||||
## Pointers (task-driven)
|
|
||||||
|
|
||||||
- *Which struct to construct?* → **Producers / Quick reference**
|
|
||||||
- *Add a new condition?* → **Add a Subtype**
|
|
||||||
- *Consume from a shell script?* → **Consumers / Shell / AI**
|
|
||||||
- *Understand or fix a CI failure?* → **CI guards**
|
|
||||||
- *Migrate a legacy `ExitError` call site?* → **Migration** + the
|
|
||||||
Deprecated note on the symbol being replaced.
|
|
||||||
- *Read source.* → `errs/doc.go` → `errs/category.go` → `errs/types.go`
|
|
||||||
→ `errs/predicates.go` → `internal/errclass/` →
|
|
||||||
`cmd/root.go` `handleRootError`.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package errs
|
|
||||||
|
|
||||||
// Category is the top-level taxonomy axis. Wire JSON: "type".
|
|
||||||
type Category string
|
|
||||||
|
|
||||||
const (
|
|
||||||
CategoryValidation Category = "validation"
|
|
||||||
CategoryAuthentication Category = "authentication"
|
|
||||||
CategoryAuthorization Category = "authorization"
|
|
||||||
CategoryConfig Category = "config"
|
|
||||||
CategoryNetwork Category = "network"
|
|
||||||
CategoryAPI Category = "api"
|
|
||||||
CategoryPolicy Category = "policy"
|
|
||||||
CategoryInternal Category = "internal"
|
|
||||||
CategoryConfirmation Category = "confirmation"
|
|
||||||
)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package errs
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestCategoryWireValues(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
got Category
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"validation", CategoryValidation, "validation"},
|
|
||||||
{"authentication", CategoryAuthentication, "authentication"},
|
|
||||||
{"authorization", CategoryAuthorization, "authorization"},
|
|
||||||
{"config", CategoryConfig, "config"},
|
|
||||||
{"network", CategoryNetwork, "network"},
|
|
||||||
{"api", CategoryAPI, "api"},
|
|
||||||
{"policy", CategoryPolicy, "policy"},
|
|
||||||
{"internal", CategoryInternal, "internal"},
|
|
||||||
{"confirmation", CategoryConfirmation, "confirmation"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if string(tt.got) != tt.want {
|
|
||||||
t.Errorf("category %s = %q, want %q", tt.name, string(tt.got), tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
37
errs/doc.go
37
errs/doc.go
@@ -1,37 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
// Package errs is the public error-contract surface for lark-cli.
|
|
||||||
//
|
|
||||||
// It defines a closed taxonomy (9 Categories) and a small set of typed
|
|
||||||
// errors that embed Problem — an RFC 7807-aligned shared shape. External
|
|
||||||
// consumers (AI agents, shell scripts, integrating SDKs) read structured
|
|
||||||
// fields instead of regex-parsing free-string error messages.
|
|
||||||
//
|
|
||||||
// # The Problem shape
|
|
||||||
//
|
|
||||||
// Every typed error embeds Problem so the JSON wire shape (`type`,
|
|
||||||
// `subtype`, `code`, `message`, `hint`, `log_id`, `retryable`) is uniform
|
|
||||||
// across categories. Typed extensions (PermissionError.MissingScopes,
|
|
||||||
// SecurityPolicyError.ChallengeURL, etc.) appear at the top level of the
|
|
||||||
// envelope alongside the shared fields, not nested under a `detail` key.
|
|
||||||
//
|
|
||||||
// # Working with typed errors
|
|
||||||
//
|
|
||||||
// Use ProblemOf to read shared fields polymorphically:
|
|
||||||
//
|
|
||||||
// if p, ok := errs.ProblemOf(err); ok {
|
|
||||||
// log.Printf("category=%s subtype=%s retryable=%t", p.Category, p.Subtype, p.Retryable)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// Use the IsXxx predicates or stdlib errors.As to branch on concrete type:
|
|
||||||
//
|
|
||||||
// if errs.IsPermission(err) {
|
|
||||||
// var pe *errs.PermissionError
|
|
||||||
// _ = errors.As(err, &pe)
|
|
||||||
// fmt.Println("missing scopes:", pe.MissingScopes)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// Use WrapInternal at boundaries to lift any non-typed error to
|
|
||||||
// *InternalError; typed errors pass through unchanged.
|
|
||||||
package errs
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package errs
|
|
||||||
|
|
||||||
// problemCarrier is the non-exported extraction interface.
|
|
||||||
// Used by ProblemOf via errors.As, working around the Go embed semantic where
|
|
||||||
// *Problem cannot match *PermissionError directly.
|
|
||||||
type problemCarrier interface {
|
|
||||||
ProblemDetail() *Problem
|
|
||||||
}
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package errs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Per-type marshal tests pin each typed error's wire shape against its
|
|
||||||
// canonical fields. They guard against future refactors that change struct
|
|
||||||
// layout from accidentally altering the externally visible JSON contract.
|
|
||||||
//
|
|
||||||
// Each test asserts (a) Problem fields surface at the top level via embed
|
|
||||||
// promotion, (b) extension fields sit alongside as siblings (NOT under a
|
|
||||||
// `detail` sub-object), and (c) omitempty is honored on optional fields.
|
|
||||||
|
|
||||||
func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
|
|
||||||
pe := &PermissionError{
|
|
||||||
Problem: Problem{
|
|
||||||
Category: CategoryAuthorization, Subtype: SubtypeMissingScope, Code: 99991679,
|
|
||||||
Message: "x", Hint: "y", LogID: "lg", Retryable: false,
|
|
||||||
},
|
|
||||||
MissingScopes: []string{"docx:document"},
|
|
||||||
Identity: "user",
|
|
||||||
ConsoleURL: "https://example",
|
|
||||||
}
|
|
||||||
b, err := json.Marshal(pe)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
s := string(b)
|
|
||||||
for _, want := range []string{
|
|
||||||
`"type":"authorization"`,
|
|
||||||
`"subtype":"missing_scope"`,
|
|
||||||
`"code":99991679`,
|
|
||||||
`"message":"x"`,
|
|
||||||
`"hint":"y"`,
|
|
||||||
`"log_id":"lg"`,
|
|
||||||
`"missing_scopes":["docx:document"]`,
|
|
||||||
`"identity":"user"`,
|
|
||||||
`"console_url":"https://example"`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(s, want) {
|
|
||||||
t.Errorf("missing %q in %s", want, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(s, `"retryable"`) {
|
|
||||||
t.Errorf("retryable should be omitted when false; got %s", s)
|
|
||||||
}
|
|
||||||
if strings.Contains(s, `"detail"`) {
|
|
||||||
t.Errorf("extension fields must not be wrapped under detail; got %s", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPermissionError_RequestedGrantedMarshal(t *testing.T) {
|
|
||||||
err := NewPermissionError(SubtypeMissingScope, "partial grant").
|
|
||||||
WithRequestedScopes("docx:document", "im:message:send").
|
|
||||||
WithGrantedScopes("docx:document").
|
|
||||||
WithMissingScopes("im:message:send")
|
|
||||||
|
|
||||||
b, e := json.Marshal(err)
|
|
||||||
if e != nil {
|
|
||||||
t.Fatal(e)
|
|
||||||
}
|
|
||||||
got := string(b)
|
|
||||||
for _, want := range []string{
|
|
||||||
`"requested_scopes":["docx:document","im:message:send"]`,
|
|
||||||
`"granted_scopes":["docx:document"]`,
|
|
||||||
`"missing_scopes":["im:message:send"]`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Errorf("envelope missing %s\nactual: %s", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidationError_MarshalJSON(t *testing.T) {
|
|
||||||
ve := &ValidationError{
|
|
||||||
Problem: Problem{Category: CategoryValidation, Subtype: SubtypeInvalidArgument, Message: "bad"},
|
|
||||||
Param: "--scope",
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(ve)
|
|
||||||
s := string(b)
|
|
||||||
for _, want := range []string{
|
|
||||||
`"type":"validation"`,
|
|
||||||
`"subtype":"invalid_argument"`,
|
|
||||||
`"message":"bad"`,
|
|
||||||
`"param":"--scope"`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(s, want) {
|
|
||||||
t.Errorf("missing %q in %s", want, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Param omitempty when ""
|
|
||||||
ve2 := &ValidationError{Problem: Problem{Category: CategoryValidation, Message: "x"}}
|
|
||||||
b2, _ := json.Marshal(ve2)
|
|
||||||
if strings.Contains(string(b2), `"param"`) {
|
|
||||||
t.Errorf("param should be omitted when empty; got %s", b2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthError_MarshalJSON(t *testing.T) {
|
|
||||||
ae := &AuthenticationError{
|
|
||||||
Problem: Problem{Category: CategoryAuthentication, Subtype: SubtypeTokenExpired, Message: "expired"},
|
|
||||||
UserOpenID: "ou_x",
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(ae)
|
|
||||||
s := string(b)
|
|
||||||
for _, want := range []string{
|
|
||||||
`"type":"authentication"`,
|
|
||||||
`"subtype":"token_expired"`,
|
|
||||||
`"message":"expired"`,
|
|
||||||
`"user_open_id":"ou_x"`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(s, want) {
|
|
||||||
t.Errorf("missing %q in %s", want, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigError_MarshalJSON(t *testing.T) {
|
|
||||||
ce := &ConfigError{
|
|
||||||
Problem: Problem{Category: CategoryConfig, Subtype: SubtypeInvalidClient, Message: "bad"},
|
|
||||||
Field: "app_id",
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(ce)
|
|
||||||
s := string(b)
|
|
||||||
for _, want := range []string{`"type":"config"`, `"subtype":"invalid_client"`, `"field":"app_id"`} {
|
|
||||||
if !strings.Contains(s, want) {
|
|
||||||
t.Errorf("missing %q in %s", want, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNetworkError_MarshalJSON(t *testing.T) {
|
|
||||||
ne := &NetworkError{
|
|
||||||
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(ne)
|
|
||||||
s := string(b)
|
|
||||||
for _, want := range []string{
|
|
||||||
`"type":"network"`,
|
|
||||||
`"subtype":"timeout"`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(s, want) {
|
|
||||||
t.Errorf("missing %q in %s", want, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(s, `"cause"`) {
|
|
||||||
t.Errorf("cause field should no longer be on the wire; got %s", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPIError_MarshalJSON(t *testing.T) {
|
|
||||||
ae := &APIError{
|
|
||||||
Problem: Problem{Category: CategoryAPI, Subtype: SubtypeRateLimit, Code: 99991400, Message: "slow", Retryable: true},
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(ae)
|
|
||||||
s := string(b)
|
|
||||||
for _, want := range []string{
|
|
||||||
`"type":"api"`,
|
|
||||||
`"subtype":"rate_limit"`,
|
|
||||||
`"code":99991400`,
|
|
||||||
`"retryable":true`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(s, want) {
|
|
||||||
t.Errorf("missing %q in %s", want, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProblem_MarshalJSON_Troubleshooter pins the upstream Lark API
|
|
||||||
// troubleshooter URL (resp.error.troubleshooter) surfacing on the wire under
|
|
||||||
// "troubleshooter". Carried via Problem so any typed error that embeds it
|
|
||||||
// inherits the field — populated by errclass.BuildAPIError before the
|
|
||||||
// category switch.
|
|
||||||
func TestProblem_MarshalJSON_Troubleshooter(t *testing.T) {
|
|
||||||
ae := &APIError{
|
|
||||||
Problem: Problem{
|
|
||||||
Category: CategoryAPI,
|
|
||||||
Subtype: SubtypeUnknown,
|
|
||||||
Code: 99991400,
|
|
||||||
Message: "x",
|
|
||||||
Troubleshooter: "https://open.feishu.cn/document/troubleshoot/abc",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(ae)
|
|
||||||
s := string(b)
|
|
||||||
if !strings.Contains(s, `"troubleshooter":"https://open.feishu.cn/document/troubleshoot/abc"`) {
|
|
||||||
t.Errorf("missing troubleshooter in %s", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Absent Troubleshooter must omit the wire key.
|
|
||||||
bare := &APIError{Problem: Problem{Category: CategoryAPI, Message: "x"}}
|
|
||||||
b2, _ := json.Marshal(bare)
|
|
||||||
if strings.Contains(string(b2), `"troubleshooter"`) {
|
|
||||||
t.Errorf("absent Troubleshooter must omit wire key; got %s", string(b2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityPolicyError_MarshalJSON(t *testing.T) {
|
|
||||||
spe := &SecurityPolicyError{
|
|
||||||
Problem: Problem{Category: CategoryPolicy, Subtype: SubtypeChallengeRequired, Message: "blocked"},
|
|
||||||
ChallengeURL: "https://chal.example",
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(spe)
|
|
||||||
s := string(b)
|
|
||||||
for _, want := range []string{
|
|
||||||
`"type":"policy"`,
|
|
||||||
`"subtype":"challenge_required"`,
|
|
||||||
`"challenge_url":"https://chal.example"`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(s, want) {
|
|
||||||
t.Errorf("missing %q in %s", want, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pin per-Subtype symmetry: SubtypeAccessDenied must serialize the same
|
|
||||||
// envelope shape as SubtypeChallengeRequired so callers can switch on
|
|
||||||
// subtype without conditional field probing. The constructor + builder
|
|
||||||
// path (mirroring how callsites actually construct these) is exercised
|
|
||||||
// here rather than the struct literal, since SubtypeAccessDenied is the
|
|
||||||
// path threaded through cmd/* sites that surface policy-deny outcomes.
|
|
||||||
func TestSecurityPolicyError_MarshalJSON_AccessDenied(t *testing.T) {
|
|
||||||
err := NewSecurityPolicyError(SubtypeAccessDenied, "user denied").
|
|
||||||
WithChallengeURL("https://chal.example/2")
|
|
||||||
|
|
||||||
b, e := json.Marshal(err)
|
|
||||||
if e != nil {
|
|
||||||
t.Fatal(e)
|
|
||||||
}
|
|
||||||
got := string(b)
|
|
||||||
for _, want := range []string{
|
|
||||||
`"type":"policy"`,
|
|
||||||
`"subtype":"access_denied"`,
|
|
||||||
`"challenge_url":"https://chal.example/2"`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Errorf("envelope missing %s\nactual: %s", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContentSafetyError_MarshalJSON(t *testing.T) {
|
|
||||||
cse := &ContentSafetyError{
|
|
||||||
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("content_blocked"), Message: "blocked"},
|
|
||||||
Rules: []string{"pii", "violence"},
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(cse)
|
|
||||||
s := string(b)
|
|
||||||
for _, want := range []string{
|
|
||||||
`"type":"policy"`,
|
|
||||||
`"rules":["pii","violence"]`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(s, want) {
|
|
||||||
t.Errorf("missing %q in %s", want, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInternalError_MarshalJSON(t *testing.T) {
|
|
||||||
ie := &InternalError{
|
|
||||||
Problem: Problem{Category: CategoryInternal, Subtype: SubtypeSDKError, Message: "boom"},
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(ie)
|
|
||||||
s := string(b)
|
|
||||||
for _, want := range []string{`"type":"internal"`, `"subtype":"sdk_error"`} {
|
|
||||||
if !strings.Contains(s, want) {
|
|
||||||
t.Errorf("missing %q in %s", want, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfirmationRequiredError_MarshalJSON(t *testing.T) {
|
|
||||||
cre := &ConfirmationRequiredError{
|
|
||||||
Problem: Problem{Category: CategoryConfirmation, Subtype: Subtype("confirmation_required"), Message: "confirm"},
|
|
||||||
Risk: "write",
|
|
||||||
Action: "mail +send",
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(cre)
|
|
||||||
s := string(b)
|
|
||||||
for _, want := range []string{
|
|
||||||
`"type":"confirmation"`,
|
|
||||||
`"risk":"write"`,
|
|
||||||
`"action":"mail +send"`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(s, want) {
|
|
||||||
t.Errorf("missing %q in %s", want, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package errs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProblemOf extracts the embedded Problem via the non-exported problemCarrier interface.
|
|
||||||
// This is the supported way to read shared fields without depending on a specific typed error.
|
|
||||||
//
|
|
||||||
// A typed error whose embedded *Problem is nil is treated as "not a problem
|
|
||||||
// carrier" — returning (nil, true) here would cause CategoryOf / IsRetryable
|
|
||||||
// and other downstream readers to dereference nil.
|
|
||||||
func ProblemOf(err error) (*Problem, bool) {
|
|
||||||
var c problemCarrier
|
|
||||||
if errors.As(err, &c) {
|
|
||||||
if p := c.ProblemDetail(); p != nil {
|
|
||||||
return p, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnwrapTypedError walks the wrap chain and returns the first error that
|
|
||||||
// embeds Problem (i.e. any typed error in this package). Returns the typed
|
|
||||||
// error itself (as error) so callers — notably JSON marshaling — see the
|
|
||||||
// concrete value's own struct tags rather than an opaque wrapper.
|
|
||||||
func UnwrapTypedError(err error) (error, bool) {
|
|
||||||
var c problemCarrier
|
|
||||||
if errors.As(err, &c) {
|
|
||||||
if e, ok := c.(error); ok {
|
|
||||||
return e, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// CategoryOf returns the error's Category for metrics/logging/dispatch routing.
|
|
||||||
// Falls back to CategoryInternal for non-typed errors.
|
|
||||||
func CategoryOf(err error) Category {
|
|
||||||
if p, ok := ProblemOf(err); ok {
|
|
||||||
return p.Category
|
|
||||||
}
|
|
||||||
return CategoryInternal
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsRetryable reads Problem.Retryable; non-typed errors are non-retryable by default.
|
|
||||||
func IsRetryable(err error) bool {
|
|
||||||
if p, ok := ProblemOf(err); ok {
|
|
||||||
return p.Retryable
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsValidation reports whether err is a *ValidationError.
|
|
||||||
func IsValidation(err error) bool { var x *ValidationError; return errors.As(err, &x) }
|
|
||||||
|
|
||||||
// IsPermission reports whether err is a *PermissionError.
|
|
||||||
func IsPermission(err error) bool { var x *PermissionError; return errors.As(err, &x) }
|
|
||||||
|
|
||||||
// IsNetwork reports whether err is a *NetworkError.
|
|
||||||
func IsNetwork(err error) bool { var x *NetworkError; return errors.As(err, &x) }
|
|
||||||
|
|
||||||
// IsAPI reports whether err is an *APIError.
|
|
||||||
func IsAPI(err error) bool { var x *APIError; return errors.As(err, &x) }
|
|
||||||
|
|
||||||
// IsSecurityPolicy reports whether err is a *SecurityPolicyError.
|
|
||||||
func IsSecurityPolicy(err error) bool { var x *SecurityPolicyError; return errors.As(err, &x) }
|
|
||||||
|
|
||||||
// IsContentSafety reports whether err is a *ContentSafetyError.
|
|
||||||
func IsContentSafety(err error) bool { var x *ContentSafetyError; return errors.As(err, &x) }
|
|
||||||
|
|
||||||
// IsInternal reports whether err is an *InternalError.
|
|
||||||
func IsInternal(err error) bool { var x *InternalError; return errors.As(err, &x) }
|
|
||||||
|
|
||||||
// IsConfirmationRequired reports whether err is a *ConfirmationRequiredError.
|
|
||||||
func IsConfirmationRequired(err error) bool {
|
|
||||||
var x *ConfirmationRequiredError
|
|
||||||
return errors.As(err, &x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsAuthentication reports whether err is an *AuthenticationError.
|
|
||||||
func IsAuthentication(err error) bool { var x *AuthenticationError; return errors.As(err, &x) }
|
|
||||||
|
|
||||||
// IsConfig reports whether err is a *ConfigError.
|
|
||||||
func IsConfig(err error) bool { var x *ConfigError; return errors.As(err, &x) }
|
|
||||||
|
|
||||||
// IsTyped reports whether err is or wraps any of the typed *errs.* errors
|
|
||||||
// in this package (i.e. implements the TypedError interface). Used by call
|
|
||||||
// sites that need to pass already-classified errors through unchanged
|
|
||||||
// instead of blanket-rewrapping them as a different category.
|
|
||||||
func IsTyped(err error) bool {
|
|
||||||
var t TypedError
|
|
||||||
return errors.As(err, &t)
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package errs_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestIsRetryable(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "api error with retryable=true",
|
|
||||||
err: &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI, Retryable: true}},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "api error with retryable=false (zero)",
|
|
||||||
err: &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI}},
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "plain error",
|
|
||||||
err: fmt.Errorf("plain"),
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nil error",
|
|
||||||
err: nil,
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := errs.IsRetryable(tt.err); got != tt.want {
|
|
||||||
t.Errorf("IsRetryable(%v) = %v, want %v", tt.err, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsAuthTypedOnly(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "errs.AuthenticationError",
|
|
||||||
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "errs.ConfigError",
|
|
||||||
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "plain error",
|
|
||||||
err: fmt.Errorf("plain"),
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := errs.IsAuthentication(tt.err); got != tt.want {
|
|
||||||
t.Errorf("IsAuthentication(%v) = %v, want %v", tt.err, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsConfigTypedOnly(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "errs.ConfigError",
|
|
||||||
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "errs.AuthenticationError",
|
|
||||||
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "plain error",
|
|
||||||
err: fmt.Errorf("plain"),
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := errs.IsConfig(tt.err); got != tt.want {
|
|
||||||
t.Errorf("IsConfig(%v) = %v, want %v", tt.err, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCategoryOf(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
want errs.Category
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "typed validation error",
|
|
||||||
err: &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation}},
|
|
||||||
want: errs.CategoryValidation,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "typed permission error",
|
|
||||||
err: &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization}},
|
|
||||||
want: errs.CategoryAuthorization,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "typed config error",
|
|
||||||
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
|
|
||||||
want: errs.CategoryConfig,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "typed auth error",
|
|
||||||
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
|
|
||||||
want: errs.CategoryAuthentication,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "plain error falls back to internal",
|
|
||||||
err: fmt.Errorf("plain"),
|
|
||||||
want: errs.CategoryInternal,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := errs.CategoryOf(tt.err); got != tt.want {
|
|
||||||
t.Errorf("CategoryOf(%v) = %q, want %q", tt.err, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProblemOf_NilProblemReturnsFalse pins that a problemCarrier whose
|
|
||||||
// ProblemDetail() returns nil does NOT satisfy ProblemOf — otherwise
|
|
||||||
// CategoryOf / IsRetryable and other downstream readers would dereference
|
|
||||||
// nil and panic. *Problem(nil) is a directly constructable trigger: its
|
|
||||||
// ProblemDetail method `return p` is nil-safe and yields nil.
|
|
||||||
func TestProblemOf_NilProblemReturnsFalse(t *testing.T) {
|
|
||||||
var nilP *errs.Problem
|
|
||||||
var err error = nilP // *Problem implements error via Error() (nil-receiver safe)
|
|
||||||
|
|
||||||
p, ok := errs.ProblemOf(err)
|
|
||||||
if ok {
|
|
||||||
t.Fatalf("ProblemOf(*Problem(nil)) = (%v, true); want (nil, false)", p)
|
|
||||||
}
|
|
||||||
if p != nil {
|
|
||||||
t.Errorf("ProblemOf(*Problem(nil)).p = %v; want nil", p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Downstream readers must not panic on the same input.
|
|
||||||
if cat := errs.CategoryOf(err); cat != errs.CategoryInternal {
|
|
||||||
t.Errorf("CategoryOf(*Problem(nil)) = %q, want fallback %q", cat, errs.CategoryInternal)
|
|
||||||
}
|
|
||||||
if retryable := errs.IsRetryable(err); retryable {
|
|
||||||
t.Errorf("IsRetryable(*Problem(nil)) = true; want false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTypedPredicates(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
pred func(error) bool
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"IsValidation+", &errs.ValidationError{}, errs.IsValidation, true},
|
|
||||||
{"IsValidation-", &errs.APIError{}, errs.IsValidation, false},
|
|
||||||
{"IsPermission+", &errs.PermissionError{}, errs.IsPermission, true},
|
|
||||||
{"IsPermission-", &errs.APIError{}, errs.IsPermission, false},
|
|
||||||
{"IsNetwork+", &errs.NetworkError{}, errs.IsNetwork, true},
|
|
||||||
{"IsAPI+", &errs.APIError{}, errs.IsAPI, true},
|
|
||||||
{"IsSecurityPolicy+", &errs.SecurityPolicyError{}, errs.IsSecurityPolicy, true},
|
|
||||||
{"IsContentSafety+", &errs.ContentSafetyError{}, errs.IsContentSafety, true},
|
|
||||||
{"IsInternal+", &errs.InternalError{}, errs.IsInternal, true},
|
|
||||||
{"IsConfirmationRequired+", &errs.ConfirmationRequiredError{}, errs.IsConfirmationRequired, true},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if got := tc.pred(tc.err); got != tc.want {
|
|
||||||
t.Errorf("%s: predicate = %v, want %v", tc.name, got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package errs
|
|
||||||
|
|
||||||
// Problem is the RFC 7807-aligned shared shape embedded by every typed error.
|
|
||||||
//
|
|
||||||
// Message is REQUIRED. Producers must populate it; an empty Message will make
|
|
||||||
// Error() return "" — a known Go footgun for fmt.Errorf("...: %v", err).
|
|
||||||
//
|
|
||||||
// Wire-format notes:
|
|
||||||
// - No Component field. Service / shortcut component is metric-only
|
|
||||||
// enrichment derived by the dispatcher from the cobra command path; it
|
|
||||||
// never appears on the wire.
|
|
||||||
// - No DocURL field. PermissionError carries the same intent via its typed
|
|
||||||
// ConsoleURL extension; other typed errors do not link out.
|
|
||||||
// - Troubleshooter is the upstream Lark API's diagnostic URL (resp.error.
|
|
||||||
// troubleshooter). Carried universally so any classified error can surface
|
|
||||||
// it; populated by errclass.BuildAPIError when the upstream response
|
|
||||||
// includes it, otherwise absent.
|
|
||||||
// - Retryable uses omitempty so only `true` is emitted; consumers treat
|
|
||||||
// absence as false.
|
|
||||||
type Problem struct {
|
|
||||||
Category Category `json:"type"`
|
|
||||||
Subtype Subtype `json:"subtype,omitempty"`
|
|
||||||
Code int `json:"code,omitempty"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Hint string `json:"hint,omitempty"`
|
|
||||||
LogID string `json:"log_id,omitempty"`
|
|
||||||
Troubleshooter string `json:"troubleshooter,omitempty"`
|
|
||||||
Retryable bool `json:"retryable,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error satisfies the standard `error` interface. A nil receiver is treated
|
|
||||||
// as the empty string so a stray nil *Problem stored in an error interface
|
|
||||||
// cannot panic the dispatcher.
|
|
||||||
func (p *Problem) Error() string {
|
|
||||||
if p == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return p.Message
|
|
||||||
}
|
|
||||||
func (p *Problem) ProblemDetail() *Problem { return p }
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package errs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestProblemError(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
p Problem
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"empty message", Problem{}, ""},
|
|
||||||
{"plain message", Problem{Message: "boom"}, "boom"},
|
|
||||||
{"message ignores hint", Problem{Message: "msg", Hint: "do x"}, "msg"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := (&tt.p).Error(); got != tt.want {
|
|
||||||
t.Errorf("Error() = %q, want %q", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProblemError_NilReceiverDoesNotPanic pins the nil-receiver guard on
|
|
||||||
// (*Problem).Error(). Without it, a nil *Problem stored in an error interface
|
|
||||||
// would panic when the root dispatcher calls err.Error() for logging.
|
|
||||||
func TestProblemError_NilReceiverDoesNotPanic(t *testing.T) {
|
|
||||||
var p *Problem // nil
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Fatalf("(*Problem)(nil).Error() panicked: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if got := p.Error(); got != "" {
|
|
||||||
t.Errorf("(*Problem)(nil).Error() = %q, want \"\"", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProblemDetailReturnsReceiver(t *testing.T) {
|
|
||||||
p := &Problem{Message: "x"}
|
|
||||||
if got := p.ProblemDetail(); got != p {
|
|
||||||
t.Errorf("ProblemDetail() = %p, want receiver %p", got, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProblemHasNoComponentField(t *testing.T) {
|
|
||||||
if f, ok := reflect.TypeOf(Problem{}).FieldByName("Component"); ok {
|
|
||||||
t.Errorf("Problem.Component must not exist; got field %#v", f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProblemHasNoDocURLField(t *testing.T) {
|
|
||||||
if f, ok := reflect.TypeOf(Problem{}).FieldByName("DocURL"); ok {
|
|
||||||
t.Errorf("Problem.DocURL must not exist on the base Problem (PermissionError carries ConsoleURL instead); got field %#v", f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProblemCategoryTagIsType(t *testing.T) {
|
|
||||||
f, ok := reflect.TypeOf(Problem{}).FieldByName("Category")
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Problem.Category must exist")
|
|
||||||
}
|
|
||||||
if got := f.Tag.Get("json"); got != "type" {
|
|
||||||
t.Errorf("Problem.Category json tag = %q, want %q", got, "type")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package errs
|
|
||||||
|
|
||||||
// Subtype is the second-level taxonomy axis. Wire JSON: "subtype".
|
|
||||||
type Subtype string
|
|
||||||
|
|
||||||
const (
|
|
||||||
SubtypeUnknown Subtype = "unknown" // catch-all fallback; producers must prefer a specific subtype
|
|
||||||
)
|
|
||||||
|
|
||||||
// CategoryValidation subtypes
|
|
||||||
const (
|
|
||||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
|
||||||
)
|
|
||||||
|
|
||||||
// CategoryAuthentication subtypes
|
|
||||||
const (
|
|
||||||
SubtypeTokenMissing Subtype = "token_missing" // no token in request (Authorization header absent / no local token cache)
|
|
||||||
SubtypeTokenInvalid Subtype = "token_invalid" // token present but content/format wrong
|
|
||||||
SubtypeTokenExpired Subtype = "token_expired" // token explicitly expired
|
|
||||||
SubtypeRefreshTokenInvalid Subtype = "refresh_token_invalid" // refresh_token is v1 legacy format, unusable
|
|
||||||
SubtypeRefreshTokenExpired Subtype = "refresh_token_expired" // refresh_token expired
|
|
||||||
SubtypeRefreshTokenRevoked Subtype = "refresh_token_revoked" // refresh_token revoked (user logout / admin action)
|
|
||||||
SubtypeRefreshTokenReused Subtype = "refresh_token_reused" // refresh_token already used (single-use rotation triggered)
|
|
||||||
SubtypeRefreshServerError Subtype = "refresh_server_error" // refresh endpoint transient error (retryable)
|
|
||||||
)
|
|
||||||
|
|
||||||
// CategoryAuthorization subtypes
|
|
||||||
const (
|
|
||||||
SubtypeMissingScope Subtype = "missing_scope" // user authorized app but did not grant this scope
|
|
||||||
SubtypeUserUnauthorized Subtype = "user_unauthorized" // user never authorized the app
|
|
||||||
SubtypeAppScopeNotApplied Subtype = "app_scope_not_applied" // app did not apply for this scope on the open platform
|
|
||||||
SubtypeTokenScopeInsufficient Subtype = "token_scope_insufficient" // token was issued without this scope (RFC 6750 alignment)
|
|
||||||
SubtypeAppUnavailable Subtype = "app_unavailable" // app status unavailable
|
|
||||||
SubtypeAppDisabled Subtype = "app_disabled" // app currently disabled in this tenant (was installed/enabled before)
|
|
||||||
SubtypePermissionDenied Subtype = "permission_denied" // resource-level permission denial (authenticated but lacks rights for this resource, HTTP 403 / gRPC PERMISSION_DENIED alignment)
|
|
||||||
)
|
|
||||||
|
|
||||||
// CategoryConfig subtypes
|
|
||||||
const (
|
|
||||||
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
|
|
||||||
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
|
|
||||||
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
|
|
||||||
)
|
|
||||||
|
|
||||||
// CategoryNetwork subtypes
|
|
||||||
const (
|
|
||||||
SubtypeNetworkTransport Subtype = "transport" // fallback when no more-specific network subtype matches
|
|
||||||
SubtypeNetworkTimeout Subtype = "timeout" // dial / read timeout
|
|
||||||
SubtypeNetworkTLS Subtype = "tls" // TLS handshake / cert failure
|
|
||||||
SubtypeNetworkDNS Subtype = "dns" // DNS resolution failure
|
|
||||||
SubtypeNetworkServer Subtype = "server_error" // upstream HTTP 5xx
|
|
||||||
)
|
|
||||||
|
|
||||||
// CategoryAPI subtypes
|
|
||||||
const (
|
|
||||||
SubtypeRateLimit Subtype = "rate_limit" // request rate limit exceeded
|
|
||||||
SubtypeConflict Subtype = "conflict" // resource state conflict (e.g. concurrent modification)
|
|
||||||
SubtypeCrossTenant Subtype = "cross_tenant" // operation crosses tenant boundary (not supported)
|
|
||||||
SubtypeCrossBrand Subtype = "cross_brand" // operation crosses brand boundary (feishu vs lark, not supported)
|
|
||||||
SubtypeInvalidParameters Subtype = "invalid_parameters" // API-side parameter validation rejected the request
|
|
||||||
SubtypeOwnershipMismatch Subtype = "ownership_mismatch" // caller is not the resource owner
|
|
||||||
SubtypeNotFound Subtype = "not_found" // referenced resource does not exist (HTTP 404 alignment)
|
|
||||||
SubtypeServerError Subtype = "server_error" // upstream server-side transient error (HTTP 5xx alignment, retryable)
|
|
||||||
SubtypeQuotaExceeded Subtype = "quota_exceeded" // resource quota / collection size limit reached (assignees, followers, members, etc.)
|
|
||||||
SubtypeAlreadyExists Subtype = "already_exists" // idempotency violation: resource already exists in target state
|
|
||||||
)
|
|
||||||
|
|
||||||
// CategoryPolicy subtypes (security-policy envelope shape)
|
|
||||||
const (
|
|
||||||
SubtypeChallengeRequired Subtype = "challenge_required" // user must complete browser challenge / MFA
|
|
||||||
SubtypeAccessDenied Subtype = "access_denied" // policy denies access outright
|
|
||||||
)
|
|
||||||
|
|
||||||
// CategoryInternal subtypes
|
|
||||||
const (
|
|
||||||
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
|
|
||||||
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
|
|
||||||
SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read)
|
|
||||||
SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save)
|
|
||||||
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
|
|
||||||
)
|
|
||||||
|
|
||||||
// CategoryConfirmation subtypes
|
|
||||||
const (
|
|
||||||
SubtypeConfirmationRequired Subtype = "confirmation_required" // high-risk operation needs explicit --yes
|
|
||||||
)
|
|
||||||
751
errs/types.go
751
errs/types.go
@@ -1,751 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package errs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
)
|
|
||||||
|
|
||||||
// formatMessage applies fmt.Sprintf only when args are present, so a
|
|
||||||
// caller passing a literal message with a stray "%" (e.g. "disk 100% full")
|
|
||||||
// is not rendered as "%!(NOVERB)". `go vet -printf` catches most accidental
|
|
||||||
// format misuse upstream; this guard makes the constructor safe even when
|
|
||||||
// the message string is dynamically composed.
|
|
||||||
func formatMessage(format string, args []any) string {
|
|
||||||
if len(args) == 0 {
|
|
||||||
return format
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(format, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typed error types and their builder APIs.
|
|
||||||
//
|
|
||||||
// Each typed error has:
|
|
||||||
// - A struct embedding Problem, with type-specific extension fields
|
|
||||||
// - A nil-safe Unwrap() method when the struct carries a Cause field
|
|
||||||
// - A NewXxxError(subtype, format, args...) constructor — Category locked
|
|
||||||
// by the function name, Subtype + Message positional and required
|
|
||||||
// - Chainable WithX(...) setters that return the concrete *XxxError pointer
|
|
||||||
// so type-specific setters remain reachable to the end of the chain
|
|
||||||
//
|
|
||||||
// Preferred shape for new code:
|
|
||||||
//
|
|
||||||
// return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
// "invalid --start: %v", err).
|
|
||||||
// WithHint("expected RFC3339, e.g. 2026-05-26T10:00:00Z").
|
|
||||||
// WithParam("--start")
|
|
||||||
//
|
|
||||||
// Category is locked by the constructor name — it can never be mis-specified
|
|
||||||
// at the call site. Subtype + Message are required positional arguments so the
|
|
||||||
// compiler refuses to build a typed error missing either identity field.
|
|
||||||
// Subtype well-formedness is enforced at PR time by the lint guard
|
|
||||||
// CheckDeclaredSubtype (`lint/errscontract`), not at runtime, to avoid
|
|
||||||
// coupling the typed package to a registry. ad_hoc_* subtypes are accepted
|
|
||||||
// at runtime; CheckAdHocSubtype emits a follow-up warning.
|
|
||||||
|
|
||||||
// TypedError is implemented by all typed errors in this package.
|
|
||||||
// It identifies a value as a typed envelope producer to the dispatcher,
|
|
||||||
// which uses it to short-circuit promotion when the outer error is
|
|
||||||
// already typed (avoiding overwrite of producer-set Subtype/Hint).
|
|
||||||
type TypedError interface {
|
|
||||||
error
|
|
||||||
ProblemDetail() *Problem
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================== ValidationError ==============================
|
|
||||||
|
|
||||||
// ValidationError is the typed error for CategoryValidation.
|
|
||||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
|
||||||
// it is intentionally not serialized.
|
|
||||||
type ValidationError struct {
|
|
||||||
Problem
|
|
||||||
Param string `json:"param,omitempty"`
|
|
||||||
Cause error `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
|
|
||||||
// it. A nil typed-pointer held inside an error interface is treated as
|
|
||||||
// "no cause" so callers cannot panic on `errors.Unwrap(err)`.
|
|
||||||
func (e *ValidationError) Unwrap() error {
|
|
||||||
if e == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return e.Cause
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error returns the typed error message. Nil-safe — falls back to "" when the
|
|
||||||
// receiver is a typed nil pointer, mirroring the embedded Problem.Error() guard
|
|
||||||
// that promote-through-value-embed would otherwise bypass.
|
|
||||||
func (e *ValidationError) Error() string {
|
|
||||||
if e == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return e.Problem.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewValidationError constructs a *ValidationError with Category locked to
|
|
||||||
// CategoryValidation and Message formatted via fmt.Sprintf(format, args...).
|
|
||||||
func NewValidationError(subtype Subtype, format string, args ...any) *ValidationError {
|
|
||||||
return &ValidationError{
|
|
||||||
Problem: Problem{
|
|
||||||
Category: CategoryValidation,
|
|
||||||
Subtype: subtype,
|
|
||||||
Message: formatMessage(format, args),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ValidationError) WithHint(format string, args ...any) *ValidationError {
|
|
||||||
e.Hint = formatMessage(format, args)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ValidationError) WithLogID(logID string) *ValidationError {
|
|
||||||
e.LogID = logID
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ValidationError) WithCode(code int) *ValidationError {
|
|
||||||
e.Code = code
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ValidationError) WithRetryable() *ValidationError {
|
|
||||||
e.Retryable = true
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ValidationError) WithParam(param string) *ValidationError {
|
|
||||||
e.Param = param
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ValidationError) WithCause(cause error) *ValidationError {
|
|
||||||
e.Cause = cause
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================== AuthenticationError =============================
|
|
||||||
|
|
||||||
// AuthenticationError is the typed error for CategoryAuthentication.
|
|
||||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
|
||||||
// it is intentionally not serialized.
|
|
||||||
type AuthenticationError struct {
|
|
||||||
Problem
|
|
||||||
UserOpenID string `json:"user_open_id,omitempty"`
|
|
||||||
Cause error `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
|
||||||
func (e *AuthenticationError) Unwrap() error {
|
|
||||||
if e == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return e.Cause
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error is nil-receiver safe; see ValidationError.Error.
|
|
||||||
func (e *AuthenticationError) Error() string {
|
|
||||||
if e == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return e.Problem.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAuthenticationError(subtype Subtype, format string, args ...any) *AuthenticationError {
|
|
||||||
return &AuthenticationError{
|
|
||||||
Problem: Problem{
|
|
||||||
Category: CategoryAuthentication,
|
|
||||||
Subtype: subtype,
|
|
||||||
Message: formatMessage(format, args),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *AuthenticationError) WithHint(format string, args ...any) *AuthenticationError {
|
|
||||||
e.Hint = formatMessage(format, args)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *AuthenticationError) WithLogID(logID string) *AuthenticationError {
|
|
||||||
e.LogID = logID
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *AuthenticationError) WithCode(code int) *AuthenticationError {
|
|
||||||
e.Code = code
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *AuthenticationError) WithRetryable() *AuthenticationError {
|
|
||||||
e.Retryable = true
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *AuthenticationError) WithUserOpenID(id string) *AuthenticationError {
|
|
||||||
e.UserOpenID = id
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *AuthenticationError) WithCause(cause error) *AuthenticationError {
|
|
||||||
e.Cause = cause
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================= PermissionError ===============================
|
|
||||||
|
|
||||||
// PermissionError is the typed error for CategoryAuthorization.
|
|
||||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
|
||||||
// it is intentionally not serialized.
|
|
||||||
type PermissionError struct {
|
|
||||||
Problem
|
|
||||||
MissingScopes []string `json:"missing_scopes,omitempty"`
|
|
||||||
RequestedScopes []string `json:"requested_scopes,omitempty"`
|
|
||||||
GrantedScopes []string `json:"granted_scopes,omitempty"`
|
|
||||||
Identity string `json:"identity,omitempty"`
|
|
||||||
ConsoleURL string `json:"console_url,omitempty"`
|
|
||||||
Cause error `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
|
||||||
func (e *PermissionError) Unwrap() error {
|
|
||||||
if e == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return e.Cause
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error is nil-receiver safe; see ValidationError.Error.
|
|
||||||
func (e *PermissionError) Error() string {
|
|
||||||
if e == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return e.Problem.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPermissionError(subtype Subtype, format string, args ...any) *PermissionError {
|
|
||||||
return &PermissionError{
|
|
||||||
Problem: Problem{
|
|
||||||
Category: CategoryAuthorization,
|
|
||||||
Subtype: subtype,
|
|
||||||
Message: formatMessage(format, args),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *PermissionError) WithHint(format string, args ...any) *PermissionError {
|
|
||||||
e.Hint = formatMessage(format, args)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *PermissionError) WithLogID(logID string) *PermissionError {
|
|
||||||
e.LogID = logID
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *PermissionError) WithCode(code int) *PermissionError {
|
|
||||||
e.Code = code
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *PermissionError) WithRetryable() *PermissionError {
|
|
||||||
e.Retryable = true
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError {
|
|
||||||
e.MissingScopes = slices.Clone(scopes)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *PermissionError) WithRequestedScopes(scopes ...string) *PermissionError {
|
|
||||||
e.RequestedScopes = slices.Clone(scopes)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *PermissionError) WithGrantedScopes(scopes ...string) *PermissionError {
|
|
||||||
e.GrantedScopes = slices.Clone(scopes)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *PermissionError) WithIdentity(identity string) *PermissionError {
|
|
||||||
e.Identity = identity
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *PermissionError) WithConsoleURL(url string) *PermissionError {
|
|
||||||
e.ConsoleURL = url
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *PermissionError) WithCause(cause error) *PermissionError {
|
|
||||||
e.Cause = cause
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================== ConfigError =================================
|
|
||||||
|
|
||||||
// ConfigError is the typed error for CategoryConfig. Cause preserves an
|
|
||||||
// optional wrapped sentinel for errors.Is / errors.Unwrap; it is
|
|
||||||
// intentionally not serialized.
|
|
||||||
type ConfigError struct {
|
|
||||||
Problem
|
|
||||||
Field string `json:"field,omitempty"`
|
|
||||||
Cause error `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
|
||||||
func (e *ConfigError) Unwrap() error {
|
|
||||||
if e == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return e.Cause
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error is nil-receiver safe; see ValidationError.Error.
|
|
||||||
func (e *ConfigError) Error() string {
|
|
||||||
if e == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return e.Problem.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewConfigError(subtype Subtype, format string, args ...any) *ConfigError {
|
|
||||||
return &ConfigError{
|
|
||||||
Problem: Problem{
|
|
||||||
Category: CategoryConfig,
|
|
||||||
Subtype: subtype,
|
|
||||||
Message: formatMessage(format, args),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ConfigError) WithHint(format string, args ...any) *ConfigError {
|
|
||||||
e.Hint = formatMessage(format, args)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ConfigError) WithLogID(logID string) *ConfigError {
|
|
||||||
e.LogID = logID
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ConfigError) WithCode(code int) *ConfigError {
|
|
||||||
e.Code = code
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ConfigError) WithRetryable() *ConfigError {
|
|
||||||
e.Retryable = true
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ConfigError) WithField(field string) *ConfigError {
|
|
||||||
e.Field = field
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ConfigError) WithCause(cause error) *ConfigError {
|
|
||||||
e.Cause = cause
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================== NetworkError ================================
|
|
||||||
|
|
||||||
// NetworkError is the typed error for CategoryNetwork. The Subtype carries
|
|
||||||
// the failure taxonomy: timeout / tls / dns / server_error, with transport
|
|
||||||
// as the fallback. Cause preserves an optional wrapped sentinel for
|
|
||||||
// errors.Is / errors.Unwrap; it is intentionally not serialized.
|
|
||||||
type NetworkError struct {
|
|
||||||
Problem
|
|
||||||
Cause error `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
|
||||||
func (e *NetworkError) Unwrap() error {
|
|
||||||
if e == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return e.Cause
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error is nil-receiver safe; see ValidationError.Error.
|
|
||||||
func (e *NetworkError) Error() string {
|
|
||||||
if e == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return e.Problem.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewNetworkError(subtype Subtype, format string, args ...any) *NetworkError {
|
|
||||||
return &NetworkError{
|
|
||||||
Problem: Problem{
|
|
||||||
Category: CategoryNetwork,
|
|
||||||
Subtype: subtype,
|
|
||||||
Message: formatMessage(format, args),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *NetworkError) WithHint(format string, args ...any) *NetworkError {
|
|
||||||
e.Hint = formatMessage(format, args)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *NetworkError) WithLogID(logID string) *NetworkError {
|
|
||||||
e.LogID = logID
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *NetworkError) WithCode(code int) *NetworkError {
|
|
||||||
e.Code = code
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *NetworkError) WithRetryable() *NetworkError {
|
|
||||||
e.Retryable = true
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *NetworkError) WithCause(cause error) *NetworkError {
|
|
||||||
e.Cause = cause
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================ APIError ===================================
|
|
||||||
|
|
||||||
// APIError is the typed error for CategoryAPI (catch-all for classified Lark
|
|
||||||
// API business errors). Cause preserves an optional wrapped sentinel for
|
|
||||||
// errors.Is / errors.Unwrap; it is intentionally not serialized.
|
|
||||||
type APIError struct {
|
|
||||||
Problem
|
|
||||||
Cause error `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
|
||||||
func (e *APIError) Unwrap() error {
|
|
||||||
if e == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return e.Cause
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error is nil-receiver safe; see ValidationError.Error.
|
|
||||||
func (e *APIError) Error() string {
|
|
||||||
if e == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return e.Problem.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAPIError(subtype Subtype, format string, args ...any) *APIError {
|
|
||||||
return &APIError{
|
|
||||||
Problem: Problem{
|
|
||||||
Category: CategoryAPI,
|
|
||||||
Subtype: subtype,
|
|
||||||
Message: formatMessage(format, args),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *APIError) WithHint(format string, args ...any) *APIError {
|
|
||||||
e.Hint = formatMessage(format, args)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *APIError) WithLogID(logID string) *APIError {
|
|
||||||
e.LogID = logID
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *APIError) WithCode(code int) *APIError {
|
|
||||||
e.Code = code
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *APIError) WithRetryable() *APIError {
|
|
||||||
e.Retryable = true
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *APIError) WithCause(cause error) *APIError {
|
|
||||||
e.Cause = cause
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================== SecurityPolicyError =============================
|
|
||||||
|
|
||||||
// SecurityPolicyError is the typed error for CategoryPolicy security-policy subtypes.
|
|
||||||
// Subtype is "challenge_required" or "access_denied"; Code is 21000 or 21001.
|
|
||||||
type SecurityPolicyError struct {
|
|
||||||
Problem
|
|
||||||
ChallengeURL string `json:"challenge_url,omitempty"`
|
|
||||||
Cause error `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
|
||||||
func (e *SecurityPolicyError) Unwrap() error {
|
|
||||||
if e == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return e.Cause
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error is nil-receiver safe; see ValidationError.Error.
|
|
||||||
func (e *SecurityPolicyError) Error() string {
|
|
||||||
if e == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return e.Problem.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSecurityPolicyError(subtype Subtype, format string, args ...any) *SecurityPolicyError {
|
|
||||||
return &SecurityPolicyError{
|
|
||||||
Problem: Problem{
|
|
||||||
Category: CategoryPolicy,
|
|
||||||
Subtype: subtype,
|
|
||||||
Message: formatMessage(format, args),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *SecurityPolicyError) WithHint(format string, args ...any) *SecurityPolicyError {
|
|
||||||
e.Hint = formatMessage(format, args)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *SecurityPolicyError) WithLogID(logID string) *SecurityPolicyError {
|
|
||||||
e.LogID = logID
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *SecurityPolicyError) WithCode(code int) *SecurityPolicyError {
|
|
||||||
e.Code = code
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *SecurityPolicyError) WithRetryable() *SecurityPolicyError {
|
|
||||||
e.Retryable = true
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *SecurityPolicyError) WithChallengeURL(url string) *SecurityPolicyError {
|
|
||||||
e.ChallengeURL = url
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *SecurityPolicyError) WithCause(cause error) *SecurityPolicyError {
|
|
||||||
e.Cause = cause
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================ ContentSafetyError =============================
|
|
||||||
|
|
||||||
// ContentSafetyError is the typed error for CategoryPolicy content-safety subtypes.
|
|
||||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
|
||||||
// it is intentionally not serialized.
|
|
||||||
type ContentSafetyError struct {
|
|
||||||
Problem
|
|
||||||
Rules []string `json:"rules,omitempty"`
|
|
||||||
Cause error `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
|
||||||
func (e *ContentSafetyError) Unwrap() error {
|
|
||||||
if e == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return e.Cause
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error is nil-receiver safe; see ValidationError.Error.
|
|
||||||
func (e *ContentSafetyError) Error() string {
|
|
||||||
if e == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return e.Problem.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewContentSafetyError(subtype Subtype, format string, args ...any) *ContentSafetyError {
|
|
||||||
return &ContentSafetyError{
|
|
||||||
Problem: Problem{
|
|
||||||
Category: CategoryPolicy,
|
|
||||||
Subtype: subtype,
|
|
||||||
Message: formatMessage(format, args),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ContentSafetyError) WithHint(format string, args ...any) *ContentSafetyError {
|
|
||||||
e.Hint = formatMessage(format, args)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ContentSafetyError) WithLogID(logID string) *ContentSafetyError {
|
|
||||||
e.LogID = logID
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ContentSafetyError) WithCode(code int) *ContentSafetyError {
|
|
||||||
e.Code = code
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ContentSafetyError) WithRetryable() *ContentSafetyError {
|
|
||||||
e.Retryable = true
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ContentSafetyError) WithRules(rules ...string) *ContentSafetyError {
|
|
||||||
e.Rules = slices.Clone(rules)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ContentSafetyError) WithCause(cause error) *ContentSafetyError {
|
|
||||||
e.Cause = cause
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================== InternalError ===============================
|
|
||||||
|
|
||||||
// InternalError is the typed error for CategoryInternal. Cause is preserved
|
|
||||||
// for logging but not emitted on the wire.
|
|
||||||
type InternalError struct {
|
|
||||||
Problem
|
|
||||||
Cause error `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
|
||||||
func (e *InternalError) Unwrap() error {
|
|
||||||
if e == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return e.Cause
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error is nil-receiver safe; see ValidationError.Error.
|
|
||||||
func (e *InternalError) Error() string {
|
|
||||||
if e == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return e.Problem.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewInternalError(subtype Subtype, format string, args ...any) *InternalError {
|
|
||||||
return &InternalError{
|
|
||||||
Problem: Problem{
|
|
||||||
Category: CategoryInternal,
|
|
||||||
Subtype: subtype,
|
|
||||||
Message: formatMessage(format, args),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *InternalError) WithHint(format string, args ...any) *InternalError {
|
|
||||||
e.Hint = formatMessage(format, args)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *InternalError) WithLogID(logID string) *InternalError {
|
|
||||||
e.LogID = logID
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *InternalError) WithCode(code int) *InternalError {
|
|
||||||
e.Code = code
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *InternalError) WithRetryable() *InternalError {
|
|
||||||
e.Retryable = true
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *InternalError) WithCause(cause error) *InternalError {
|
|
||||||
e.Cause = cause
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================= ConfirmationRequiredError =========================
|
|
||||||
|
|
||||||
// Risk classifies the impact of a confirmation-required operation. Every
|
|
||||||
// ConfirmationRequiredError MUST populate Risk; callers without a known
|
|
||||||
// risk level use RiskUnknown so the envelope is never wire-invalid.
|
|
||||||
const (
|
|
||||||
RiskRead = "read"
|
|
||||||
RiskWrite = "write"
|
|
||||||
RiskHighRiskWrite = "high-risk-write"
|
|
||||||
RiskUnknown = "unknown"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConfirmationRequiredError is the typed error for CategoryConfirmation.
|
|
||||||
// Risk is one of: "read" | "write" | "high-risk-write" | "unknown".
|
|
||||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
|
||||||
// it is intentionally not serialized.
|
|
||||||
type ConfirmationRequiredError struct {
|
|
||||||
Problem
|
|
||||||
Risk string `json:"risk"`
|
|
||||||
Action string `json:"action"`
|
|
||||||
Cause error `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
|
||||||
func (e *ConfirmationRequiredError) Unwrap() error {
|
|
||||||
if e == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return e.Cause
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error is nil-receiver safe; see ValidationError.Error.
|
|
||||||
func (e *ConfirmationRequiredError) Error() string {
|
|
||||||
if e == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return e.Problem.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewConfirmationRequiredError constructs a *ConfirmationRequiredError.
|
|
||||||
// Risk + Action are wire-required (non-omitempty). Empty inputs are
|
|
||||||
// normalized at the constructor boundary so callers cannot build a
|
|
||||||
// wire-invalid envelope: risk falls back to RiskUnknown, action to
|
|
||||||
// "unknown". risk is one of: "read" | "write" | "high-risk-write".
|
|
||||||
func NewConfirmationRequiredError(risk, action, format string, args ...any) *ConfirmationRequiredError {
|
|
||||||
if risk == "" {
|
|
||||||
risk = RiskUnknown
|
|
||||||
}
|
|
||||||
if action == "" {
|
|
||||||
action = "unknown"
|
|
||||||
}
|
|
||||||
return &ConfirmationRequiredError{
|
|
||||||
Problem: Problem{
|
|
||||||
Category: CategoryConfirmation,
|
|
||||||
Subtype: SubtypeConfirmationRequired,
|
|
||||||
Message: formatMessage(format, args),
|
|
||||||
},
|
|
||||||
Risk: risk,
|
|
||||||
Action: action,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ConfirmationRequiredError) WithHint(format string, args ...any) *ConfirmationRequiredError {
|
|
||||||
e.Hint = formatMessage(format, args)
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ConfirmationRequiredError) WithLogID(logID string) *ConfirmationRequiredError {
|
|
||||||
e.LogID = logID
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ConfirmationRequiredError) WithCode(code int) *ConfirmationRequiredError {
|
|
||||||
e.Code = code
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ConfirmationRequiredError) WithCause(cause error) *ConfirmationRequiredError {
|
|
||||||
e.Cause = cause
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
@@ -1,580 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package errs_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ============================== JSON shape & embed ==============================
|
|
||||||
|
|
||||||
func TestPermissionErrorJSONShape(t *testing.T) {
|
|
||||||
perm := &errs.PermissionError{
|
|
||||||
Problem: errs.Problem{
|
|
||||||
Category: errs.CategoryAuthorization,
|
|
||||||
Subtype: errs.SubtypeMissingScope,
|
|
||||||
Message: "x",
|
|
||||||
},
|
|
||||||
MissingScopes: []string{"docx:document"},
|
|
||||||
}
|
|
||||||
b, err := json.Marshal(perm)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal failed: %v", err)
|
|
||||||
}
|
|
||||||
got := string(b)
|
|
||||||
|
|
||||||
mustContain := []string{
|
|
||||||
`"type":"authorization"`,
|
|
||||||
`"subtype":"missing_scope"`,
|
|
||||||
`"missing_scopes":["docx:document"]`,
|
|
||||||
}
|
|
||||||
for _, want := range mustContain {
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Errorf("json output missing %q\nfull output: %s", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mustNotContain := []string{
|
|
||||||
`"component"`,
|
|
||||||
`"doc_url"`,
|
|
||||||
`"retryable":false`,
|
|
||||||
}
|
|
||||||
for _, bad := range mustNotContain {
|
|
||||||
if strings.Contains(got, bad) {
|
|
||||||
t.Errorf("json output unexpectedly contains %q\nfull output: %s", bad, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEmbedSemanticChasm proves the documented Go embed limitation:
|
|
||||||
// errors.As(*PermissionError, &p *Problem) returns false even though
|
|
||||||
// PermissionError embeds Problem. ProblemOf works around this by routing
|
|
||||||
// via the unexported problemCarrier interface.
|
|
||||||
func TestEmbedSemanticChasm(t *testing.T) {
|
|
||||||
perm := &errs.PermissionError{
|
|
||||||
Problem: errs.Problem{
|
|
||||||
Category: errs.CategoryAuthorization,
|
|
||||||
Subtype: errs.SubtypeMissingScope,
|
|
||||||
Message: "missing",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var p *errs.Problem
|
|
||||||
if errors.As(perm, &p) {
|
|
||||||
t.Errorf("errors.As(*PermissionError, &*Problem) unexpectedly succeeded; Go embed semantic changed")
|
|
||||||
}
|
|
||||||
|
|
||||||
got, ok := errs.ProblemOf(perm)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("ProblemOf(*PermissionError) returned ok=false; expected to extract embedded Problem")
|
|
||||||
}
|
|
||||||
if got != &perm.Problem {
|
|
||||||
t.Errorf("ProblemOf returned %p, want &perm.Problem = %p", got, &perm.Problem)
|
|
||||||
}
|
|
||||||
if got.Category != errs.CategoryAuthorization {
|
|
||||||
t.Errorf("extracted Problem.Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityPolicyErrorUnwrap(t *testing.T) {
|
|
||||||
orig := errors.New("transport stalled")
|
|
||||||
spe := &errs.SecurityPolicyError{
|
|
||||||
Problem: errs.Problem{Category: errs.CategoryPolicy, Subtype: errs.Subtype("challenge_required"), Message: "blocked"},
|
|
||||||
Cause: orig,
|
|
||||||
}
|
|
||||||
if got := errors.Unwrap(spe); got != orig {
|
|
||||||
t.Fatalf("errors.Unwrap(spe) = %v, want %v", got, orig)
|
|
||||||
}
|
|
||||||
if !errors.Is(spe, orig) {
|
|
||||||
t.Fatal("errors.Is(spe, orig) = false, want true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTypedErrors_UnwrapNilReceiver pins the nil-receiver guard on every typed
|
|
||||||
// error's Unwrap. Without these, a typed-nil pointer stored in an error
|
|
||||||
// interface would panic when the root dispatcher or any caller walks the
|
|
||||||
// errors.Is / errors.Unwrap chain.
|
|
||||||
//
|
|
||||||
// The doc comments on these types claim "nil-receiver safe" but until this
|
|
||||||
// test landed nothing actually pinned that claim — exactly the
|
|
||||||
// behavioral-comment-without-test footgun caught in PR #984 review.
|
|
||||||
func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
checks := []struct {
|
|
||||||
name string
|
|
||||||
call func() error
|
|
||||||
}{
|
|
||||||
{"ValidationError", func() error { var e *errs.ValidationError; return e.Unwrap() }},
|
|
||||||
{"AuthenticationError", func() error { var e *errs.AuthenticationError; return e.Unwrap() }},
|
|
||||||
{"ConfigError", func() error { var e *errs.ConfigError; return e.Unwrap() }},
|
|
||||||
{"NetworkError", func() error { var e *errs.NetworkError; return e.Unwrap() }},
|
|
||||||
{"SecurityPolicyError", func() error { var e *errs.SecurityPolicyError; return e.Unwrap() }},
|
|
||||||
{"InternalError", func() error { var e *errs.InternalError; return e.Unwrap() }},
|
|
||||||
}
|
|
||||||
for _, c := range checks {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Fatalf("(*%s)(nil).Unwrap() panicked: %v", c.name, r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if got := c.call(); got != nil {
|
|
||||||
t.Errorf("(*%s)(nil).Unwrap() = %v, want nil", c.name, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTypedError_NilReceiverError pins the nil-receiver guard on every typed
|
|
||||||
// error's Error(). Each typed error must define its own Error() method that
|
|
||||||
// nil-guards the outer pointer; the embedded Problem.Error()'s nil guard is
|
|
||||||
// bypassed because Go must dereference the outer pointer to reach the embedded
|
|
||||||
// field via value-embed promotion.
|
|
||||||
func TestTypedError_NilReceiverError(t *testing.T) {
|
|
||||||
// Each typed error must define its own Error() method that nil-guards
|
|
||||||
// the outer pointer; the embedded Problem.Error()'s nil guard is bypassed
|
|
||||||
// because Go must dereference the outer pointer to reach the embedded field.
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
}{
|
|
||||||
{"ValidationError", (*errs.ValidationError)(nil)},
|
|
||||||
{"AuthenticationError", (*errs.AuthenticationError)(nil)},
|
|
||||||
{"PermissionError", (*errs.PermissionError)(nil)},
|
|
||||||
{"ConfigError", (*errs.ConfigError)(nil)},
|
|
||||||
{"NetworkError", (*errs.NetworkError)(nil)},
|
|
||||||
{"APIError", (*errs.APIError)(nil)},
|
|
||||||
{"InternalError", (*errs.InternalError)(nil)},
|
|
||||||
{"SecurityPolicyError", (*errs.SecurityPolicyError)(nil)},
|
|
||||||
{"ContentSafetyError", (*errs.ContentSafetyError)(nil)},
|
|
||||||
{"ConfirmationRequiredError", (*errs.ConfirmationRequiredError)(nil)},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Fatalf("(*%s)(nil).Error() panicked: %v", tc.name, r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if got := tc.err.Error(); got != "" {
|
|
||||||
t.Errorf("(*%s)(nil).Error() = %q, want empty string", tc.name, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTypedErrors_UnwrapPropagatesCause pins the positive Unwrap path so the
|
|
||||||
// nil-safety guard above does not silently drop a real Cause on non-nil
|
|
||||||
// receivers. Without this, a buggy refactor could change `return e.Cause` to
|
|
||||||
// `return nil` and the test suite would still pass.
|
|
||||||
func TestTypedErrors_UnwrapPropagatesCause(t *testing.T) {
|
|
||||||
cause := errors.New("upstream cause")
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
err interface{ Unwrap() error }
|
|
||||||
}{
|
|
||||||
{"ValidationError", &errs.ValidationError{Cause: cause}},
|
|
||||||
{"AuthenticationError", &errs.AuthenticationError{Cause: cause}},
|
|
||||||
{"ConfigError", &errs.ConfigError{Cause: cause}},
|
|
||||||
{"NetworkError", &errs.NetworkError{Cause: cause}},
|
|
||||||
{"SecurityPolicyError", &errs.SecurityPolicyError{Cause: cause}},
|
|
||||||
{"InternalError", &errs.InternalError{Cause: cause}},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
if got := c.err.Unwrap(); got != cause {
|
|
||||||
t.Errorf("(*%s).Unwrap() = %v, want %v", c.name, got, cause)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================== Builder API ===============================
|
|
||||||
|
|
||||||
// TestNewXxxError_LocksCategory verifies each constructor sets Category
|
|
||||||
// from its function name; caller cannot mis-specify it.
|
|
||||||
func TestNewXxxError_LocksCategory(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
got errs.Category
|
|
||||||
want errs.Category
|
|
||||||
}{
|
|
||||||
{"validation", errs.NewValidationError(errs.SubtypeInvalidArgument, "x").Category, errs.CategoryValidation},
|
|
||||||
{"authentication", errs.NewAuthenticationError(errs.SubtypeTokenMissing, "x").Category, errs.CategoryAuthentication},
|
|
||||||
{"authorization", errs.NewPermissionError(errs.SubtypeMissingScope, "x").Category, errs.CategoryAuthorization},
|
|
||||||
{"config", errs.NewConfigError(errs.SubtypeNotConfigured, "x").Category, errs.CategoryConfig},
|
|
||||||
{"network", errs.NewNetworkError(errs.SubtypeNetworkTransport, "x").Category, errs.CategoryNetwork},
|
|
||||||
{"api", errs.NewAPIError(errs.SubtypeRateLimit, "x").Category, errs.CategoryAPI},
|
|
||||||
{"policy_security", errs.NewSecurityPolicyError(errs.SubtypeChallengeRequired, "x").Category, errs.CategoryPolicy},
|
|
||||||
{"policy_content", errs.NewContentSafetyError(errs.SubtypeUnknown, "x").Category, errs.CategoryPolicy},
|
|
||||||
{"internal", errs.NewInternalError(errs.SubtypeSDKError, "x").Category, errs.CategoryInternal},
|
|
||||||
{"confirmation", errs.NewConfirmationRequiredError("write", "delete files", "x").Category, errs.CategoryConfirmation},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if tc.got != tc.want {
|
|
||||||
t.Errorf("Category = %q, want %q", tc.got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewXxxError_PrintfFormat verifies Message is formatted via fmt.Sprintf
|
|
||||||
// just like fmt.Errorf — the canonical Go convention for error messages.
|
|
||||||
func TestNewXxxError_PrintfFormat(t *testing.T) {
|
|
||||||
cause := errors.New("boom")
|
|
||||||
got := errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
||||||
"invalid --start (%s): %v", "yesterday", cause)
|
|
||||||
want := "invalid --start (yesterday): boom"
|
|
||||||
if got.Message != want {
|
|
||||||
t.Errorf("Message = %q, want %q", got.Message, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewXxxError_LiteralPercentNoArgs pins the constructor's empty-args
|
|
||||||
// fast path: a literal "%" in the message must NOT be rendered as
|
|
||||||
// "%!(NOVERB)" when no args are passed.
|
|
||||||
func TestNewXxxError_LiteralPercentNoArgs(t *testing.T) {
|
|
||||||
got := errs.NewValidationError(errs.SubtypeInvalidArgument, "disk 100% full")
|
|
||||||
if got.Message != "disk 100% full" {
|
|
||||||
t.Errorf("Message = %q, want %q", got.Message, "disk 100% full")
|
|
||||||
}
|
|
||||||
hinted := errs.NewInternalError(errs.SubtypeStorage, "save failed").
|
|
||||||
WithHint("only 5% headroom remains")
|
|
||||||
if hinted.Hint != "only 5% headroom remains" {
|
|
||||||
t.Errorf("Hint = %q, want %q", hinted.Hint, "only 5% headroom remains")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWithChain_ReturnsConcretePointer verifies WithX setters return the
|
|
||||||
// concrete *XxxError pointer, not *Problem — so chains preserve type and
|
|
||||||
// type-specific setters remain reachable to the end of the chain.
|
|
||||||
func TestWithChain_ReturnsConcretePointer(t *testing.T) {
|
|
||||||
// Chain composition: only compiles if every intermediate result has
|
|
||||||
// the concrete pointer type. Hint is on every type, Param is only on
|
|
||||||
// ValidationError — chain must keep ValidationError type to reach it.
|
|
||||||
got := errs.NewValidationError(errs.SubtypeInvalidArgument, "msg").
|
|
||||||
WithHint("hint text").
|
|
||||||
WithLogID("log-123").
|
|
||||||
WithCode(42).
|
|
||||||
WithRetryable().
|
|
||||||
WithParam("--start").
|
|
||||||
WithCause(errors.New("boom"))
|
|
||||||
|
|
||||||
if got.Hint != "hint text" {
|
|
||||||
t.Errorf("Hint = %q, want %q", got.Hint, "hint text")
|
|
||||||
}
|
|
||||||
if got.LogID != "log-123" {
|
|
||||||
t.Errorf("LogID = %q, want %q", got.LogID, "log-123")
|
|
||||||
}
|
|
||||||
if got.Code != 42 {
|
|
||||||
t.Errorf("Code = %d, want %d", got.Code, 42)
|
|
||||||
}
|
|
||||||
if !got.Retryable {
|
|
||||||
t.Errorf("Retryable = false, want true")
|
|
||||||
}
|
|
||||||
if got.Param != "--start" {
|
|
||||||
t.Errorf("Param = %q, want %q", got.Param, "--start")
|
|
||||||
}
|
|
||||||
if got.Cause == nil || got.Cause.Error() != "boom" {
|
|
||||||
t.Errorf("Cause = %v, want error 'boom'", got.Cause)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWithChain_MutatesReceiver verifies WithX returns the same pointer
|
|
||||||
// (not a copy) — chain edits propagate to the original construction.
|
|
||||||
func TestWithChain_MutatesReceiver(t *testing.T) {
|
|
||||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "msg")
|
|
||||||
returned := e.WithHint("hint")
|
|
||||||
if returned != e {
|
|
||||||
t.Errorf("WithHint returned different pointer; want same as receiver")
|
|
||||||
}
|
|
||||||
if e.Hint != "hint" {
|
|
||||||
t.Errorf("Receiver Hint not mutated: got %q", e.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWithHint_PrintfFormat verifies WithHint follows fmt.Sprintf, matching
|
|
||||||
// the constructor's printf convention.
|
|
||||||
func TestWithHint_PrintfFormat(t *testing.T) {
|
|
||||||
got := errs.NewValidationError(errs.SubtypeInvalidArgument, "x").
|
|
||||||
WithHint("expected one of: %v", []string{"7d", "1m"})
|
|
||||||
want := "expected one of: [7d 1m]"
|
|
||||||
if got.Hint != want {
|
|
||||||
t.Errorf("Hint = %q, want %q", got.Hint, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPermissionError_FullChain verifies the most field-heavy typed error
|
|
||||||
// constructs cleanly via the chain.
|
|
||||||
func TestPermissionError_FullChain(t *testing.T) {
|
|
||||||
got := errs.NewPermissionError(errs.SubtypeMissingScope,
|
|
||||||
"--confirm-send requires scope: %s", "mail:user_mailbox.message:send").
|
|
||||||
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
|
||||||
WithMissingScopes("mail:user_mailbox.message:send").
|
|
||||||
WithIdentity("user").
|
|
||||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
|
||||||
|
|
||||||
if got.Category != errs.CategoryAuthorization {
|
|
||||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
|
||||||
}
|
|
||||||
if got.Subtype != errs.SubtypeMissingScope {
|
|
||||||
t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypeMissingScope)
|
|
||||||
}
|
|
||||||
if len(got.MissingScopes) != 1 || got.MissingScopes[0] != "mail:user_mailbox.message:send" {
|
|
||||||
t.Errorf("MissingScopes = %v, want [mail:user_mailbox.message:send]", got.MissingScopes)
|
|
||||||
}
|
|
||||||
if got.Identity != "user" {
|
|
||||||
t.Errorf("Identity = %q, want %q", got.Identity, "user")
|
|
||||||
}
|
|
||||||
if got.ConsoleURL == "" {
|
|
||||||
t.Error("ConsoleURL is empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWithMissingScopes_VariadicAndSliceExpansion verifies both forms work.
|
|
||||||
func TestWithMissingScopes_VariadicAndSliceExpansion(t *testing.T) {
|
|
||||||
t.Run("variadic", func(t *testing.T) {
|
|
||||||
got := errs.NewPermissionError(errs.SubtypeMissingScope, "x").
|
|
||||||
WithMissingScopes("a:read", "b:write")
|
|
||||||
if len(got.MissingScopes) != 2 {
|
|
||||||
t.Errorf("got %v, want 2 elements", got.MissingScopes)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("slice_expanded", func(t *testing.T) {
|
|
||||||
scopes := []string{"a:read", "b:write"}
|
|
||||||
got := errs.NewPermissionError(errs.SubtypeMissingScope, "x").
|
|
||||||
WithMissingScopes(scopes...)
|
|
||||||
if len(got.MissingScopes) != 2 {
|
|
||||||
t.Errorf("got %v, want 2 elements", got.MissingScopes)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNetworkError_SubtypeAndChain verifies that a network failure carries
|
|
||||||
// its canonical subtype, Retryable flag, and Unwrap chain together.
|
|
||||||
func TestNetworkError_SubtypeAndChain(t *testing.T) {
|
|
||||||
got := errs.NewNetworkError(errs.SubtypeNetworkTimeout, "download failed: %v", errors.New("timeout")).
|
|
||||||
WithCause(errors.New("context deadline exceeded")).
|
|
||||||
WithRetryable()
|
|
||||||
|
|
||||||
if got.Subtype != errs.SubtypeNetworkTimeout {
|
|
||||||
t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypeNetworkTimeout)
|
|
||||||
}
|
|
||||||
if !got.Retryable {
|
|
||||||
t.Errorf("Retryable = false, want true")
|
|
||||||
}
|
|
||||||
if got.Cause == nil {
|
|
||||||
t.Error("Cause is nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewConfirmationRequiredError_RequiresRiskAndAction verifies the
|
|
||||||
// constructor signature pins Risk + Action as positional args (non-omitempty
|
|
||||||
// wire fields per types.go).
|
|
||||||
func TestNewConfirmationRequiredError_RequiresRiskAndAction(t *testing.T) {
|
|
||||||
got := errs.NewConfirmationRequiredError("high-risk-write", "delete 42 files",
|
|
||||||
"this operation will delete %d files", 42)
|
|
||||||
|
|
||||||
if got.Risk != "high-risk-write" {
|
|
||||||
t.Errorf("Risk = %q, want %q", got.Risk, "high-risk-write")
|
|
||||||
}
|
|
||||||
if got.Action != "delete 42 files" {
|
|
||||||
t.Errorf("Action = %q, want %q", got.Action, "delete 42 files")
|
|
||||||
}
|
|
||||||
if got.Message != "this operation will delete 42 files" {
|
|
||||||
t.Errorf("Message = %q", got.Message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBuilder_ErrorsAsCompat verifies builder-constructed errors satisfy
|
|
||||||
// errors.As / errors.Is for both the typed wrapper and any wrapped cause.
|
|
||||||
func TestBuilder_ErrorsAsCompat(t *testing.T) {
|
|
||||||
cause := errors.New("upstream failure")
|
|
||||||
wrapped := errs.NewInternalError(errs.SubtypeSDKError, "wrap: %v", cause).WithCause(cause)
|
|
||||||
|
|
||||||
var asInternal *errs.InternalError
|
|
||||||
if !errors.As(wrapped, &asInternal) {
|
|
||||||
t.Error("errors.As should resolve to *InternalError")
|
|
||||||
}
|
|
||||||
if !errors.Is(wrapped, cause) {
|
|
||||||
t.Error("errors.Is should resolve to original cause via Unwrap")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBuilder_WireFormat marshals a fully-built error and asserts the JSON
|
|
||||||
// matches the canonical envelope shape. This complements marshal_test.go;
|
|
||||||
// the focus here is verifying builder-set fields land in the right JSON
|
|
||||||
// keys.
|
|
||||||
func TestBuilder_WireFormat(t *testing.T) {
|
|
||||||
e := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope %s", "calendar:event:create").
|
|
||||||
WithCode(99991679).
|
|
||||||
WithLogID("20260520-0a1b2c3d").
|
|
||||||
WithHint("run lark-cli auth login --scope calendar:event:create").
|
|
||||||
WithMissingScopes("calendar:event:create").
|
|
||||||
WithIdentity("user").
|
|
||||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
|
||||||
|
|
||||||
buf, err := json.Marshal(e)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Marshal: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var got map[string]any
|
|
||||||
if err := json.Unmarshal(buf, &got); err != nil {
|
|
||||||
t.Fatalf("Unmarshal: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantFields := map[string]any{
|
|
||||||
"type": "authorization",
|
|
||||||
"subtype": "missing_scope",
|
|
||||||
"code": float64(99991679),
|
|
||||||
"message": "missing scope calendar:event:create",
|
|
||||||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
|
||||||
"log_id": "20260520-0a1b2c3d",
|
|
||||||
"identity": "user",
|
|
||||||
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
|
|
||||||
"missing_scopes": []any{"calendar:event:create"},
|
|
||||||
}
|
|
||||||
for k, want := range wantFields {
|
|
||||||
gotVal, ok := got[k]
|
|
||||||
if !ok {
|
|
||||||
t.Errorf("missing wire field %q in %v", k, got)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch v := want.(type) {
|
|
||||||
case []any:
|
|
||||||
gotSlice, ok := gotVal.([]any)
|
|
||||||
if !ok || len(gotSlice) != len(v) {
|
|
||||||
t.Errorf("field %q = %v, want %v", k, gotVal, v)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for i := range v {
|
|
||||||
if gotSlice[i] != v[i] {
|
|
||||||
t.Errorf("field %q[%d] = %v, want %v", k, i, gotSlice[i], v[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if gotVal != want {
|
|
||||||
t.Errorf("field %q = %v, want %v", k, gotVal, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// retryable not set → must be absent (omitempty)
|
|
||||||
if _, present := got["retryable"]; present {
|
|
||||||
t.Errorf("retryable should be omitted when false, got %v", got["retryable"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBuilder_WithRetryable_OmittedWhenFalse verifies omitempty behaviour:
|
|
||||||
// retryable only appears on the wire when explicitly set to true.
|
|
||||||
func TestBuilder_WithRetryable_OmittedWhenFalse(t *testing.T) {
|
|
||||||
t.Run("absent_when_not_set", func(t *testing.T) {
|
|
||||||
e := errs.NewNetworkError(errs.SubtypeNetworkTransport, "x")
|
|
||||||
buf, _ := json.Marshal(e)
|
|
||||||
var got map[string]any
|
|
||||||
_ = json.Unmarshal(buf, &got)
|
|
||||||
if _, ok := got["retryable"]; ok {
|
|
||||||
t.Errorf("retryable present when unset; want omitted")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("present_when_set", func(t *testing.T) {
|
|
||||||
e := errs.NewNetworkError(errs.SubtypeNetworkTransport, "x").WithRetryable()
|
|
||||||
buf, _ := json.Marshal(e)
|
|
||||||
var got map[string]any
|
|
||||||
_ = json.Unmarshal(buf, &got)
|
|
||||||
v, ok := got["retryable"]
|
|
||||||
if !ok || v != true {
|
|
||||||
t.Errorf("retryable = %v ok=%v, want true present", v, ok)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewSecurityPolicyError_ChallengeURL covers the Policy-specific field.
|
|
||||||
func TestNewSecurityPolicyError_ChallengeURL(t *testing.T) {
|
|
||||||
got := errs.NewSecurityPolicyError(errs.SubtypeChallengeRequired, "verify your device").
|
|
||||||
WithCode(21000).
|
|
||||||
WithChallengeURL("https://applink.feishu.cn/T/xxxxx")
|
|
||||||
if got.ChallengeURL == "" {
|
|
||||||
t.Error("ChallengeURL not set")
|
|
||||||
}
|
|
||||||
if got.Code != 21000 {
|
|
||||||
t.Errorf("Code = %d, want 21000", got.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewContentSafetyError_Rules covers the variadic Rules setter.
|
|
||||||
func TestNewContentSafetyError_Rules(t *testing.T) {
|
|
||||||
got := errs.NewContentSafetyError(errs.SubtypeUnknown, "content blocked").
|
|
||||||
WithRules("no_pii", "no_secrets")
|
|
||||||
if len(got.Rules) != 2 {
|
|
||||||
t.Errorf("Rules = %v, want 2 elements", got.Rules)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTypedError_UnwrapSymmetry pins that every typed error carries a Cause
|
|
||||||
// field that participates in errors.Unwrap / errors.Is. Uniformity across
|
|
||||||
// all typed errors lets callers descend below the typed-error boundary
|
|
||||||
// without first switching on the concrete type.
|
|
||||||
func TestTypedError_UnwrapSymmetry(t *testing.T) {
|
|
||||||
sentinel := errors.New("upstream cause")
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
}{
|
|
||||||
{"APIError", errs.NewAPIError(errs.SubtypeServerError, "x").WithCause(sentinel)},
|
|
||||||
{"PermissionError", errs.NewPermissionError(errs.SubtypeMissingScope, "x").WithCause(sentinel)},
|
|
||||||
{"ContentSafetyError", errs.NewContentSafetyError(errs.SubtypeUnknown, "x").WithCause(sentinel)},
|
|
||||||
{"ConfirmationRequiredError", errs.NewConfirmationRequiredError("write", "cmd", "x").WithCause(sentinel)},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name+"_Unwrap_returns_cause", func(t *testing.T) {
|
|
||||||
if got := errors.Unwrap(tc.err); got != sentinel {
|
|
||||||
t.Errorf("Unwrap() = %v, want %v", got, sentinel)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run(tc.name+"_errors.Is_sentinel", func(t *testing.T) {
|
|
||||||
if !errors.Is(tc.err, sentinel) {
|
|
||||||
t.Error("errors.Is(err, sentinel) = false, want true via Unwrap chain")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
t.Run("nil_receiver_Unwrap_safe", func(t *testing.T) {
|
|
||||||
var p *errs.APIError
|
|
||||||
_ = p.Unwrap()
|
|
||||||
var pp *errs.PermissionError
|
|
||||||
_ = pp.Unwrap()
|
|
||||||
var c *errs.ContentSafetyError
|
|
||||||
_ = c.Unwrap()
|
|
||||||
var cr *errs.ConfirmationRequiredError
|
|
||||||
_ = cr.Unwrap()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuilderSetter_DefensiveCopy(t *testing.T) {
|
|
||||||
t.Run("WithMissingScopes clones input", func(t *testing.T) {
|
|
||||||
scopes := []string{"docx:document", "im:message:send"}
|
|
||||||
err := errs.NewPermissionError(errs.SubtypeMissingScope, "test").
|
|
||||||
WithMissingScopes(scopes...)
|
|
||||||
scopes[0] = "MUTATED"
|
|
||||||
if got := err.MissingScopes[0]; got != "docx:document" {
|
|
||||||
t.Errorf("MissingScopes[0] = %q after caller mutation; want defensive copy", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("WithRules clones input", func(t *testing.T) {
|
|
||||||
rules := []string{"rule-A", "rule-B"}
|
|
||||||
err := errs.NewContentSafetyError(errs.SubtypeUnknown, "test").
|
|
||||||
WithRules(rules...)
|
|
||||||
rules[0] = "MUTATED"
|
|
||||||
if got := err.Rules[0]; got != "rule-A" {
|
|
||||||
t.Errorf("Rules[0] = %q after caller mutation; want defensive copy", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
27
errs/wrap.go
27
errs/wrap.go
@@ -1,27 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package errs
|
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
// WrapInternal wraps a non-typed error into *InternalError.
|
|
||||||
// Typed errors (anything implementing problemCarrier) pass through unchanged.
|
|
||||||
// Component is metric-only and derived by the dispatcher, so it is not a parameter here.
|
|
||||||
func WrapInternal(err error) error {
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var c problemCarrier
|
|
||||||
if errors.As(err, &c) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return &InternalError{
|
|
||||||
Problem: Problem{
|
|
||||||
Category: CategoryInternal,
|
|
||||||
Subtype: SubtypeUnknown,
|
|
||||||
Message: err.Error(),
|
|
||||||
},
|
|
||||||
Cause: err,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package errs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWrapInternalPlainError(t *testing.T) {
|
|
||||||
orig := fmt.Errorf("boom")
|
|
||||||
wrapped := WrapInternal(orig)
|
|
||||||
|
|
||||||
var ie *InternalError
|
|
||||||
if !errors.As(wrapped, &ie) {
|
|
||||||
t.Fatalf("WrapInternal did not produce *InternalError; got %T", wrapped)
|
|
||||||
}
|
|
||||||
if ie.Category != CategoryInternal {
|
|
||||||
t.Errorf("Category = %q, want %q", ie.Category, CategoryInternal)
|
|
||||||
}
|
|
||||||
if ie.Subtype != SubtypeUnknown {
|
|
||||||
t.Errorf("Subtype = %q, want %q", ie.Subtype, SubtypeUnknown)
|
|
||||||
}
|
|
||||||
if ie.Message != "boom" {
|
|
||||||
t.Errorf("Message = %q, want %q", ie.Message, "boom")
|
|
||||||
}
|
|
||||||
if ie.Cause != orig {
|
|
||||||
t.Errorf("Cause = %v, want original error %v", ie.Cause, orig)
|
|
||||||
}
|
|
||||||
if got := errors.Unwrap(wrapped); got != orig {
|
|
||||||
t.Errorf("errors.Unwrap = %v, want original %v", got, orig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrapInternalPassesThroughTyped(t *testing.T) {
|
|
||||||
apiErr := &APIError{Problem: Problem{Category: CategoryAPI, Message: "api boom"}}
|
|
||||||
got := WrapInternal(apiErr)
|
|
||||||
if got != apiErr {
|
|
||||||
t.Errorf("WrapInternal should pass through typed errors unchanged; got %#v want %#v", got, apiErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrapInternalNil(t *testing.T) {
|
|
||||||
if got := WrapInternal(nil); got != nil {
|
|
||||||
t.Errorf("WrapInternal(nil) = %v, want nil", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package minutes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/event"
|
|
||||||
"github.com/larksuite/cli/internal/validate"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
minutesDetailRetryDelay = 500 * time.Millisecond
|
|
||||||
minutesDetailMaxRetries = 2
|
|
||||||
)
|
|
||||||
|
|
||||||
// MinutesMinuteSourceOutput is the flattened minute source payload.
|
|
||||||
type MinutesMinuteSourceOutput struct {
|
|
||||||
SourceType string `json:"source_type,omitempty" desc:"Minute source type"`
|
|
||||||
SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MinutesMinuteGeneratedOutput is the flattened shape for minutes.minute.generated_v1.
|
|
||||||
type MinutesMinuteGeneratedOutput struct {
|
|
||||||
Type string `json:"type" desc:"Event type; always minutes.minute.generated_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); taken from header.create_time when present" kind:"timestamp_ms"`
|
|
||||||
MinuteToken string `json:"minute_token,omitempty" desc:"Minute token"`
|
|
||||||
Title string `json:"title,omitempty" desc:"Minute title"`
|
|
||||||
MinuteSource *MinutesMinuteSourceOutput `json:"minute_source,omitempty" desc:"Minute source metadata"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func processMinutesMinuteGenerated(ctx context.Context, rt 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 {
|
|
||||||
MinuteToken string `json:"minute_token"`
|
|
||||||
MinuteSource struct {
|
|
||||||
SourceType string `json:"source_type"`
|
|
||||||
SourceEntityID string `json:"source_entity_id"`
|
|
||||||
} `json:"minute_source"`
|
|
||||||
} `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
|
|
||||||
}
|
|
||||||
|
|
||||||
out := &MinutesMinuteGeneratedOutput{
|
|
||||||
Type: envelope.Header.EventType,
|
|
||||||
EventID: envelope.Header.EventID,
|
|
||||||
Timestamp: envelope.Header.CreateTime,
|
|
||||||
MinuteToken: envelope.Event.MinuteToken,
|
|
||||||
}
|
|
||||||
if out.Type == "" {
|
|
||||||
out.Type = raw.EventType
|
|
||||||
}
|
|
||||||
if src := envelope.Event.MinuteSource; src.SourceType != "" || src.SourceEntityID != "" {
|
|
||||||
out.MinuteSource = &MinutesMinuteSourceOutput{
|
|
||||||
SourceType: src.SourceType,
|
|
||||||
SourceEntityID: src.SourceEntityID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if rt != nil && out.MinuteToken != "" {
|
|
||||||
fillMinutesMinuteGeneratedDetails(ctx, rt, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.Marshal(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fillMinutesMinuteGeneratedDetails(ctx context.Context, rt event.APIClient, out *MinutesMinuteGeneratedOutput) {
|
|
||||||
if rt == nil || out == nil || out.MinuteToken == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
path := fmt.Sprintf(pathMinuteDetailFmt, validate.EncodePathSegment(out.MinuteToken))
|
|
||||||
|
|
||||||
type minuteDetailResp struct {
|
|
||||||
Data struct {
|
|
||||||
Minute struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
} `json:"minute"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
for attempt := 0; attempt <= minutesDetailMaxRetries; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
time.Sleep(minutesDetailRetryDelay)
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := rt.CallAPI(ctx, "GET", path, nil)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp minuteDetailResp
|
|
||||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.Data.Minute.Title == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
out.Title = resp.Data.Minute.Title
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package minutes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/event"
|
|
||||||
"github.com/larksuite/cli/internal/validate"
|
|
||||||
)
|
|
||||||
|
|
||||||
type stubAPIClient struct {
|
|
||||||
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
|
|
||||||
if s.callFn == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return s.callFn(ctx, method, path, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
|
|
||||||
t.Helper()
|
|
||||||
want := map[string]string{"event_type": wantEventType}
|
|
||||||
if !reflect.DeepEqual(gotBody, want) {
|
|
||||||
t.Fatalf("request body = %#v, want %#v", gotBody, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
for _, k := range Keys() {
|
|
||||||
event.RegisterKey(k)
|
|
||||||
}
|
|
||||||
os.Exit(m.Run())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMinutesKeys_ProcessedMinuteGeneratedRegistered(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
def, ok := event.Lookup(eventTypeMinuteGenerated)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
|
|
||||||
}
|
|
||||||
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 def.PreConsume == nil {
|
|
||||||
t.Error("PreConsume must not be nil for processed key")
|
|
||||||
}
|
|
||||||
if len(def.Scopes) != 1 || def.Scopes[0] != "minutes:minutes.basic:read" {
|
|
||||||
t.Errorf("Scopes = %v", def.Scopes)
|
|
||||||
}
|
|
||||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
|
||||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessMinutesMinuteGenerated(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
var gotMethod, gotPath string
|
|
||||||
rt := &stubAPIClient{
|
|
||||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
|
||||||
gotMethod = method
|
|
||||||
gotPath = path
|
|
||||||
if body != nil {
|
|
||||||
t.Fatalf("GET detail body = %#v, want nil", body)
|
|
||||||
}
|
|
||||||
return json.RawMessage(`{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "success",
|
|
||||||
"data": {
|
|
||||||
"minute": {
|
|
||||||
"token": "<doc_token_001>",
|
|
||||||
"title": "产品周会的视频会议",
|
|
||||||
"note_id": "7616590025794260496"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`), nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
out := runMinuteGenerated(t, rt, `{
|
|
||||||
"schema": "2.0",
|
|
||||||
"header": {
|
|
||||||
"event_id": "ev_minute_001",
|
|
||||||
"event_type": "minutes.minute.generated_v1",
|
|
||||||
"create_time": "1608725989000"
|
|
||||||
},
|
|
||||||
"event": {
|
|
||||||
"minute_token": "<doc_token_001>",
|
|
||||||
"minute_source": {
|
|
||||||
"source_type": "meeting",
|
|
||||||
"source_entity_id": "6911188411934433028"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
|
|
||||||
if gotMethod != "GET" {
|
|
||||||
t.Errorf("detail method = %q, want GET", gotMethod)
|
|
||||||
}
|
|
||||||
if gotPath != fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment("<doc_token_001>")) {
|
|
||||||
t.Errorf("detail path = %q", gotPath)
|
|
||||||
}
|
|
||||||
if out.Type != eventTypeMinuteGenerated {
|
|
||||||
t.Errorf("Type = %q", out.Type)
|
|
||||||
}
|
|
||||||
if out.EventID != "ev_minute_001" || out.Timestamp != "1608725989000" {
|
|
||||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
|
||||||
}
|
|
||||||
if out.MinuteToken != "<doc_token_001>" {
|
|
||||||
t.Errorf("MinuteToken = %q", out.MinuteToken)
|
|
||||||
}
|
|
||||||
if out.Title != "产品周会的视频会议" {
|
|
||||||
t.Errorf("Title = %q", out.Title)
|
|
||||||
}
|
|
||||||
if out.MinuteSource == nil {
|
|
||||||
t.Fatal("MinuteSource should not be nil")
|
|
||||||
}
|
|
||||||
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "6911188411934433028" {
|
|
||||||
t.Errorf("MinuteSource = %+v", out.MinuteSource)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessMinutesMinuteGenerated_DetailFailureFallsBackToBaseFields(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
called := 0
|
|
||||||
rt := &stubAPIClient{
|
|
||||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
|
||||||
called++
|
|
||||||
return nil, context.DeadlineExceeded
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
out := runMinuteGenerated(t, rt, `{
|
|
||||||
"schema": "2.0",
|
|
||||||
"header": {
|
|
||||||
"event_id": "ev_minute_002",
|
|
||||||
"event_type": "minutes.minute.generated_v1",
|
|
||||||
"create_time": "1608725989001"
|
|
||||||
},
|
|
||||||
"event": {
|
|
||||||
"minute_token": "<doc_token_004>",
|
|
||||||
"minute_source": {
|
|
||||||
"source_type": "meeting",
|
|
||||||
"source_entity_id": "7641156270787481117"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
|
|
||||||
wantCalls := 1 + minutesDetailMaxRetries
|
|
||||||
if called != wantCalls {
|
|
||||||
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
|
|
||||||
}
|
|
||||||
if out.MinuteToken != "<doc_token_004>" {
|
|
||||||
t.Errorf("MinuteToken = %q", out.MinuteToken)
|
|
||||||
}
|
|
||||||
if out.Title != "" {
|
|
||||||
t.Errorf("Title = %q, want empty", out.Title)
|
|
||||||
}
|
|
||||||
if out.MinuteSource == nil {
|
|
||||||
t.Fatal("MinuteSource should remain from event payload")
|
|
||||||
}
|
|
||||||
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "7641156270787481117" {
|
|
||||||
t.Errorf("MinuteSource = %+v", out.MinuteSource)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessMinutesMinuteGenerated_EmptyTitleRetriesAndSucceeds(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
called := 0
|
|
||||||
rt := &stubAPIClient{
|
|
||||||
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
|
|
||||||
called++
|
|
||||||
if called <= 1 {
|
|
||||||
return json.RawMessage(`{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "success",
|
|
||||||
"data": {
|
|
||||||
"minute": {
|
|
||||||
"title": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`), nil
|
|
||||||
}
|
|
||||||
return json.RawMessage(`{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "success",
|
|
||||||
"data": {
|
|
||||||
"minute": {
|
|
||||||
"title": "delayed title"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`), nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
out := runMinuteGenerated(t, rt, `{
|
|
||||||
"schema": "2.0",
|
|
||||||
"header": {
|
|
||||||
"event_id": "ev_minute_retry",
|
|
||||||
"event_type": "minutes.minute.generated_v1",
|
|
||||||
"create_time": "1608725989000"
|
|
||||||
},
|
|
||||||
"event": {
|
|
||||||
"minute_token": "<doc_token_003>"
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
|
|
||||||
if called != 2 {
|
|
||||||
t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called)
|
|
||||||
}
|
|
||||||
if out.Title != "delayed title" {
|
|
||||||
t.Errorf("Title = %q, want delayed title", out.Title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessMinutesMinuteGenerated_EmptyTitleExhaustsRetries(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
called := 0
|
|
||||||
rt := &stubAPIClient{
|
|
||||||
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
|
|
||||||
called++
|
|
||||||
return json.RawMessage(`{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "success",
|
|
||||||
"data": {
|
|
||||||
"minute": {
|
|
||||||
"title": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`), nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
out := runMinuteGenerated(t, rt, `{
|
|
||||||
"schema": "2.0",
|
|
||||||
"header": {
|
|
||||||
"event_id": "ev_minute_exhaust",
|
|
||||||
"event_type": "minutes.minute.generated_v1",
|
|
||||||
"create_time": "1608725989000"
|
|
||||||
},
|
|
||||||
"event": {
|
|
||||||
"minute_token": "<doc_token_002>"
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
|
|
||||||
wantCalls := 1 + minutesDetailMaxRetries
|
|
||||||
if called != wantCalls {
|
|
||||||
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
|
|
||||||
}
|
|
||||||
if out.Title != "" {
|
|
||||||
t.Errorf("Title = %q, want empty after exhausted retries", out.Title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMinutesMinuteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
def, ok := event.Lookup(eventTypeMinuteGenerated)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
|
|
||||||
}
|
|
||||||
|
|
||||||
type call struct {
|
|
||||||
method string
|
|
||||||
path string
|
|
||||||
body any
|
|
||||||
}
|
|
||||||
var calls []call
|
|
||||||
rt := &stubAPIClient{
|
|
||||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
|
||||||
calls = append(calls, call{method: method, path: path, body: body})
|
|
||||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PreConsume error: %v", err)
|
|
||||||
}
|
|
||||||
if cleanup == nil {
|
|
||||||
t.Fatal("cleanup must not be nil")
|
|
||||||
}
|
|
||||||
if len(calls) != 1 {
|
|
||||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
|
||||||
}
|
|
||||||
if calls[0].method != "POST" || calls[0].path != pathMinuteSubscribe {
|
|
||||||
t.Fatalf("subscribe call = %+v", calls[0])
|
|
||||||
}
|
|
||||||
assertSubscriptionRequest(t, calls[0].body, eventTypeMinuteGenerated)
|
|
||||||
|
|
||||||
cleanup()
|
|
||||||
if len(calls) != 2 {
|
|
||||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
|
||||||
}
|
|
||||||
if calls[1].method != "POST" || calls[1].path != pathMinuteUnsubscribe {
|
|
||||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
|
||||||
}
|
|
||||||
assertSubscriptionRequest(t, calls[1].body, eventTypeMinuteGenerated)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
raw := &event.RawEvent{
|
|
||||||
EventType: eventTypeMinuteGenerated,
|
|
||||||
Payload: json.RawMessage(`not json`),
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
got, err := processMinutesMinuteGenerated(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 runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) MinutesMinuteGeneratedOutput {
|
|
||||||
t.Helper()
|
|
||||||
raw := &event.RawEvent{
|
|
||||||
EventType: eventTypeMinuteGenerated,
|
|
||||||
Payload: json.RawMessage(payload),
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
got, err := processMinutesMinuteGenerated(context.Background(), rt, raw, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Process error: %v", err)
|
|
||||||
}
|
|
||||||
var out MinutesMinuteGeneratedOutput
|
|
||||||
if err := json.Unmarshal(got, &out); err != nil {
|
|
||||||
t.Fatalf("Process output is not valid MinutesMinuteGeneratedOutput JSON: %v\nraw=%s", err, string(got))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package minutes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/event"
|
|
||||||
)
|
|
||||||
|
|
||||||
const cleanupTimeout = 5 * time.Second
|
|
||||||
|
|
||||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
|
||||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
|
||||||
if rt == nil {
|
|
||||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
|
||||||
}
|
|
||||||
|
|
||||||
body := map[string]string{"event_type": eventType}
|
|
||||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
|
||||||
defer cancel()
|
|
||||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
// Package minutes registers Minutes-domain EventKeys.
|
|
||||||
package minutes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/event"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
eventTypeMinuteGenerated = "minutes.minute.generated_v1"
|
|
||||||
|
|
||||||
pathMinuteSubscribe = "/open-apis/minutes/v1/minutes/subscription"
|
|
||||||
pathMinuteUnsubscribe = "/open-apis/minutes/v1/minutes/unsubscription"
|
|
||||||
|
|
||||||
pathMinuteDetailFmt = "/open-apis/minutes/v1/minutes/%s"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Keys returns all Minutes-domain EventKey definitions.
|
|
||||||
func Keys() []event.KeyDefinition {
|
|
||||||
return []event.KeyDefinition{
|
|
||||||
{
|
|
||||||
Key: eventTypeMinuteGenerated,
|
|
||||||
DisplayName: "Minute generated",
|
|
||||||
Description: "Triggered when a minute has been generated",
|
|
||||||
EventType: eventTypeMinuteGenerated,
|
|
||||||
Schema: event.SchemaDef{
|
|
||||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})},
|
|
||||||
},
|
|
||||||
Process: processMinutesMinuteGenerated,
|
|
||||||
PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe),
|
|
||||||
Scopes: []string{"minutes:minutes.basic:read"},
|
|
||||||
AuthTypes: []string{
|
|
||||||
"user",
|
|
||||||
},
|
|
||||||
RequiredConsoleEvents: []string{eventTypeMinuteGenerated},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,17 +6,13 @@ package events
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/larksuite/cli/events/im"
|
"github.com/larksuite/cli/events/im"
|
||||||
"github.com/larksuite/cli/events/minutes"
|
|
||||||
"github.com/larksuite/cli/events/vc"
|
|
||||||
"github.com/larksuite/cli/internal/event"
|
"github.com/larksuite/cli/internal/event"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mail is intentionally omitted in this phase.
|
// Mail is intentionally omitted: only IM is wired up this phase.
|
||||||
func init() {
|
func init() {
|
||||||
all := [][]event.KeyDefinition{
|
all := [][]event.KeyDefinition{
|
||||||
im.Keys(),
|
im.Keys(),
|
||||||
minutes.Keys(),
|
|
||||||
vc.Keys(),
|
|
||||||
}
|
}
|
||||||
for _, keys := range all {
|
for _, keys := range all {
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package vc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/event"
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/larksuite/cli/internal/validate"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
vcNoteArtifactTypeNote = 1
|
|
||||||
vcNoteArtifactTypeVerbatim = 2
|
|
||||||
|
|
||||||
vcNoteDetailRetryDelay = 500 * time.Millisecond
|
|
||||||
vcNoteDetailMaxRetries = 2
|
|
||||||
vcNoteDetailNotFoundCode = 121004
|
|
||||||
)
|
|
||||||
|
|
||||||
// VCNoteSourceOutput is the flattened note source payload.
|
|
||||||
type VCNoteSourceOutput struct {
|
|
||||||
SourceType string `json:"source_type,omitempty" desc:"Note source type"`
|
|
||||||
SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// VCNoteGeneratedOutput is the flattened shape for vc.note.generated_v1.
|
|
||||||
type VCNoteGeneratedOutput struct {
|
|
||||||
Type string `json:"type" desc:"Event type; always vc.note.generated_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); taken from header.create_time when present" kind:"timestamp_ms"`
|
|
||||||
NoteID string `json:"note_id,omitempty" desc:"Note ID"`
|
|
||||||
NoteToken string `json:"note_token,omitempty" desc:"Generated note document token"`
|
|
||||||
VerbatimToken string `json:"verbatim_token,omitempty" desc:"Generated verbatim document token"`
|
|
||||||
NoteSource *VCNoteSourceOutput `json:"note_source,omitempty" desc:"Note source metadata"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func processVCNoteGenerated(ctx context.Context, rt 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 {
|
|
||||||
NoteID string `json:"note_id"`
|
|
||||||
} `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
|
|
||||||
}
|
|
||||||
|
|
||||||
out := &VCNoteGeneratedOutput{
|
|
||||||
Type: envelope.Header.EventType,
|
|
||||||
EventID: envelope.Header.EventID,
|
|
||||||
Timestamp: envelope.Header.CreateTime,
|
|
||||||
NoteID: envelope.Event.NoteID,
|
|
||||||
}
|
|
||||||
if out.Type == "" {
|
|
||||||
out.Type = raw.EventType
|
|
||||||
}
|
|
||||||
|
|
||||||
if rt != nil && out.NoteID != "" {
|
|
||||||
fillVCNoteGeneratedDetails(ctx, rt, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.Marshal(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VCNoteGeneratedOutput) {
|
|
||||||
if rt == nil || out == nil || out.NoteID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
path := fmt.Sprintf(pathNoteDetailFmt, validate.EncodePathSegment(out.NoteID))
|
|
||||||
|
|
||||||
type noteDetailResp struct {
|
|
||||||
Data struct {
|
|
||||||
Note struct {
|
|
||||||
Artifacts []struct {
|
|
||||||
ArtifactType int `json:"artifact_type"`
|
|
||||||
DocToken string `json:"doc_token"`
|
|
||||||
} `json:"artifacts"`
|
|
||||||
NoteSource struct {
|
|
||||||
SourceEntityID string `json:"source_entity_id"`
|
|
||||||
SourceType string `json:"source_type"`
|
|
||||||
} `json:"note_source"`
|
|
||||||
} `json:"note"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
for attempt := 0; attempt <= vcNoteDetailMaxRetries; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
time.Sleep(vcNoteDetailRetryDelay)
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := rt.CallAPI(ctx, "GET", path, nil)
|
|
||||||
if err != nil {
|
|
||||||
if isLarkCode(err, vcNoteDetailNotFoundCode) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp noteDetailResp
|
|
||||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var noteToken, verbatimToken string
|
|
||||||
for _, artifact := range resp.Data.Note.Artifacts {
|
|
||||||
switch artifact.ArtifactType {
|
|
||||||
case vcNoteArtifactTypeNote:
|
|
||||||
if noteToken == "" {
|
|
||||||
noteToken = artifact.DocToken
|
|
||||||
}
|
|
||||||
case vcNoteArtifactTypeVerbatim:
|
|
||||||
if verbatimToken == "" {
|
|
||||||
verbatimToken = artifact.DocToken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if noteToken == "" && verbatimToken == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if noteToken != "" {
|
|
||||||
out.NoteToken = noteToken
|
|
||||||
}
|
|
||||||
if verbatimToken != "" {
|
|
||||||
out.VerbatimToken = verbatimToken
|
|
||||||
}
|
|
||||||
if src := resp.Data.Note.NoteSource; src.SourceType != "" || src.SourceEntityID != "" {
|
|
||||||
out.NoteSource = &VCNoteSourceOutput{
|
|
||||||
SourceType: src.SourceType,
|
|
||||||
SourceEntityID: src.SourceEntityID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isLarkCode(err error, code int) bool {
|
|
||||||
var exitErr *output.ExitError
|
|
||||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
|
||||||
return exitErr.Detail.Code == code
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package vc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/event"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestVCKeys_ProcessedNoteGeneratedRegistered(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
def, ok := event.Lookup(eventTypeNoteGenerated)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated)
|
|
||||||
}
|
|
||||||
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 def.PreConsume == nil {
|
|
||||||
t.Error("PreConsume must not be nil for processed key")
|
|
||||||
}
|
|
||||||
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:note:read" {
|
|
||||||
t.Errorf("Scopes = %v", def.Scopes)
|
|
||||||
}
|
|
||||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
|
||||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessVCNoteGenerated(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
var gotMethod, gotPath string
|
|
||||||
rt := &stubAPIClient{
|
|
||||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
|
||||||
gotMethod = method
|
|
||||||
gotPath = path
|
|
||||||
if body != nil {
|
|
||||||
t.Fatalf("GET detail body = %#v, want nil", body)
|
|
||||||
}
|
|
||||||
return json.RawMessage(`{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "success",
|
|
||||||
"data": {
|
|
||||||
"note": {
|
|
||||||
"artifacts": [
|
|
||||||
{"artifact_type": 1, "doc_token": "note_doc_token"},
|
|
||||||
{"artifact_type": 2, "doc_token": "verbatim_doc_token"}
|
|
||||||
],
|
|
||||||
"note_source": {
|
|
||||||
"source_type": "meeting",
|
|
||||||
"source_entity_id": "6911188411934433028"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`), nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
out := runNoteGenerated(t, rt, `{
|
|
||||||
"schema": "2.0",
|
|
||||||
"header": {
|
|
||||||
"event_id": "ev_vc_note_001",
|
|
||||||
"event_type": "vc.note.generated_v1",
|
|
||||||
"create_time": "1608725989000"
|
|
||||||
},
|
|
||||||
"event": {
|
|
||||||
"note_id": "6943848821689040898"
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
|
|
||||||
if gotMethod != "GET" {
|
|
||||||
t.Errorf("detail method = %q, want GET", gotMethod)
|
|
||||||
}
|
|
||||||
if gotPath != "/open-apis/vc/v1/notes/6943848821689040898" {
|
|
||||||
t.Errorf("detail path = %q", gotPath)
|
|
||||||
}
|
|
||||||
if out.Type != eventTypeNoteGenerated {
|
|
||||||
t.Errorf("Type = %q", out.Type)
|
|
||||||
}
|
|
||||||
if out.EventID != "ev_vc_note_001" || out.Timestamp != "1608725989000" {
|
|
||||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
|
||||||
}
|
|
||||||
if out.NoteID != "6943848821689040898" {
|
|
||||||
t.Errorf("NoteID = %q", out.NoteID)
|
|
||||||
}
|
|
||||||
if out.NoteToken != "note_doc_token" {
|
|
||||||
t.Errorf("NoteToken = %q", out.NoteToken)
|
|
||||||
}
|
|
||||||
if out.VerbatimToken != "verbatim_doc_token" {
|
|
||||||
t.Errorf("VerbatimToken = %q", out.VerbatimToken)
|
|
||||||
}
|
|
||||||
if out.NoteSource == nil {
|
|
||||||
t.Fatal("NoteSource should not be nil")
|
|
||||||
}
|
|
||||||
if out.NoteSource.SourceType != "meeting" || out.NoteSource.SourceEntityID != "6911188411934433028" {
|
|
||||||
t.Errorf("NoteSource = %+v", out.NoteSource)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVCNoteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
def, ok := event.Lookup(eventTypeNoteGenerated)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated)
|
|
||||||
}
|
|
||||||
|
|
||||||
type call struct {
|
|
||||||
method string
|
|
||||||
path string
|
|
||||||
body any
|
|
||||||
}
|
|
||||||
var calls []call
|
|
||||||
rt := &stubAPIClient{
|
|
||||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
|
||||||
calls = append(calls, call{method: method, path: path, body: body})
|
|
||||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PreConsume error: %v", err)
|
|
||||||
}
|
|
||||||
if cleanup == nil {
|
|
||||||
t.Fatal("cleanup must not be nil")
|
|
||||||
}
|
|
||||||
if len(calls) != 1 {
|
|
||||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
|
||||||
}
|
|
||||||
if calls[0].method != "POST" || calls[0].path != pathNoteSubscribe {
|
|
||||||
t.Fatalf("subscribe call = %+v", calls[0])
|
|
||||||
}
|
|
||||||
assertSubscriptionRequest(t, calls[0].body, eventTypeNoteGenerated)
|
|
||||||
|
|
||||||
cleanup()
|
|
||||||
if len(calls) != 2 {
|
|
||||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
|
||||||
}
|
|
||||||
if calls[1].method != "POST" || calls[1].path != pathNoteUnsubscribe {
|
|
||||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
|
||||||
}
|
|
||||||
assertSubscriptionRequest(t, calls[1].body, eventTypeNoteGenerated)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessVCNoteGenerated_DetailFailureFallsBackToBaseFields(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
called := 0
|
|
||||||
rt := &stubAPIClient{
|
|
||||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
|
||||||
called++
|
|
||||||
return nil, context.DeadlineExceeded
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
out := runNoteGenerated(t, rt, `{
|
|
||||||
"schema": "2.0",
|
|
||||||
"header": {
|
|
||||||
"event_id": "ev_vc_note_002",
|
|
||||||
"event_type": "vc.note.generated_v1",
|
|
||||||
"create_time": "1608725989001"
|
|
||||||
},
|
|
||||||
"event": {
|
|
||||||
"note_id": "6943848821689040999"
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
|
|
||||||
if called != 1 {
|
|
||||||
t.Fatalf("detail API called %d times, want 1", called)
|
|
||||||
}
|
|
||||||
if out.NoteID != "6943848821689040999" {
|
|
||||||
t.Errorf("NoteID = %q", out.NoteID)
|
|
||||||
}
|
|
||||||
if out.NoteToken != "" || out.VerbatimToken != "" {
|
|
||||||
t.Errorf("NoteToken/VerbatimToken = %q/%q, want empty", out.NoteToken, out.VerbatimToken)
|
|
||||||
}
|
|
||||||
if out.NoteSource != nil {
|
|
||||||
t.Errorf("NoteSource = %+v, want nil", out.NoteSource)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessVCNoteGenerated_EmptyTokensRetriesAndSucceeds(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
called := 0
|
|
||||||
rt := &stubAPIClient{
|
|
||||||
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
|
|
||||||
called++
|
|
||||||
if called <= 1 {
|
|
||||||
return json.RawMessage(`{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "success",
|
|
||||||
"data": {
|
|
||||||
"note": {
|
|
||||||
"artifacts": [],
|
|
||||||
"note_source": {"source_type": "meeting", "source_entity_id": "123"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`), nil
|
|
||||||
}
|
|
||||||
return json.RawMessage(`{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "success",
|
|
||||||
"data": {
|
|
||||||
"note": {
|
|
||||||
"artifacts": [
|
|
||||||
{"artifact_type": 1, "doc_token": "delayed_note_token"},
|
|
||||||
{"artifact_type": 2, "doc_token": "delayed_verbatim_token"}
|
|
||||||
],
|
|
||||||
"note_source": {"source_type": "meeting", "source_entity_id": "123"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`), nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
out := runNoteGenerated(t, rt, `{
|
|
||||||
"schema": "2.0",
|
|
||||||
"header": {
|
|
||||||
"event_id": "ev_vc_note_empty_retry",
|
|
||||||
"event_type": "vc.note.generated_v1",
|
|
||||||
"create_time": "1608725989000"
|
|
||||||
},
|
|
||||||
"event": {
|
|
||||||
"note_id": "6943848821689040empty"
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
|
|
||||||
if called != 2 {
|
|
||||||
t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called)
|
|
||||||
}
|
|
||||||
if out.NoteToken != "delayed_note_token" {
|
|
||||||
t.Errorf("NoteToken = %q, want delayed_note_token", out.NoteToken)
|
|
||||||
}
|
|
||||||
if out.VerbatimToken != "delayed_verbatim_token" {
|
|
||||||
t.Errorf("VerbatimToken = %q, want delayed_verbatim_token", out.VerbatimToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessVCNoteGenerated_EmptyTokensExhaustsRetries(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
called := 0
|
|
||||||
rt := &stubAPIClient{
|
|
||||||
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
|
|
||||||
called++
|
|
||||||
return json.RawMessage(`{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "success",
|
|
||||||
"data": {
|
|
||||||
"note": {
|
|
||||||
"artifacts": [],
|
|
||||||
"note_source": {"source_type": "meeting", "source_entity_id": "123"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`), nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
out := runNoteGenerated(t, rt, `{
|
|
||||||
"schema": "2.0",
|
|
||||||
"header": {
|
|
||||||
"event_id": "ev_vc_note_empty_exhaust",
|
|
||||||
"event_type": "vc.note.generated_v1",
|
|
||||||
"create_time": "1608725989000"
|
|
||||||
},
|
|
||||||
"event": {
|
|
||||||
"note_id": "6943848821689040emptyex"
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
|
|
||||||
wantCalls := 1 + vcNoteDetailMaxRetries
|
|
||||||
if called != wantCalls {
|
|
||||||
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
|
|
||||||
}
|
|
||||||
if out.NoteToken != "" || out.VerbatimToken != "" {
|
|
||||||
t.Errorf("NoteToken/VerbatimToken = %q/%q, want empty after exhausted retries", out.NoteToken, out.VerbatimToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessVCNoteGenerated_MalformedPayload(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
raw := &event.RawEvent{
|
|
||||||
EventType: eventTypeNoteGenerated,
|
|
||||||
Payload: json.RawMessage(`not json`),
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
got, err := processVCNoteGenerated(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 runNoteGenerated(t *testing.T, rt event.APIClient, payload string) VCNoteGeneratedOutput {
|
|
||||||
t.Helper()
|
|
||||||
raw := &event.RawEvent{
|
|
||||||
EventType: eventTypeNoteGenerated,
|
|
||||||
Payload: json.RawMessage(payload),
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
got, err := processVCNoteGenerated(context.Background(), rt, raw, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Process error: %v", err)
|
|
||||||
}
|
|
||||||
var out VCNoteGeneratedOutput
|
|
||||||
if err := json.Unmarshal(got, &out); err != nil {
|
|
||||||
t.Fatalf("Process output is not valid VCNoteGeneratedOutput JSON: %v\nraw=%s", err, string(got))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package vc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/event"
|
|
||||||
)
|
|
||||||
|
|
||||||
// VCParticipantMeetingEndedOutput is the flattened shape for vc.meeting.participant_meeting_ended_v1.
|
|
||||||
type VCParticipantMeetingEndedOutput struct {
|
|
||||||
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_ended_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); taken from header.create_time when present" kind:"timestamp_ms"`
|
|
||||||
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
|
||||||
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
|
||||||
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
|
||||||
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
|
||||||
EndTime string `json:"end_time,omitempty" desc:"Meeting end time in RFC3339, converted to the local timezone"`
|
|
||||||
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func processVCParticipantMeetingEnded(_ 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 {
|
|
||||||
Meeting struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Topic string `json:"topic"`
|
|
||||||
MeetingNo string `json:"meeting_no"`
|
|
||||||
StartTime string `json:"start_time"`
|
|
||||||
EndTime string `json:"end_time"`
|
|
||||||
CalendarEventID string `json:"calendar_event_id"`
|
|
||||||
} `json:"meeting"`
|
|
||||||
} `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
|
|
||||||
}
|
|
||||||
|
|
||||||
meeting := envelope.Event.Meeting
|
|
||||||
out := &VCParticipantMeetingEndedOutput{
|
|
||||||
Type: envelope.Header.EventType,
|
|
||||||
EventID: envelope.Header.EventID,
|
|
||||||
Timestamp: envelope.Header.CreateTime,
|
|
||||||
MeetingID: meeting.ID,
|
|
||||||
Topic: meeting.Topic,
|
|
||||||
MeetingNo: meeting.MeetingNo,
|
|
||||||
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
|
||||||
EndTime: unixSecondsToLocalRFC3339(meeting.EndTime),
|
|
||||||
CalendarEventID: meeting.CalendarEventID,
|
|
||||||
}
|
|
||||||
if out.Type == "" {
|
|
||||||
out.Type = raw.EventType
|
|
||||||
}
|
|
||||||
return json.Marshal(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func unixSecondsToLocalRFC3339(raw string) string {
|
|
||||||
if raw == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
secs, err := strconv.ParseInt(raw, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return time.Unix(secs, 0).Local().Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package vc
|
|
||||||
|
|
||||||
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 TestVCKeys_ProcessedMeetingEndedRegistered(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
def, ok := event.Lookup(eventTypeMeetingEnded)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("%s should be registered via Keys()", eventTypeMeetingEnded)
|
|
||||||
}
|
|
||||||
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 def.PreConsume == nil {
|
|
||||||
t.Error("PreConsume must not be nil for processed key")
|
|
||||||
}
|
|
||||||
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
|
|
||||||
t.Errorf("Scopes = %v", def.Scopes)
|
|
||||||
}
|
|
||||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
|
||||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessVCParticipantMeetingEnded(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
payload := `{
|
|
||||||
"schema": "2.0",
|
|
||||||
"header": {
|
|
||||||
"event_id": "ev_vc_end_001",
|
|
||||||
"event_type": "vc.meeting.participant_meeting_ended_v1",
|
|
||||||
"create_time": "1608725989000",
|
|
||||||
"app_id": "cli_test"
|
|
||||||
},
|
|
||||||
"event": {
|
|
||||||
"meeting": {
|
|
||||||
"id": "6911188411934433028",
|
|
||||||
"topic": "my meeting",
|
|
||||||
"meeting_no": "235812466",
|
|
||||||
"start_time": "1608883322",
|
|
||||||
"end_time": "1608883899",
|
|
||||||
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
out := runMeetingEnded(t, payload)
|
|
||||||
|
|
||||||
if out.Type != eventTypeMeetingEnded {
|
|
||||||
t.Errorf("Type = %q", out.Type)
|
|
||||||
}
|
|
||||||
if out.EventID != "ev_vc_end_001" {
|
|
||||||
t.Errorf("EventID = %q", out.EventID)
|
|
||||||
}
|
|
||||||
if out.Timestamp != "1608725989000" {
|
|
||||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
|
||||||
}
|
|
||||||
if out.MeetingID != "6911188411934433028" {
|
|
||||||
t.Errorf("MeetingID = %q", out.MeetingID)
|
|
||||||
}
|
|
||||||
if out.Topic != "my meeting" || out.MeetingNo != "235812466" {
|
|
||||||
t.Errorf("Topic/MeetingNo = %q/%q", out.Topic, out.MeetingNo)
|
|
||||||
}
|
|
||||||
if out.CalendarEventID != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
|
|
||||||
t.Errorf("CalendarEventID = %q", out.CalendarEventID)
|
|
||||||
}
|
|
||||||
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out.StartTime != want {
|
|
||||||
t.Errorf("StartTime = %q, want %q", out.StartTime, want)
|
|
||||||
}
|
|
||||||
if want := time.Unix(1608883899, 0).Local().Format(time.RFC3339); out.EndTime != want {
|
|
||||||
t.Errorf("EndTime = %q, want %q", out.EndTime, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessVCParticipantMeetingEnded_InvalidMeetingTimes(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
payload := `{
|
|
||||||
"schema": "2.0",
|
|
||||||
"header": {
|
|
||||||
"event_id": "ev_vc_end_002",
|
|
||||||
"event_type": "vc.meeting.participant_meeting_ended_v1",
|
|
||||||
"create_time": "1608725989001"
|
|
||||||
},
|
|
||||||
"event": {
|
|
||||||
"meeting": {
|
|
||||||
"id": "meeting_invalid_time",
|
|
||||||
"start_time": "bad",
|
|
||||||
"end_time": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
out := runMeetingEnded(t, payload)
|
|
||||||
if out.StartTime != "" || out.EndTime != "" {
|
|
||||||
t.Errorf("StartTime/EndTime = %q/%q, want empty strings", out.StartTime, out.EndTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
raw := &event.RawEvent{
|
|
||||||
EventType: eventTypeMeetingEnded,
|
|
||||||
Payload: json.RawMessage(`not json`),
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
got, err := processVCParticipantMeetingEnded(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 TestVCParticipantMeetingEnded_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
def, ok := event.Lookup("vc.meeting.participant_meeting_ended_v1")
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("vc.meeting.participant_meeting_ended_v1 should be registered via Keys()")
|
|
||||||
}
|
|
||||||
|
|
||||||
type call struct {
|
|
||||||
method string
|
|
||||||
path string
|
|
||||||
body any
|
|
||||||
}
|
|
||||||
var calls []call
|
|
||||||
rt := &stubAPIClient{
|
|
||||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
|
||||||
calls = append(calls, call{method: method, path: path, body: body})
|
|
||||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PreConsume error: %v", err)
|
|
||||||
}
|
|
||||||
if cleanup == nil {
|
|
||||||
t.Fatal("cleanup must not be nil")
|
|
||||||
}
|
|
||||||
if len(calls) != 1 {
|
|
||||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
|
||||||
}
|
|
||||||
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
|
|
||||||
t.Fatalf("subscribe call = %+v", calls[0])
|
|
||||||
}
|
|
||||||
assertSubscriptionRequest(t, calls[0].body, eventTypeMeetingEnded)
|
|
||||||
|
|
||||||
cleanup()
|
|
||||||
if len(calls) != 2 {
|
|
||||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
|
||||||
}
|
|
||||||
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
|
|
||||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
|
||||||
}
|
|
||||||
assertSubscriptionRequest(t, calls[1].body, eventTypeMeetingEnded)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMeetingEnded(t *testing.T, payload string) VCParticipantMeetingEndedOutput {
|
|
||||||
t.Helper()
|
|
||||||
raw := &event.RawEvent{
|
|
||||||
EventType: eventTypeMeetingEnded,
|
|
||||||
Payload: json.RawMessage(payload),
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Process error: %v", err)
|
|
||||||
}
|
|
||||||
var out VCParticipantMeetingEndedOutput
|
|
||||||
if err := json.Unmarshal(got, &out); err != nil {
|
|
||||||
t.Fatalf("Process output is not valid VCParticipantMeetingEndedOutput JSON: %v\nraw=%s", err, string(got))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package vc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/event"
|
|
||||||
)
|
|
||||||
|
|
||||||
const cleanupTimeout = 5 * time.Second
|
|
||||||
|
|
||||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
|
||||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
|
||||||
if rt == nil {
|
|
||||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
|
||||||
}
|
|
||||||
|
|
||||||
body := map[string]string{"event_type": eventType}
|
|
||||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
|
||||||
defer cancel()
|
|
||||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user