mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 02:00:19 +08:00
Compare commits
155 Commits
v1.0.27
...
feat/conta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
296b34a501 | ||
|
|
a2cc5e124e | ||
|
|
a2dde84158 | ||
|
|
21998b9ca8 | ||
|
|
ce2abff8ae | ||
|
|
893555a1b1 | ||
|
|
8d496b8a48 | ||
|
|
01fe71d7db | ||
|
|
3b770558e5 | ||
|
|
3cd84fca90 | ||
|
|
c2e737434c | ||
|
|
b91f6a23f3 | ||
|
|
bbef3cbfb1 | ||
|
|
cdae999541 | ||
|
|
36ff632a13 | ||
|
|
ab94ee9f54 | ||
|
|
30327abacb | ||
|
|
70081f62b1 | ||
|
|
17cbc13fcb | ||
|
|
e98471ce26 | ||
|
|
9e2be14301 | ||
|
|
367cfc9d06 | ||
|
|
e182b01f68 | ||
|
|
1135fc2767 | ||
|
|
68d78d5067 | ||
|
|
b783561965 | ||
|
|
f00261da9f | ||
|
|
137176e8b0 | ||
|
|
0bf590d01a | ||
|
|
cf40945bbc | ||
|
|
b9e5b50251 | ||
|
|
049ddf771b | ||
|
|
f12d279fc2 | ||
|
|
83adbac2b2 | ||
|
|
ee9d090e64 | ||
|
|
fe72e41fb2 | ||
|
|
877fbe6d47 | ||
|
|
e93e2a98e1 | ||
|
|
0dda56914d | ||
|
|
8bc4ec3fff | ||
|
|
06a3921f40 | ||
|
|
b25ff1ced5 | ||
|
|
aea9f37f58 | ||
|
|
ac06eaa0f4 | ||
|
|
282c27784d | ||
|
|
f2a4c95665 | ||
|
|
cb5055eb46 | ||
|
|
9d4233bfe3 | ||
|
|
708cbc2b31 | ||
|
|
6d1f9980fa | ||
|
|
6e3e120ec8 | ||
|
|
ce5b4f24e1 | ||
|
|
4b2223194b | ||
|
|
4582dfd281 | ||
|
|
5c01a7f7f0 | ||
|
|
d5d2fee848 | ||
|
|
ffcf7781b4 | ||
|
|
fbe4cc689a | ||
|
|
ac85c3e34d | ||
|
|
daba3c9afd | ||
|
|
e54220ade1 | ||
|
|
d3fbc88527 | ||
|
|
652e96906c | ||
|
|
6cea6c9af0 | ||
|
|
816927f8b8 | ||
|
|
56749e70cb | ||
|
|
8c700aea00 | ||
|
|
42746d6c9d | ||
|
|
94b103dbf6 | ||
|
|
e19e09019c | ||
|
|
3bab9a0692 | ||
|
|
6840bb7415 | ||
|
|
ce485eb3f5 | ||
|
|
c98a49f2a3 | ||
|
|
c02a38f077 | ||
|
|
3a3fc31d0b | ||
|
|
8c73f49e91 | ||
|
|
9272b9da99 | ||
|
|
27a5eeddcc | ||
|
|
0c4eadd41e | ||
|
|
69c34481f5 | ||
|
|
fa45e1c7e4 | ||
|
|
d793790807 | ||
|
|
13411d9a51 | ||
|
|
939b7b6fb6 | ||
|
|
a4c5ec99c8 | ||
|
|
7c54f9b023 | ||
|
|
e6bc292575 | ||
|
|
4aa61db8b2 | ||
|
|
28c66be199 | ||
|
|
0e70b056f8 | ||
|
|
95ffff4212 | ||
|
|
e511404065 | ||
|
|
b8469d2dc6 | ||
|
|
afa084e7a4 | ||
|
|
3354494579 | ||
|
|
2bb69d1942 | ||
|
|
c4fb7006d2 | ||
|
|
583349e572 | ||
|
|
315e0ab50c | ||
|
|
ef89d1fd40 | ||
|
|
c8b9809f96 | ||
|
|
de00343063 | ||
|
|
67b16c5ec3 | ||
|
|
7af616b9e5 | ||
|
|
df4b657737 | ||
|
|
4b721c0410 | ||
|
|
241952459d | ||
|
|
33c292c05e | ||
|
|
ca6c6c3e29 | ||
|
|
7bad9f2656 | ||
|
|
898e0eebfd | ||
|
|
0b7215637f | ||
|
|
14a3213038 | ||
|
|
caff780c17 | ||
|
|
5778adfefa | ||
|
|
7400226e34 | ||
|
|
4a45e00139 | ||
|
|
f03138b9f0 | ||
|
|
ed9eecf94f | ||
|
|
f49a2f7e14 | ||
|
|
a93fb2d6b3 | ||
|
|
7acf64c3ef | ||
|
|
52e0129078 | ||
|
|
8a8dff47ce | ||
|
|
1c2d3d7679 | ||
|
|
0d20f88453 | ||
|
|
b0bd9b0258 | ||
|
|
ba6edb84e4 | ||
|
|
a54a879330 | ||
|
|
a27c636131 | ||
|
|
37459b60ec | ||
|
|
f1aa7d8f42 | ||
|
|
a18504b1f9 | ||
|
|
5e0ac02f08 | ||
|
|
b0c9a4d74e | ||
|
|
ddc24fec90 | ||
|
|
25454f498b | ||
|
|
62ff3d66a6 | ||
|
|
ce0b68dc0e | ||
|
|
cc16c4d2d7 | ||
|
|
1ee7f22ee5 | ||
|
|
b612dde19e | ||
|
|
4181174352 | ||
|
|
1180baac61 | ||
|
|
db1a3fc0a6 | ||
|
|
7c6abb3834 | ||
|
|
4c63198237 | ||
|
|
c0fbe54ef6 | ||
|
|
4ba39ef392 | ||
|
|
25c72ced6f | ||
|
|
0ed63b02e4 | ||
|
|
5352e6a90a | ||
|
|
16f1a0f320 | ||
|
|
4d625420b0 |
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -9,7 +9,7 @@
|
||||
## Test Plan
|
||||
<!-- Describe how this change was verified. -->
|
||||
- [ ] Unit tests pass
|
||||
- [ ] Manual local verification confirms the `lark xxx` command works as expected
|
||||
- [ ] Manual local verification confirms the `lark-cli <domain> <command>` flow works as expected
|
||||
|
||||
## Related Issues
|
||||
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
lint:
|
||||
needs: fast-gate
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
# Build output
|
||||
/lark-cli
|
||||
/lark-cli*
|
||||
.cache/
|
||||
dist/
|
||||
bin/
|
||||
@@ -34,8 +34,13 @@ tests/mail/reports/
|
||||
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
.tmp/
|
||||
cover*.out
|
||||
|
||||
lark-env.sh
|
||||
|
||||
@@ -14,3 +14,4 @@ id = "lark-session-token"
|
||||
description = "Detect Lark session tokens"
|
||||
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
|
||||
keywords = ["XN0YXJ0-", "-WVuZA"]
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ linters:
|
||||
- path: _test\.go$
|
||||
linters:
|
||||
- bodyclose
|
||||
- bidichk
|
||||
- gocritic
|
||||
- depguard
|
||||
- forbidigo
|
||||
|
||||
305
CHANGELOG.md
305
CHANGELOG.md
@@ -2,6 +2,295 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Switch markdown export to V2 `docs_ai` fetch API (#948)
|
||||
- **drive**: Add `+inspect` shortcut for document URL inspection with wiki unwrapping (#947)
|
||||
- **wiki**: Add `+node-get` / `+node-delete` / `+space-create` shortcuts (#904)
|
||||
- **base**: Support Base attachment APIs (#887)
|
||||
- **mail**: Validate `bot` + `mailbox=me` and add dynamic `--as` help tests (#895)
|
||||
- **mail**: Expose draft priority in `--inspect` projection and document `--set-priority` (#779)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **identitydiag**: Harden verify path and tighten status semantics (#961)
|
||||
- **wiki**: Surface real node URL for `+node-create` / `+node-copy` (#960)
|
||||
- **auth**: Split bot and user identity diagnostics (#957)
|
||||
- **base**: Address Base attachment review follow-ups (#958)
|
||||
- **docs**: Clarify `replace_all` selection errors (#954)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Clarify add comment constraints (#967)
|
||||
- **lark-im**: Clarify message activity search (#865)
|
||||
|
||||
### Tests
|
||||
|
||||
- Verify e2e resource cleanup (#949)
|
||||
- **lint**: Exclude `bidichk` from test files (#959)
|
||||
|
||||
## [v1.0.33] - 2026-05-18
|
||||
|
||||
### Features
|
||||
|
||||
- **markdown**: Add `+patch` shortcut (#857)
|
||||
- **slides**: Improve slide planning and validation guidance (#847)
|
||||
- **drive**: Add `+sync` workflow for Drive directories (#873)
|
||||
- **drive**: Add drive version shortcut (#841)
|
||||
- **extension**: Plugin / Hook framework with command pruning (#910)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **sheets**: Explicitly document safe JSON unmarshal ignore in `DryRun` (#935)
|
||||
- **base**: Mark base field update high risk (#936)
|
||||
- **auth**: Guide agents to yield during auth device flow (#933)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **lark-wiki**: Correct the `--as` default-identity claim (#919)
|
||||
|
||||
### Tests
|
||||
|
||||
- Drop stale e2e `--yes` flags (#920)
|
||||
|
||||
## [v1.0.32] - 2026-05-15
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Add `--width`/`--height` flags to `docs +media-insert` (#832)
|
||||
- **wiki**: Add `+space-list` / `+node-list` / `+node-copy` shortcuts (#392)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Preserve parent token on nested overwrite (#908)
|
||||
- **selfupdate**: Use `LookPath` instead of `Executable` for binary verification (#886)
|
||||
- **registry**: Wait for background meta refresh before test reset (#894)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Add SVG whiteboard support to `lark-doc` v2 skill (#901)
|
||||
- **drive**: Add permission public patch error guidance (#863)
|
||||
|
||||
## [v1.0.31] - 2026-05-14
|
||||
|
||||
### Features
|
||||
|
||||
- **install**: Skip interactive prompts in non-TTY environments (#888)
|
||||
- **update**: Recommend `lark-cli update` over `npm install` for AI agents (#884)
|
||||
- **im**: Add `--exclude-muted` to `+chat-search` and new `+chat-list` shortcut (#820)
|
||||
- **auth**: Add `--exclude` flag and allow combining `--scope` with `--domain`/`--recommend` (#844)
|
||||
- **drive**: Add modified-time smart sync mode (#859)
|
||||
- **approval**: Add `tasks.add_sign` and `tasks.rollback` methods (#867)
|
||||
|
||||
## [v1.0.30] - 2026-05-13
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add `--chat-mode topic` to `+chat-create` (#790)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **auth**: Support comma-separated `--scope` in `auth login` (#764)
|
||||
- **auth**: Clarify URL handling in auth messages and docs (#856)
|
||||
- **bind**: Accept `~/` paths in OpenClaw secret references (#839)
|
||||
|
||||
### Tests
|
||||
|
||||
- **update**: Isolate stamp writes from real `~/.lark-cli/skills.stamp` (#858)
|
||||
|
||||
## [v1.0.29] - 2026-05-12
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Add agent meeting join, leave, and events shortcuts (#824)
|
||||
- **mail**: Add unknown-flag fuzzy match for `lark-cli mail` commands (#806)
|
||||
- **whiteboard**: Pin `whiteboard-cli` to `v0.2.11` in `lark-whiteboard` skill (#850)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Silence misleading "skills not installed" startup notice (#801)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Refine data analysis SOP wording (#784, #849)
|
||||
- Update README capability descriptions (#793)
|
||||
|
||||
## [v1.0.28] - 2026-05-11
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Support UAT for `messages.forward` and add `threads.forward` (#689)
|
||||
- **im**: Add flag shortcuts `+flag-create` / `+flag-list` / `+flag-cancel` for message bookmarks (#770)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Handle duplicate remote sync paths (#803)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Name `--query` / `--member-ids` in `+chat-search` shortcut row (#812)
|
||||
|
||||
## [v1.0.27] - 2026-05-09
|
||||
|
||||
### Features
|
||||
@@ -644,6 +933,22 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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.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.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31
|
||||
[v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30
|
||||
[v1.0.29]: https://github.com/larksuite/cli/releases/tag/v1.0.29
|
||||
[v1.0.28]: https://github.com/larksuite/cli/releases/tag/v1.0.28
|
||||
[v1.0.27]: https://github.com/larksuite/cli/releases/tag/v1.0.27
|
||||
[v1.0.26]: https://github.com/larksuite/cli/releases/tag/v1.0.26
|
||||
[v1.0.25]: https://github.com/larksuite/cli/releases/tag/v1.0.25
|
||||
|
||||
37
Makefile
37
Makefile
@@ -8,7 +8,9 @@ DATE := $(shell date +%Y-%m-%d)
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
.PHONY: build vet test unit-test integration-test install uninstall clean fetch_meta
|
||||
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
||||
|
||||
all: test
|
||||
|
||||
fetch_meta:
|
||||
python3 scripts/fetch_meta.py
|
||||
@@ -19,13 +21,32 @@ build: fetch_meta
|
||||
vet: fetch_meta
|
||||
go vet ./...
|
||||
|
||||
# fmt-check fails when any file would be reformatted by gofmt. Keep this
|
||||
# in sync with the fast-gate "Check formatting" step in CI.
|
||||
fmt-check:
|
||||
@unformatted=$$(gofmt -l . | grep -v '^\.claude/' || true); \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
echo "Unformatted Go files:"; \
|
||||
echo "$$unformatted"; \
|
||||
echo "Run 'gofmt -w .' and commit."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
go test -race -gcflags="all=-N -l" -count=1 ./cmd/... ./internal/... ./shortcuts/...
|
||||
go test -race -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
# breaks, the plugin author guide's "go build ./..." path is broken.
|
||||
examples-build:
|
||||
go build ./extension/platform/examples/audit-observer
|
||||
go build ./extension/platform/examples/readonly-policy
|
||||
|
||||
integration-test: build
|
||||
go test -v -count=1 ./tests/...
|
||||
|
||||
test: vet unit-test integration-test
|
||||
test: vet fmt-check unit-test examples-build integration-test
|
||||
|
||||
install: build
|
||||
install -d $(PREFIX)/bin
|
||||
@@ -37,3 +58,13 @@ uninstall:
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
# Run secret-leak checks locally before pushing.
|
||||
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
|
||||
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
|
||||
# Step 2: gitleaks scans the full repo for real leaked secrets.
|
||||
# Install gitleaks: https://github.com/gitleaks/gitleaks#installing
|
||||
gitleaks:
|
||||
@bash scripts/check-doc-tokens.sh
|
||||
@command -v gitleaks >/dev/null 2>&1 || { echo "gitleaks not found. Install: brew install gitleaks"; exit 1; }
|
||||
gitleaks detect --redact -v --exit-code=2
|
||||
|
||||
31
README.md
31
README.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 26 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/)
|
||||
- **Wide Coverage** — 18 business domains, 200+ curated commands, 26 AI Agent [Skills](./skills/)
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
||||
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||
@@ -24,11 +24,11 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
|
||||
| Category | Capabilities |
|
||||
| ------------- |-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
|
||||
| 📅 Calendar | View, create and update events, invite attendees, find meeting rooms, RSVP to invitations, check free/busy & time suggestions |
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files |
|
||||
| 📝 Markdown | Create, fetch, patch, and overwrite Drive-native `.md` files |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
@@ -36,11 +36,12 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
|
||||
| 🎥 Meetings | Search meeting records, query meeting minutes artifacts and recordings |
|
||||
| 🕐 Attendance | Query personal attendance check-in records |
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
||||
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
|
||||
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
@@ -62,11 +63,7 @@ Choose **one** of the following methods:
|
||||
**Option 1 — From npm (recommended):**
|
||||
|
||||
```bash
|
||||
# Install CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add larksuite/cli -y -g
|
||||
npx @larksuite/cli@latest install
|
||||
```
|
||||
|
||||
**Option 2 — From source:**
|
||||
@@ -102,11 +99,7 @@ lark-cli calendar +agenda
|
||||
**Step 1 — Install**
|
||||
|
||||
```bash
|
||||
# Install CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add larksuite/cli -y -g
|
||||
npx @larksuite/cli@latest install
|
||||
```
|
||||
|
||||
**Step 2 — Configure app credentials**
|
||||
@@ -136,11 +129,11 @@ lark-cli auth status
|
||||
| Skill | Description |
|
||||
| ------------------------------- |----------------------------------------------------------------------------------------------------------------|
|
||||
| `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) |
|
||||
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
|
||||
| `lark-calendar` | Calendar events (create/update), agenda view, free/busy queries, time suggestions, room finding, RSVP replies |
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
|
||||
| `lark-markdown` | Create, fetch, patch, and overwrite Drive-native Markdown files |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
@@ -151,7 +144,7 @@ lark-cli auth status
|
||||
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
|
||||
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
|
||||
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
|
||||
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters); upload audio/video to create minutes, download media |
|
||||
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
|
||||
| `lark-skill-maker` | Custom skill creation framework |
|
||||
| `lark-attendance` | Query personal attendance check-in records |
|
||||
@@ -286,6 +279,8 @@ 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.
|
||||
|
||||
Before opening a PR, see [AGENTS.md](./AGENTS.md) for the local build, test, and PR checklist used by contributors and AI agents.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **MIT License**.
|
||||
|
||||
33
README.zh.md
33
README.zh.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 26 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 26 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 18 大业务域、200+ 精选命令、26 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -24,11 +24,11 @@
|
||||
|
||||
| 类别 | 能力 |
|
||||
| ------------- |--------------------------------------------|
|
||||
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
|
||||
| 📅 日历 | 查看、创建和更新日程,邀请参会人、查找会议室、回复日程邀请、查询忙闲与时间建议 |
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
|
||||
| 📝 Markdown | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 `.md` 文件 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
@@ -36,11 +36,12 @@
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要产物与会议录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -62,11 +63,7 @@
|
||||
**方式一 — 从 npm 安装(推荐):**
|
||||
|
||||
```bash
|
||||
# 安装 CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
npx @larksuite/cli@latest install
|
||||
```
|
||||
|
||||
**方式二 — 从源码安装:**
|
||||
@@ -102,11 +99,7 @@ lark-cli calendar +agenda
|
||||
**第 1 步 — 安装**
|
||||
|
||||
```bash
|
||||
# 安装 CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
npx @larksuite/cli@latest install
|
||||
```
|
||||
|
||||
**第 2 步 — 配置应用凭证**
|
||||
@@ -137,11 +130,11 @@ lark-cli auth status
|
||||
| Skill | 说明 |
|
||||
| --------------------------------- |-------------------------------------------|
|
||||
| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) |
|
||||
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
|
||||
| `lark-calendar` | 日历日程(创建/更新)、议程查看、忙闲查询、时间建议、会议室查找、回复邀请 |
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
|
||||
| `lark-markdown` | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 Markdown 文件 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
@@ -152,7 +145,7 @@ lark-cli auth status
|
||||
| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 |
|
||||
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
|
||||
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
|
||||
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节),上传音视频生成妙记,下载音视频文件 |
|
||||
| `lark-openapi-explorer` | 从官方文档探索底层 API |
|
||||
| `lark-skill-maker` | 自定义 skill 创建框架 |
|
||||
| `lark-attendance` | 查询个人考勤打卡记录 |
|
||||
@@ -287,6 +280,8 @@ lark-cli schema im.messages.delete
|
||||
|
||||
对于较大的改动,建议先通过 Issue 与我们讨论。
|
||||
|
||||
提交 PR 前,请先阅读 [AGENTS.md](./AGENTS.md),其中列出了贡献者和 AI Agent 使用的本地构建、测试和 PR 检查清单。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 **MIT 许可证** 开源。
|
||||
|
||||
@@ -103,6 +103,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -237,7 +238,11 @@ func apiRun(opts *APIOptions) error {
|
||||
|
||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||
if err != nil {
|
||||
return output.MarkRaw(client.WrapDoAPIError(err))
|
||||
// MarkRaw tells the dispatcher to skip enrichPermissionError so the
|
||||
// raw API error detail (log_id, troubleshooter, permission_violations)
|
||||
// stays on the wire — `lark-cli api` callers explicitly want the raw
|
||||
// envelope.
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
@@ -247,9 +252,15 @@ func apiRun(opts *APIOptions) error {
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
// Stage 1: CheckResponse emits the legacy *output.ExitError envelope.
|
||||
// Per-domain migration in stage 2+ will route through
|
||||
// errclass.BuildAPIError to populate identity-aware fields
|
||||
// (PermissionError.ConsoleURL needs Brand+AppID from the client).
|
||||
CheckError: ac.CheckResponse,
|
||||
})
|
||||
// MarkRaw tells root error handler to skip enrichPermissionError,
|
||||
// preserving the original API error detail (log_id, troubleshooter, etc.).
|
||||
// MarkRaw: see comment above on the DoAPI path. Applies equally to
|
||||
// HandleResponse failures so the raw API error survives to the wire.
|
||||
if err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
@@ -261,9 +272,12 @@ 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 {
|
||||
if pagOpts.Identity == "" {
|
||||
pagOpts.Identity = request.As
|
||||
}
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil {
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
@@ -276,9 +290,9 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
pf.FormatPage(items)
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
@@ -290,9 +304,9 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
default:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -399,154 +397,6 @@ 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) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -43,6 +43,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthList(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthCheck(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,32 @@ func TestAuthLoginCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { return 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{
|
||||
"only delivers final turn messages",
|
||||
"--no-wait --json",
|
||||
"send the verification URL (or QR code) to the user as your final message",
|
||||
"run --device-code in a later step",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("help missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -37,6 +37,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
||||
cmd.MarkFlagRequired("scope")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -46,8 +47,7 @@ func authCheckRun(opts *CheckOptions) error {
|
||||
|
||||
required := strings.Fields(opts.Scope)
|
||||
if len(required) == 0 {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": true, "granted": []string{}, "missing": []string{}})
|
||||
return nil
|
||||
return output.ErrValidation("--scope cannot be empty")
|
||||
}
|
||||
|
||||
config, err := f.Config()
|
||||
|
||||
@@ -34,6 +34,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
return authListRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
@@ -30,6 +31,7 @@ type LoginOptions struct {
|
||||
Scope string
|
||||
Recommend bool
|
||||
Domains []string
|
||||
Exclude []string
|
||||
NoWait bool
|
||||
DeviceCode string
|
||||
}
|
||||
@@ -46,10 +48,13 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
|
||||
Long: `Device Flow authorization login.
|
||||
|
||||
For AI agents: this command blocks until the user completes authorization in the
|
||||
browser. Run it in the background and retrieve the verification URL from its output.`,
|
||||
browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json,
|
||||
send the verification URL (or QR code) to the user as your final message, end the turn, then
|
||||
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
|
||||
to generate QR codes (supports ASCII and PNG formats).`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
return output.ErrWithHint(output.ExitValidation, "command_denied",
|
||||
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
|
||||
"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)")
|
||||
}
|
||||
@@ -61,12 +66,21 @@ browser. Run it in the background and retrieve the verification URL from its out
|
||||
},
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
available := sortedKnownDomains()
|
||||
var helpBrand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
helpBrand = cfg.Brand
|
||||
}
|
||||
}
|
||||
available := sortedKnownDomains(helpBrand)
|
||||
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
|
||||
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
|
||||
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
|
||||
"scopes to exclude from the request (repeatable or comma-separated, e.g. --exclude drive:file:download)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
|
||||
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
|
||||
@@ -108,7 +122,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Determine UI language from saved config
|
||||
lang := "zh"
|
||||
var lang i18n.Lang
|
||||
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
|
||||
if app := multi.FindApp(config.ProfileName); app != nil {
|
||||
lang = app.Lang
|
||||
@@ -133,14 +147,14 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
// Expand --domain all to all available domains (from_meta projects + shortcut services)
|
||||
for _, d := range selectedDomains {
|
||||
if strings.EqualFold(d, "all") {
|
||||
selectedDomains = sortedKnownDomains()
|
||||
selectedDomains = sortedKnownDomains(config.Brand)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domain names and suggest corrections for unknown ones
|
||||
if len(selectedDomains) > 0 {
|
||||
knownDomains := allKnownDomains()
|
||||
knownDomains := allKnownDomains(config.Brand)
|
||||
for _, d := range selectedDomains {
|
||||
if !knownDomains[d] {
|
||||
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
|
||||
@@ -158,9 +172,13 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
|
||||
|
||||
if len(opts.Exclude) > 0 && !hasAnyOption {
|
||||
return output.ErrValidation("--exclude requires --scope, --domain, or --recommend to be specified")
|
||||
}
|
||||
|
||||
if !hasAnyOption {
|
||||
if !opts.JSON && f.IOStreams.IsTerminal {
|
||||
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
|
||||
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -180,25 +198,28 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
log("View all options:")
|
||||
log(msg.HintFooter)
|
||||
log("")
|
||||
log("Note: this command blocks until authorization is complete. Run it in the background and retrieve the verification URL from its output.")
|
||||
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 output.ErrValidation("please specify the scopes to authorize")
|
||||
}
|
||||
}
|
||||
|
||||
finalScope := opts.Scope
|
||||
// Normalize --scope so users can pass either OAuth-standard space-separated
|
||||
// values or the more natural comma-separated list. RFC 6749 §3.3 mandates
|
||||
// space-delimited scopes in the wire request, so the device authorization
|
||||
// endpoint rejects raw "a,b" strings as a single malformed scope.
|
||||
finalScope := normalizeScopeInput(opts.Scope)
|
||||
|
||||
// Resolve scopes from domain/permission filters
|
||||
// Resolve scopes from domain/permission filters and merge with --scope.
|
||||
// --scope, --domain, and --recommend combine additively so callers can,
|
||||
// for example, request all `docs` scopes plus a few specific `drive`
|
||||
// scopes in a single command.
|
||||
if len(selectedDomains) > 0 || opts.Recommend {
|
||||
if opts.Scope != "" {
|
||||
return output.ErrValidation("cannot use --scope together with --domain/--recommend")
|
||||
}
|
||||
|
||||
var candidateScopes []string
|
||||
if len(selectedDomains) > 0 {
|
||||
candidateScopes = collectScopesForDomains(selectedDomains, "user")
|
||||
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
|
||||
} else {
|
||||
// --recommend without --domain: all domains
|
||||
candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user")
|
||||
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
|
||||
}
|
||||
|
||||
// Filter to auto-approve scopes if --recommend or interactive "common"
|
||||
@@ -206,11 +227,35 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
candidateScopes = registry.FilterAutoApproveScopes(candidateScopes)
|
||||
}
|
||||
|
||||
if len(candidateScopes) == 0 {
|
||||
if len(candidateScopes) == 0 && opts.Scope == "" {
|
||||
return output.ErrValidation("no matching scopes found, check domain/scope options")
|
||||
}
|
||||
|
||||
finalScope = strings.Join(candidateScopes, " ")
|
||||
// Merge --scope additively with the resolved domain scopes.
|
||||
merged := make(map[string]bool, len(candidateScopes)+len(strings.Fields(finalScope)))
|
||||
for _, s := range candidateScopes {
|
||||
merged[s] = true
|
||||
}
|
||||
for _, s := range strings.Fields(finalScope) {
|
||||
merged[s] = true
|
||||
}
|
||||
finalScope = joinSortedScopeSet(merged)
|
||||
}
|
||||
|
||||
// Apply --exclude on top of the resolved scope set. We honour exclude
|
||||
// regardless of whether scopes came from --scope, --domain, --recommend,
|
||||
// or any combination thereof.
|
||||
if len(opts.Exclude) > 0 {
|
||||
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
|
||||
if len(unknown) > 0 {
|
||||
return output.ErrValidation(
|
||||
"these --exclude scopes are not present in the requested set: %s",
|
||||
strings.Join(unknown, ", "))
|
||||
}
|
||||
finalScope = excluded
|
||||
if strings.TrimSpace(finalScope) == "" {
|
||||
return output.ErrValidation("no scopes left after applying --exclude; nothing to authorize")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Request device authorization
|
||||
@@ -232,7 +277,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"hint": fmt.Sprintf("Show verification_url to user, then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
|
||||
"hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**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. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
@@ -415,6 +460,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncLoginUserToProfile persists the logged-in user info into the named profile.
|
||||
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
@@ -440,6 +486,7 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// findProfileByName returns the AppConfig matching profileName, or nil.
|
||||
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].ProfileName() == profileName {
|
||||
@@ -453,7 +500,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
|
||||
// shortcut scopes for the given domain names.
|
||||
// Domains with auth_domain children are automatically expanded to include
|
||||
// their children's scopes.
|
||||
func collectScopesForDomains(domains []string, identity string) []string {
|
||||
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
|
||||
scopeSet := make(map[string]bool)
|
||||
|
||||
// 1. API scopes from from_meta projects
|
||||
@@ -472,8 +519,11 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
continue
|
||||
}
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.ScopesForIdentity(identity) {
|
||||
for _, s := range sc.DeclaredScopesForIdentity(identity) {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
}
|
||||
@@ -491,7 +541,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
// allKnownDomains returns all valid auth domain names (from_meta projects +
|
||||
// shortcut services), excluding domains that have auth_domain set (they are
|
||||
// folded into their parent domain).
|
||||
func allKnownDomains() map[string]bool {
|
||||
func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
if !registry.HasAuthDomain(p) {
|
||||
@@ -499,6 +549,9 @@ func allKnownDomains() map[string]bool {
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
continue
|
||||
}
|
||||
if !registry.HasAuthDomain(sc.Service) {
|
||||
domains[sc.Service] = true
|
||||
}
|
||||
@@ -507,8 +560,8 @@ func allKnownDomains() map[string]bool {
|
||||
}
|
||||
|
||||
// sortedKnownDomains returns all valid domain names sorted alphabetically.
|
||||
func sortedKnownDomains() []string {
|
||||
m := allKnownDomains()
|
||||
func sortedKnownDomains(brand core.LarkBrand) []string {
|
||||
m := allKnownDomains(brand)
|
||||
domains := make([]string, 0, len(m))
|
||||
for d := range m {
|
||||
domains = append(domains, d)
|
||||
@@ -532,6 +585,40 @@ func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeScopeInput accepts a user-supplied --scope value that may use
|
||||
// commas, spaces, tabs, or newlines (or any mix) as separators and returns the
|
||||
// canonical OAuth 2.0 wire form: a single space-joined string with empties
|
||||
// trimmed and duplicates removed (first occurrence wins; order preserved).
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "vc:note:read,vc:meeting.meetingevent:read" -> "vc:note:read vc:meeting.meetingevent:read"
|
||||
// "a, b , c" -> "a b c"
|
||||
// "a b a" -> "a b"
|
||||
// "" -> ""
|
||||
func normalizeScopeInput(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// Treat both commas and any whitespace as separators.
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
})
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
seen := make(map[string]struct{}, len(fields))
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if _, ok := seen[f]; ok {
|
||||
continue
|
||||
}
|
||||
seen[f] = struct{}{}
|
||||
out = append(out, f)
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// suggestDomain finds the best "did you mean" match for an unknown domain.
|
||||
func suggestDomain(input string, known map[string]bool) string {
|
||||
// Check common cases: prefix match or input is a substring
|
||||
@@ -542,3 +629,58 @@ func suggestDomain(input string, known map[string]bool) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// joinSortedScopeSet returns a deterministic, space-separated scope string
|
||||
// from a set, sorted alphabetically. Empty/blank scopes are dropped.
|
||||
func joinSortedScopeSet(set map[string]bool) string {
|
||||
out := make([]string, 0, len(set))
|
||||
for s := range set {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// applyExcludeScopes removes the provided exclude entries from the requested
|
||||
// scope string. Each --exclude flag value may itself contain comma- or
|
||||
// whitespace-separated scopes. Returns the filtered scope string and any
|
||||
// exclude entries that were not present in the requested set (callers can
|
||||
// surface those as a validation error to catch typos like
|
||||
// `--exclude drive:file:downlod`).
|
||||
func applyExcludeScopes(requested string, excludes []string) (string, []string) {
|
||||
requestedSet := make(map[string]bool)
|
||||
for _, s := range strings.Fields(requested) {
|
||||
requestedSet[s] = true
|
||||
}
|
||||
|
||||
excludeSet := make(map[string]bool)
|
||||
for _, raw := range excludes {
|
||||
// --exclude already splits on commas (StringSliceVar), but also
|
||||
// tolerate whitespace-separated entries inside a single value.
|
||||
for _, s := range strings.Fields(strings.ReplaceAll(raw, ",", " ")) {
|
||||
excludeSet[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
var unknown []string
|
||||
for s := range excludeSet {
|
||||
if !requestedSet[s] {
|
||||
unknown = append(unknown, s)
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
sort.Strings(unknown)
|
||||
return requested, unknown
|
||||
}
|
||||
|
||||
kept := make(map[string]bool, len(requestedSet))
|
||||
for s := range requestedSet {
|
||||
if !excludeSet[s] {
|
||||
kept[s] = true
|
||||
}
|
||||
}
|
||||
return joinSortedScopeSet(kept), nil
|
||||
}
|
||||
|
||||
32
cmd/auth/login_brand_filter_test.go
Normal file
32
cmd/auth/login_brand_filter_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
|
||||
feishuDomains := allKnownDomains(core.BrandFeishu)
|
||||
if !feishuDomains["apps"] {
|
||||
t.Errorf("expected apps domain to be known on Feishu brand")
|
||||
}
|
||||
|
||||
larkDomains := allKnownDomains(core.BrandLark)
|
||||
if larkDomains["apps"] {
|
||||
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
|
||||
}
|
||||
|
||||
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
|
||||
if len(feishuScopes) == 0 {
|
||||
t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes))
|
||||
}
|
||||
|
||||
larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark)
|
||||
if len(larkScopes) != 0 {
|
||||
t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
@@ -105,7 +106,7 @@ func buildDomainMeta(name, lang string) domainMeta {
|
||||
}
|
||||
|
||||
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
|
||||
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*interactiveResult, error) {
|
||||
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
|
||||
allDomains := getDomainMetadata(lang)
|
||||
|
||||
// Build multi-select options
|
||||
@@ -165,7 +166,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i
|
||||
}
|
||||
|
||||
// Compute scope summary
|
||||
scopes := collectScopesForDomains(selectedDomains, "user")
|
||||
scopes := collectScopesForDomains(selectedDomains, "user", brand)
|
||||
if permLevel == "common" {
|
||||
scopes = registry.FilterAutoApproveScopes(scopes)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
package auth
|
||||
|
||||
import "github.com/larksuite/cli/internal/i18n"
|
||||
|
||||
type loginMsg struct {
|
||||
// Interactive UI (login_interactive.go)
|
||||
SelectDomains string
|
||||
@@ -59,7 +61,7 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout ≥ 600s;如不支持长 timeout,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询,**不要短 timeout 反复重试**——每次重启会作废上一轮的 device code,导致用户授权的链接失效。",
|
||||
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 编码/解码、添加空格或标点)。",
|
||||
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
@@ -95,7 +97,7 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is ≥ 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout** — each restart invalidates the previous device code, so any URL the user already authorized becomes useless.",
|
||||
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.",
|
||||
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
@@ -114,8 +116,9 @@ var loginMsgEn = &loginMsg{
|
||||
HintFooter: " lark-cli auth login --help",
|
||||
}
|
||||
|
||||
func getLoginMsg(lang string) *loginMsg {
|
||||
if lang == "en" {
|
||||
// getLoginMsg returns the login message bundle for the given language.
|
||||
func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
if lang.IsEnglish() {
|
||||
return loginMsgEn
|
||||
}
|
||||
return loginMsgZh
|
||||
@@ -125,5 +128,5 @@ func getLoginMsg(lang string) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs", "markdown"}
|
||||
return []string{"base", "contact", "docs", "markdown", "apps"}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
)
|
||||
|
||||
func TestGetLoginMsg_Zh(t *testing.T) {
|
||||
@@ -31,7 +33,7 @@ func TestGetLoginMsg_En(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetLoginMsg_DefaultsToZh(t *testing.T) {
|
||||
for _, lang := range []string{"", "fr", "ja", "unknown"} {
|
||||
for _, lang := range []i18n.Lang{"", "fr_fr", "ja_jp", "unknown"} {
|
||||
msg := getLoginMsg(lang)
|
||||
if msg != loginMsgZh {
|
||||
t.Errorf("getLoginMsg(%q) should default to zh", lang)
|
||||
@@ -61,7 +63,7 @@ func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string)
|
||||
}
|
||||
|
||||
func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
|
||||
msg := getLoginMsg(lang)
|
||||
|
||||
// LoginSuccess should contain two %s placeholders (userName, openId)
|
||||
@@ -97,16 +99,17 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestAgentTimeoutHint_CarriesKeyInfo guards the contract that the synchronous
|
||||
// auth-login output tells AI agents two things: (a) this command blocks for
|
||||
// minutes — set a long runner timeout, and (b) the alternative is the
|
||||
// --no-wait + --device-code split-flow. Without (a) AI sets a 10s timeout and
|
||||
// kills the process before the user can authorize; without (b) the AI has no
|
||||
// recovery path and just retries with the same short timeout, invalidating
|
||||
// each new device code in turn.
|
||||
// auth-login output tells AI agents three things: (a) this command blocks for
|
||||
// minutes — set a long runner timeout, (b) the alternative is the --no-wait +
|
||||
// --device-code split-flow, and (c) non-streaming harnesses must end the turn
|
||||
// after presenting the URL instead of blocking in the same turn.
|
||||
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
|
||||
hint := getLoginMsg(lang).AgentTimeoutHint
|
||||
for _, want := range []string{"--no-wait", "--device-code"} {
|
||||
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
|
||||
if lang == i18n.LangZhCN && want == "turn" {
|
||||
want = "本轮"
|
||||
}
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("%s AgentTimeoutHint missing %q: %s", lang, want, hint)
|
||||
}
|
||||
|
||||
@@ -176,6 +176,11 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
"granted": issue.Summary.Granted,
|
||||
"missing": issue.Summary.Missing,
|
||||
}
|
||||
// Legacy *output.ExitError producer: this literal predates the typed
|
||||
// error contract introduced by errs/. New code MUST NOT construct
|
||||
// *output.ExitError directly — missing-scope signals should move to
|
||||
// *errs.PermissionError (with MissingScopes/ConsoleURL as typed
|
||||
// extension fields) when the login flow migrates to typed errors.
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{
|
||||
|
||||
@@ -70,6 +70,32 @@ func TestSuggestDomain_ExactMatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeScopeInput(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"single", "vc:note:read", "vc:note:read"},
|
||||
{"comma", "vc:note:read,vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"space", "vc:note:read vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"comma_and_spaces", "vc:note:read, vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"mixed_separators", "a, b\tc\nd e", "a b c d e"},
|
||||
{"trim_and_dedup", " a , b , a ", "a b"},
|
||||
{"trailing_separators", "a,b,,", "a b"},
|
||||
{"only_separators", " , , ", ""},
|
||||
{"tab_separated", "im:message:send\toffline_access", "im:message:send offline_access"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := normalizeScopeInput(tc.in); got != tc.want {
|
||||
t.Errorf("normalizeScopeInput(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
|
||||
// Empty AuthTypes defaults to ["user"]
|
||||
sc := common.Shortcut{AuthTypes: nil}
|
||||
@@ -145,7 +171,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllKnownDomains(t *testing.T) {
|
||||
domains := allKnownDomains()
|
||||
domains := allKnownDomains("")
|
||||
if len(domains) == 0 {
|
||||
t.Fatal("expected non-empty known domains")
|
||||
}
|
||||
@@ -159,7 +185,7 @@ func TestAllKnownDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSortedKnownDomains(t *testing.T) {
|
||||
sorted := sortedKnownDomains()
|
||||
sorted := sortedKnownDomains("")
|
||||
if len(sorted) == 0 {
|
||||
t.Fatal("expected non-empty sorted domains")
|
||||
}
|
||||
@@ -169,7 +195,7 @@ func TestSortedKnownDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should match allKnownDomains
|
||||
known := allKnownDomains()
|
||||
known := allKnownDomains("")
|
||||
if len(sorted) != len(known) {
|
||||
t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known))
|
||||
}
|
||||
@@ -194,7 +220,7 @@ func TestCollectScopesForDomains(t *testing.T) {
|
||||
t.Skip("no from_meta data available")
|
||||
}
|
||||
|
||||
scopes := collectScopesForDomains([]string{"calendar"}, "user")
|
||||
scopes := collectScopesForDomains([]string{"calendar"}, "user", "")
|
||||
if len(scopes) == 0 {
|
||||
t.Fatal("expected non-empty scopes for calendar domain")
|
||||
}
|
||||
@@ -221,7 +247,7 @@ func TestCollectScopesForDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user")
|
||||
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user", "")
|
||||
if len(scopes) != 0 {
|
||||
t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes))
|
||||
}
|
||||
@@ -289,10 +315,12 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
|
||||
if !strings.Contains(msg, "scopes") {
|
||||
t.Errorf("expected error to mention scopes, got: %s", msg)
|
||||
}
|
||||
// Stderr should contain background hint
|
||||
// Stderr should explain the split-flow path for non-streaming agents.
|
||||
stderrStr := stderr.String()
|
||||
if !strings.Contains(stderrStr, "background") {
|
||||
t.Errorf("expected stderr to mention background, got: %s", stderrStr)
|
||||
for _, want := range []string{"--no-wait --json", "final message of the turn", "--device-code"} {
|
||||
if !strings.Contains(stderrStr, want) {
|
||||
t.Errorf("expected stderr to mention %q, got: %s", want, stderrStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -879,6 +907,77 @@ func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
||||
f, stdout, _, 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": 5,
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
NoWait: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("authLoginRun() error = %v", err)
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(strings.NewReader(stdout.String()))
|
||||
var data map[string]interface{}
|
||||
if err := dec.Decode(&data); err != nil {
|
||||
t.Fatalf("Decode(stdout first event) error = %v, stdout=%q", err, stdout.String())
|
||||
}
|
||||
hint, _ := data["hint"].(string)
|
||||
for _, want := range []string{
|
||||
"MUST generate QR code AND display it",
|
||||
"lark-cli auth qrcode",
|
||||
"Prefer PNG QR code (--output)",
|
||||
"use ASCII (--ascii) only when the user explicitly requests it",
|
||||
"This is a required step, do NOT skip it",
|
||||
"CRITICAL",
|
||||
"You MUST include the QR image in your response",
|
||||
"Generating the file alone is NOT enough",
|
||||
"image tags, inline images, or file attachments",
|
||||
"Display order",
|
||||
"place the QR code image below the URL",
|
||||
"opaque string",
|
||||
"cannot be modified",
|
||||
"final message of the turn",
|
||||
"return control to the user",
|
||||
"do not block on --device-code in the same turn",
|
||||
"After the user confirms authorization in a later step",
|
||||
"lark-cli auth login --device-code device-code",
|
||||
} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"Then immediately execute",
|
||||
"Do not instruct the user to run this command themselves",
|
||||
} {
|
||||
if strings.Contains(hint, unwanted) {
|
||||
t.Fatalf("hint should not contain %q, got:\n%s", unwanted, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
@@ -917,6 +1016,69 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *testing.T) {
|
||||
f, stdout, _, 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": 5,
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: ctx,
|
||||
Scope: "im:message:send",
|
||||
JSON: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from cancelled context")
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(strings.NewReader(stdout.String()))
|
||||
var data map[string]interface{}
|
||||
if err := dec.Decode(&data); err != nil {
|
||||
t.Fatalf("Decode(stdout first event) error = %v, stdout=%q", err, stdout.String())
|
||||
}
|
||||
hint, _ := data["agent_hint"].(string)
|
||||
for _, want := range []string{
|
||||
"timeout >= 600s",
|
||||
"本轮最终消息",
|
||||
"结束本轮",
|
||||
"用户回复已完成授权",
|
||||
"不要在同一轮里展示 URL 后立刻阻塞执行 --device-code",
|
||||
"必须生成二维码并展示",
|
||||
"lark-cli auth qrcode",
|
||||
"优先生成 PNG 二维码(--output)",
|
||||
"仅当用户明确要求时才使用 ASCII(--ascii)",
|
||||
"生成后必须在回复中展示图片",
|
||||
"仅生成文件不算完成",
|
||||
"image 标签或内联图片",
|
||||
"二维码图片置于 URL 下方完整展示",
|
||||
"URL 输出规则",
|
||||
"opaque string",
|
||||
"不要做任何修改",
|
||||
} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
@@ -927,7 +1089,7 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := allKnownDomains()
|
||||
domains := allKnownDomains("")
|
||||
if domains["whiteboard"] {
|
||||
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
|
||||
}
|
||||
@@ -937,7 +1099,7 @@ func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"docs"}, "user")
|
||||
scopes := collectScopesForDomains([]string{"docs"}, "user", "")
|
||||
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
|
||||
found := false
|
||||
for _, s := range scopes {
|
||||
|
||||
@@ -33,6 +33,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
||||
return authLogoutRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
142
cmd/auth/qrcode.go
Normal file
142
cmd/auth/qrcode.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// QRCodeOptions holds inputs for auth qrcode command.
|
||||
type QRCodeOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
URL string
|
||||
Size int
|
||||
ASCII bool
|
||||
Output string
|
||||
}
|
||||
|
||||
// NewCmdAuthQRCode creates the auth qrcode subcommand.
|
||||
func NewCmdAuthQRCode(f *cmdutil.Factory, runF func(*QRCodeOptions) error) *cobra.Command {
|
||||
opts := &QRCodeOptions{Factory: f, Size: 256}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "qrcode <url>",
|
||||
Short: "Generate QR code for verification URL",
|
||||
Long: `Generate a QR code image or ASCII representation for a verification URL.
|
||||
|
||||
This command is designed for AI agents to generate QR codes for OAuth authorization URLs.
|
||||
|
||||
For PNG output, the --output flag is required to specify the output file path (must be a relative path within the current directory).
|
||||
For ASCII output, the result is printed to stdout with fixed size.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.URL = args[0]
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runQRCode(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
|
||||
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runQRCode executes the auth qrcode command.
|
||||
func runQRCode(opts *QRCodeOptions) error {
|
||||
if opts.URL == "" {
|
||||
return output.Errorf(output.ExitValidation, "missing_url", "url is required")
|
||||
}
|
||||
|
||||
if opts.ASCII {
|
||||
var out io.Writer = os.Stdout
|
||||
if opts.Factory != nil {
|
||||
out = opts.Factory.IOStreams.Out
|
||||
}
|
||||
return generateASCIIQRCode(opts.URL, out)
|
||||
}
|
||||
|
||||
if opts.Output == "" {
|
||||
return output.Errorf(output.ExitValidation, "missing_output", "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.")
|
||||
}
|
||||
|
||||
if opts.Size < 32 {
|
||||
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at least 32, got %d", opts.Size))
|
||||
}
|
||||
|
||||
if opts.Size > 1024 {
|
||||
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at most 1024, got %d", opts.Size))
|
||||
}
|
||||
|
||||
safePath, err := validate.SafeOutputPath(opts.Output)
|
||||
if err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"ok": true,
|
||||
"file_path": safePath,
|
||||
"hint": "You MUST include the QR image in your response. Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.",
|
||||
}
|
||||
|
||||
var out io.Writer = os.Stdout
|
||||
if opts.Factory != nil {
|
||||
out = opts.Factory.IOStreams.Out
|
||||
}
|
||||
encoder := json.NewEncoder(out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(result); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write output: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateImageQRCode encodes the URL as a PNG QR code and writes it to outputPath.
|
||||
func generateImageQRCode(url string, size int, outputPath string) error {
|
||||
png, err := qrcode.Encode(url, qrcode.Medium, size)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to encode QR code: %v", err))
|
||||
}
|
||||
|
||||
err = vfs.WriteFile(outputPath, png, 0644)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "write_error", fmt.Sprintf("failed to write QR code to %s: %v", outputPath, err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateASCIIQRCode encodes the URL as an ASCII QR code and prints it to stdout.
|
||||
func generateASCIIQRCode(url string, w io.Writer) error {
|
||||
q, err := qrcode.New(url, qrcode.Medium)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to create QR code: %v", err))
|
||||
}
|
||||
|
||||
fmt.Fprint(w, q.ToSmallString(false))
|
||||
|
||||
return nil
|
||||
}
|
||||
368
cmd/auth/qrcode_test.go
Normal file
368
cmd/auth/qrcode_test.go
Normal file
@@ -0,0 +1,368 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png", "--size", "128"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.URL != "https://example.com" {
|
||||
t.Errorf("URL = %q, want %q", gotOpts.URL, "https://example.com")
|
||||
}
|
||||
if gotOpts.Size != 128 {
|
||||
t.Errorf("Size = %d, want %d", gotOpts.Size, 128)
|
||||
}
|
||||
if gotOpts.Output != "qr.png" {
|
||||
t.Errorf("Output = %q, want %q", gotOpts.Output, "qr.png")
|
||||
}
|
||||
if gotOpts.ASCII {
|
||||
t.Error("ASCII should be false by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"https://example.com", "--ascii"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !gotOpts.ASCII {
|
||||
t.Error("ASCII should be true when --ascii is passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_DefaultSize(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"https://example.com", "--ascii"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.Size != 256 {
|
||||
t.Errorf("default Size = %d, want 256", gotOpts.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_ExactOneArg(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error when no URL argument provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_RunE_PNGEndToEnd(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
tmpDir := t.TempDir()
|
||||
oldWd, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(oldWd) })
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("qr.png")
|
||||
if err != nil {
|
||||
t.Fatalf("output file not created: %v", err)
|
||||
}
|
||||
if string(data[:4]) != "\x89PNG" {
|
||||
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v, got: %s", err, stdout.String())
|
||||
}
|
||||
if result["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", result["ok"])
|
||||
}
|
||||
hint, _ := result["hint"].(string)
|
||||
if hint == "" {
|
||||
t.Error("hint is empty")
|
||||
}
|
||||
if !strings.Contains(hint, "MUST include") {
|
||||
t.Errorf("hint missing 'MUST include', got: %s", hint)
|
||||
}
|
||||
if !strings.Contains(hint, "NOT enough") {
|
||||
t.Errorf("hint missing 'NOT enough', got: %s", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_RunE_MissingOutput(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"https://example.com"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error when --output is missing in PNG mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"qrcode <url>",
|
||||
"QR code",
|
||||
"--output",
|
||||
"--ascii",
|
||||
"relative path",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("help missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_MissingURL(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{URL: ""})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "missing_url" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_MissingOutput(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "missing_output" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_InvalidSize(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 16,
|
||||
Output: "qr.png",
|
||||
})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "invalid_size" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_SizeTooLarge(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 2048,
|
||||
Output: "qr.png",
|
||||
})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "invalid_size" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 256,
|
||||
Output: "/etc/passwd",
|
||||
})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_PNGWritesFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
tmpDir := t.TempDir()
|
||||
oldWd, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(oldWd) })
|
||||
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 256,
|
||||
Output: "qr.png",
|
||||
Factory: f,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat("qr.png")
|
||||
if err != nil {
|
||||
t.Fatalf("output file not created: %v", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Error("output file is empty")
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if jsonErr := json.Unmarshal(stdout.Bytes(), &result); jsonErr != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v, got: %s", jsonErr, stdout.String())
|
||||
}
|
||||
if result["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", result["ok"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_ASCIIOutputsToStdout(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
ASCII: true,
|
||||
Factory: f,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() == 0 {
|
||||
t.Error("ASCII QR code produced no output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImageQRCode_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "test-qr.png")
|
||||
|
||||
if err := generateImageQRCode("https://example.com", 256, outputPath); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output file: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("output file is empty")
|
||||
}
|
||||
if len(data) < 8 {
|
||||
t.Error("output too small to be a valid PNG")
|
||||
}
|
||||
if string(data[:4]) != "\x89PNG" {
|
||||
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImageQRCode_WriteError(t *testing.T) {
|
||||
err := generateImageQRCode("https://example.com", 256, "/nonexistent/deep/nested/dir/qr.png")
|
||||
if err == nil {
|
||||
t.Fatal("expected error writing to nonexistent directory")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
|
||||
}
|
||||
if exitErr.Detail.Type != "write_error" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "write_error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateASCIIQRCode_Success(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
err := generateASCIIQRCode("https://example.com", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Error("ASCII QR code produced no output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
err := generateASCIIQRCode("", &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty string")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "encode_error" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "encode_error")
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -5,13 +5,11 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -37,6 +35,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -59,73 +58,59 @@ func authStatusRun(opts *StatusOptions) error {
|
||||
"defaultAs": defaultAs,
|
||||
}
|
||||
|
||||
if config.UserOpenId == "" {
|
||||
result["identity"] = "bot"
|
||||
result["note"] = "No user logged in. Only bot (tenant) identity is available for API calls. Run `lark-cli auth login` to log in."
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
result["identity"] = "bot"
|
||||
result["userName"] = config.UserName
|
||||
result["userOpenId"] = config.UserOpenId
|
||||
result["note"] = "Token does not exist or has been cleared. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
status := larkauth.TokenStatus(stored)
|
||||
if status == "expired" {
|
||||
result["identity"] = "bot"
|
||||
result["note"] = "User token has expired. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
|
||||
} else {
|
||||
result["identity"] = "user"
|
||||
}
|
||||
result["userName"] = config.UserName
|
||||
result["userOpenId"] = config.UserOpenId
|
||||
result["tokenStatus"] = status
|
||||
result["scope"] = stored.Scope
|
||||
result["expiresAt"] = time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)
|
||||
result["refreshExpiresAt"] = time.UnixMilli(stored.RefreshExpiresAt).Format(time.RFC3339)
|
||||
result["grantedAt"] = time.UnixMilli(stored.GrantedAt).Format(time.RFC3339)
|
||||
|
||||
// --verify: call the server to confirm token is actually usable.
|
||||
if opts.Verify && status != "expired" {
|
||||
verified, verifyErr := verifyTokenOnServer(f, config)
|
||||
result["verified"] = verified
|
||||
if verifyErr != "" {
|
||||
result["verifyError"] = verifyErr
|
||||
}
|
||||
}
|
||||
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
||||
result["identities"] = diagnostics
|
||||
result["identity"] = effectiveIdentity(diagnostics)
|
||||
addEffectiveVerification(result, diagnostics)
|
||||
addStatusNote(result, diagnostics)
|
||||
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyTokenOnServer obtains a valid access token (refreshing if needed)
|
||||
// and calls /authen/v1/user_info to confirm the server accepts it.
|
||||
// Returns (true, "") on success or (false, reason) on failure.
|
||||
func verifyTokenOnServer(f *cmdutil.Factory, config *core.CliConfig) (bool, string) {
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return false, "failed to create HTTP client: " + err.Error()
|
||||
}
|
||||
const (
|
||||
identityUser = "user"
|
||||
identityBot = "bot"
|
||||
identityNone = "none"
|
||||
)
|
||||
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(config, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
return false, "token unusable: " + err.Error()
|
||||
func effectiveIdentity(d identitydiag.Result) string {
|
||||
switch {
|
||||
case d.User.Available:
|
||||
return identityUser
|
||||
case d.Bot.Available:
|
||||
return identityBot
|
||||
default:
|
||||
return identityNone
|
||||
}
|
||||
}
|
||||
|
||||
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
|
||||
switch result["identity"] {
|
||||
case identityUser:
|
||||
if d.User.Verified != nil {
|
||||
result["verified"] = *d.User.Verified
|
||||
if !*d.User.Verified {
|
||||
result["verifyError"] = d.User.Message
|
||||
}
|
||||
}
|
||||
case identityBot:
|
||||
if d.Bot.Verified != nil {
|
||||
result["verified"] = *d.Bot.Verified
|
||||
if !*d.Bot.Verified {
|
||||
result["verifyError"] = d.Bot.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addStatusNote(result map[string]interface{}, d identitydiag.Result) {
|
||||
switch {
|
||||
case !d.User.Available && d.Bot.Available:
|
||||
result["note"] = "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls. Run `lark-cli auth login` to enable user identity."
|
||||
case d.User.Status == identitydiag.StatusNeedsRefresh:
|
||||
result["note"] = "User identity needs refresh and will be refreshed automatically on the next user API call."
|
||||
case !d.User.Available && !d.Bot.Available:
|
||||
result["note"] = "No usable identity is available. Configure bot credentials or run `lark-cli auth login`."
|
||||
}
|
||||
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
return false, "failed to create SDK client: " + err.Error()
|
||||
}
|
||||
|
||||
if err := larkauth.VerifyUserToken(context.Background(), sdk, token); err != nil {
|
||||
return false, "server rejected token: " + err.Error()
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
96
cmd/auth/status_test.go
Normal file
96
cmd/auth/status_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got statusOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if got.Identities.Bot.Status != "ready" || !got.Identities.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want ready and available", got.Identities.Bot)
|
||||
}
|
||||
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
|
||||
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot",
|
||||
"app_name": "diagnostic bot",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got statusOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if got.Verified == nil || !*got.Verified {
|
||||
t.Fatalf("verified = %v, want true", got.Verified)
|
||||
}
|
||||
if got.Identities.Bot.Verified == nil || !*got.Identities.Bot.Verified {
|
||||
t.Fatalf("bot verified = %v, want true", got.Identities.Bot.Verified)
|
||||
}
|
||||
if got.Identities.Bot.OpenID != "ou_bot" {
|
||||
t.Fatalf("bot open id = %q, want ou_bot", got.Identities.Bot.OpenID)
|
||||
}
|
||||
if got.Identities.User.Status != "missing" {
|
||||
t.Fatalf("user status = %q, want missing", got.Identities.User.Status)
|
||||
}
|
||||
}
|
||||
|
||||
type statusOutput struct {
|
||||
Identity string `json:"identity"`
|
||||
Verified *bool `json:"verified"`
|
||||
Identities struct {
|
||||
Bot statusIdentity `json:"bot"`
|
||||
User statusIdentity `json:"user"`
|
||||
} `json:"identities"`
|
||||
}
|
||||
|
||||
type statusIdentity struct {
|
||||
Status string `json:"status"`
|
||||
Available bool `json:"available"`
|
||||
Verified *bool `json:"verified"`
|
||||
OpenID string `json:"openId"`
|
||||
}
|
||||
56
cmd/build.go
56
cmd/build.go
@@ -19,7 +19,9 @@ import (
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -59,18 +61,28 @@ func HideProfile(hide bool) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// Build constructs the full command tree without executing.
|
||||
// Returns only the cobra.Command; Factory is internal.
|
||||
// Build constructs the full command tree. It also installs registered
|
||||
// plugins and emits the Startup lifecycle event during assembly --
|
||||
// so Plugin.On(Startup) handlers run even if the returned command is
|
||||
// never dispatched. The matching Shutdown event is only emitted by
|
||||
// Execute; callers that bypass Execute will not see Shutdown fire.
|
||||
//
|
||||
// Returns only the cobra.Command; Factory and hook Registry are internal.
|
||||
// Use Execute for the standard production entry point.
|
||||
func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command {
|
||||
_, rootCmd := buildInternal(ctx, inv, opts...)
|
||||
_, rootCmd, _ := buildInternal(ctx, inv, opts...)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// buildInternal is a pure assembly function: it wires the command tree from
|
||||
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
|
||||
// env) belongs in the caller and must be threaded in via BuildOption.
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
|
||||
//
|
||||
// Returns (factory, rootCmd, registry). The registry is nil when plugin
|
||||
// install failed (FailClosed guard installed) or when no plugin produced
|
||||
// hooks; callers that wire Shutdown emit must nil-check before calling
|
||||
// hook.Emit.
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command, *hook.Registry) {
|
||||
// cfg.globals.Profile is left zero here; it's bound to the --profile
|
||||
// flag in RegisterGlobalFlags and filled by cobra's parse step.
|
||||
cfg := &buildConfig{}
|
||||
@@ -124,10 +136,42 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
// Prune commands incompatible with strict mode.
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
return f, rootCmd
|
||||
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
|
||||
if installErr != nil {
|
||||
installPluginInstallErrorGuard(rootCmd, installErr)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
var pluginRules []cmdpolicy.PluginRule
|
||||
var registry *hook.Registry
|
||||
if installResult != nil {
|
||||
pluginRules = installResult.PluginRules
|
||||
registry = installResult.Registry
|
||||
}
|
||||
|
||||
// Policy errors fail-CLOSED when a plugin contributed (security
|
||||
// intent must not be silently dropped); yaml-only errors fail-OPEN
|
||||
// with a warning so a typo can't lock the user out.
|
||||
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
|
||||
if len(pluginRules) > 0 {
|
||||
installPluginConflictGuard(rootCmd, err)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
warnPolicyError(cfg.streams.ErrOut, err)
|
||||
}
|
||||
|
||||
if registry != nil {
|
||||
if err := wireHooks(ctx, rootCmd, registry); err != nil {
|
||||
installPluginLifecycleErrorGuard(rootCmd, err)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
}
|
||||
|
||||
recordInventory(installResult)
|
||||
return f, rootCmd, registry
|
||||
}
|
||||
|
||||
@@ -37,5 +37,6 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -37,8 +38,10 @@ type BindOptions struct {
|
||||
// this flag because its own prompts already require human confirmation.
|
||||
Force bool
|
||||
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateBindFlags
|
||||
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
|
||||
// the account being bound. Populated after resolveAccount; TUI stages
|
||||
@@ -55,7 +58,7 @@ type BindOptions struct {
|
||||
|
||||
// NewCmdConfigBind creates the config bind subcommand.
|
||||
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
|
||||
opts := &BindOptions{Factory: f}
|
||||
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "bind",
|
||||
@@ -102,7 +105,8 @@ 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.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -146,7 +150,7 @@ func configBindRun(opts *BindOptions) error {
|
||||
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
applyPreferences(appConfig, opts)
|
||||
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
|
||||
noticeUserDefaultRisk(opts)
|
||||
|
||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
||||
@@ -201,16 +205,18 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
||||
|
||||
// TUI: prompt for language before any downstream prompts. The source
|
||||
// selection itself may still be skipped entirely if --source or the
|
||||
// env already pinned it.
|
||||
// env already pinned it. Picker offers 2 options (中文 / English) and
|
||||
// drives BOTH opts.Lang (preference) and opts.UILang (TUI rendering).
|
||||
if opts.IsTUI && !opts.langExplicit {
|
||||
lang, err := promptLangSelection("")
|
||||
lang, err := promptLangSelection()
|
||||
if err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return "", output.ErrBare(1)
|
||||
}
|
||||
return "", err
|
||||
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
|
||||
}
|
||||
opts.Lang = lang
|
||||
opts.Lang = string(lang)
|
||||
opts.UILang = lang
|
||||
}
|
||||
|
||||
if explicit != "" {
|
||||
@@ -244,7 +250,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
|
||||
return existingBinding{}, err
|
||||
}
|
||||
if action == "cancel" {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
msg := getBindMsg(opts.UILang)
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
|
||||
return existingBinding{Cancelled: true}, nil
|
||||
}
|
||||
@@ -328,7 +334,7 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
|
||||
if !hasStrictBotLock(previousConfigBytes) {
|
||||
return nil
|
||||
}
|
||||
msg := getBindMsg(opts.Lang)
|
||||
msg := getBindMsg(opts.UILang)
|
||||
return output.ErrWithHint(output.ExitValidation, "bind",
|
||||
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
|
||||
}
|
||||
@@ -346,14 +352,23 @@ func noticeUserDefaultRisk(opts *BindOptions) {
|
||||
if opts.IsTUI || opts.Identity != "user-default" {
|
||||
return
|
||||
}
|
||||
msg := getBindMsg(opts.Lang)
|
||||
msg := getBindMsg(opts.UILang)
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
|
||||
}
|
||||
|
||||
// applyPreferences expands the chosen identity preset into the underlying
|
||||
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
|
||||
// profile's intent survives later changes to global strict-mode settings.
|
||||
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
|
||||
// preferredLang resolves the language to persist: the requested value when set,
|
||||
// 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 {
|
||||
case "bot-only":
|
||||
sm := core.StrictModeBot
|
||||
@@ -364,9 +379,23 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
|
||||
appConfig.StrictMode = &sm
|
||||
appConfig.DefaultAs = core.AsUser
|
||||
}
|
||||
if opts.Lang != "" {
|
||||
appConfig.Lang = opts.Lang
|
||||
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior)
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -392,7 +421,10 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
||||
}
|
||||
|
||||
replaced := previousConfigBytes != nil
|
||||
msg := getBindMsg(opts.Lang)
|
||||
// uiMsg renders human-facing TUI text (stderr success banner). Follows
|
||||
// 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)
|
||||
|
||||
if replaced {
|
||||
@@ -400,7 +432,11 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
||||
}
|
||||
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
|
||||
fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice)
|
||||
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.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
|
||||
// stderr is enough and a machine-readable JSON dump on stdout is just
|
||||
@@ -418,12 +454,17 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
||||
"replaced": replaced,
|
||||
"identity": opts.Identity,
|
||||
}
|
||||
brand := brandDisplay(string(appConfig.Brand), opts.Lang)
|
||||
// JSON "message" follows the effective preference on disk (appConfig.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 {
|
||||
case "bot-only":
|
||||
envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand)
|
||||
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
|
||||
case "user-default":
|
||||
envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display)
|
||||
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(envelope)
|
||||
@@ -460,7 +501,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core
|
||||
|
||||
// tuiSelectSource prompts user to choose bind source.
|
||||
func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
msg := getBindMsg(opts.UILang)
|
||||
var source string
|
||||
|
||||
// Pre-select based on detected env signals
|
||||
@@ -485,7 +526,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title(msg.SelectSource).
|
||||
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))).
|
||||
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.UILang))).
|
||||
Options(
|
||||
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
|
||||
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
|
||||
@@ -507,7 +548,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||
// tuiSelectApp prompts the user to choose from multiple account candidates.
|
||||
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
|
||||
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
msg := getBindMsg(opts.UILang)
|
||||
options := make([]huh.Option[int], 0, len(candidates))
|
||||
for i, c := range candidates {
|
||||
label := c.AppID
|
||||
@@ -521,7 +562,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))).
|
||||
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.UILang))).
|
||||
Options(options...).
|
||||
Value(&selected),
|
||||
),
|
||||
@@ -538,7 +579,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
|
||||
|
||||
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
|
||||
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
msg := getBindMsg(opts.UILang)
|
||||
|
||||
// Build existing binding summary
|
||||
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
|
||||
@@ -590,6 +631,11 @@ func validateBindFlags(opts *BindOptions) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -605,8 +651,8 @@ func validateBindFlags(opts *BindOptions) error {
|
||||
// DescriptionFunc approach breaks here because a longer description on
|
||||
// hover pushes options out of the field's initial viewport.
|
||||
func tuiSelectIdentity(opts *BindOptions) (string, error) {
|
||||
msg := getBindMsg(opts.Lang)
|
||||
brand := brandDisplay(opts.Brand, opts.Lang)
|
||||
msg := getBindMsg(opts.UILang)
|
||||
brand := brandDisplay(opts.Brand, opts.UILang)
|
||||
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
|
||||
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
|
||||
var value string
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
package config
|
||||
|
||||
import "github.com/larksuite/cli/internal/i18n"
|
||||
|
||||
// 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
|
||||
@@ -84,6 +86,11 @@ type bindMsg struct {
|
||||
// require in-flow human confirmation.
|
||||
IdentityEscalationMessage 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{
|
||||
@@ -116,6 +123,8 @@ var bindMsgZh = &bindMsg{
|
||||
|
||||
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
|
||||
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
|
||||
|
||||
LangPreferenceSet: "语言偏好已设置:%s",
|
||||
}
|
||||
|
||||
var bindMsgEn = &bindMsg{
|
||||
@@ -150,10 +159,13 @@ 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.",
|
||||
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",
|
||||
}
|
||||
|
||||
func getBindMsg(lang string) *bindMsg {
|
||||
if lang == "en" {
|
||||
// getBindMsg picks the zh/en TUI bundle; non-English falls back to zh.
|
||||
func getBindMsg(lang i18n.Lang) *bindMsg {
|
||||
if lang.IsEnglish() {
|
||||
return bindMsgEn
|
||||
}
|
||||
return bindMsgZh
|
||||
@@ -164,11 +176,11 @@ func getBindMsg(lang string) *bindMsg {
|
||||
// "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en —
|
||||
// this is the safe default when the brand hasn't been resolved yet (for
|
||||
// example, on the pre-binding source-selection screen).
|
||||
func brandDisplay(brand, lang string) string {
|
||||
func brandDisplay(brand string, lang i18n.Lang) string {
|
||||
if brand == "lark" || brand == "Lark" || brand == "LARK" {
|
||||
return "Lark"
|
||||
}
|
||||
if lang == "en" {
|
||||
if lang.IsEnglish() {
|
||||
return "Feishu"
|
||||
}
|
||||
return "飞书"
|
||||
|
||||
@@ -13,30 +13,46 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// assertExitError checks the full structured error in one assertion.
|
||||
// assertExitError checks the full structured error in one assertion. It
|
||||
// accepts both *output.ExitError (used by output.ErrWithHint) and the
|
||||
// typed validation error — they normalize to the same wantDetail fields.
|
||||
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
|
||||
if errors.As(err, &exitErr) {
|
||||
if exitErr.Code != wantCode {
|
||||
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
|
||||
}
|
||||
if exitErr.Code != wantCode {
|
||||
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)
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
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
|
||||
}
|
||||
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError; error = %v", err, err)
|
||||
}
|
||||
|
||||
// assertEnvelope decodes stdout and checks it matches want exactly — every key
|
||||
@@ -105,14 +121,229 @@ func TestConfigBindCmd_LangDefault(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.Lang != "zh" {
|
||||
t.Errorf("Lang = %q, want default %q", gotOpts.Lang, "zh")
|
||||
if gotOpts.Lang != "" {
|
||||
t.Errorf("Lang = %q, want default %q (unset)", gotOpts.Lang, "")
|
||||
}
|
||||
if gotOpts.langExplicit {
|
||||
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) ──
|
||||
|
||||
func TestConfigBindRun_InvalidSource(t *testing.T) {
|
||||
@@ -408,6 +639,26 @@ func TestConfigBindRun_LarkChannel_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Env template form: secret = "${VAR}" should resolve via the SecretInput
|
||||
// pipeline (same path openclaw uses), so the keychain receives the env value
|
||||
// not the literal template string.
|
||||
func TestConfigBindRun_LarkChannel_EnvTemplate(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("LARK_APP_SECRET", "resolved_via_env")
|
||||
writeLarkChannelFixture(t, fakeHome,
|
||||
`{"accounts":{"app":{"id":"cli_lc_env","secret":"${LARK_APP_SECRET}","tenant":"feishu"}}}`)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// tenant: "lark" should land as Brand("lark"), not normalized to "feishu".
|
||||
func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
@@ -575,8 +826,10 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
// Config errors share ExitAuth (3); the workspace is detected but no
|
||||
// binding exists yet, which is a config error.
|
||||
if cfgErr.Code != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
@@ -1437,10 +1690,14 @@ func TestGetBindMsg_En(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBindMsg_UnknownLang_DefaultsToZh(t *testing.T) {
|
||||
msg := getBindMsg("fr")
|
||||
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
|
||||
t.Errorf("fr (default) SelectSource = %q, want %q", msg.SelectSource, want)
|
||||
func TestGetBindMsg_NonEnLang_FallsBackToZh(t *testing.T) {
|
||||
// Only zh and en TUI bundles exist; any non-English language (canonical
|
||||
// locale, short code, or unrecognized value) falls back to zh.
|
||||
for _, lang := range []i18n.Lang{"fr_fr", "ja_jp", "ko", "unknown", ""} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1603,3 +1860,36 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,13 +312,22 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||
"internal: appID %q does not match config", appID)
|
||||
}
|
||||
if b.cfg.Accounts.App.Secret == "" {
|
||||
if b.cfg.Accounts.App.Secret.IsZero() {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("accounts.app.secret is empty in %s", b.path),
|
||||
"run lark-channel-bridge's setup to populate the app credential")
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(b.cfg.Accounts.App.Secret), b.opts.Factory.Keychain)
|
||||
// Resolve through the same SecretInput pipeline openclaw uses, so
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("failed to resolve appSecret for %s: %v", appID, err),
|
||||
fmt.Sprintf("check appSecret configuration in %s", b.path))
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||
"keychain unavailable: %v", err)
|
||||
@@ -380,10 +389,12 @@ func resolveHermesEnvPath() string {
|
||||
}
|
||||
|
||||
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
|
||||
// config.json. Mirrors the bridge's src/config/paths.ts which hardcodes
|
||||
// ~/.lark-channel/config.json with no env override — multi-instance is not
|
||||
// a supported scenario today.
|
||||
// source config. LARK_CHANNEL_CONFIG lets a host point bind at a projected
|
||||
// single-account config without changing lark-cli's target config directory.
|
||||
func resolveLarkChannelConfigPath() string {
|
||||
if p := os.Getenv("LARK_CHANNEL_CONFIG"); strings.TrimSpace(p) != "" {
|
||||
return expandHome(p)
|
||||
}
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
@@ -173,3 +174,27 @@ func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -95,8 +96,9 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
// Config errors share ExitAuth (3), not ExitValidation.
|
||||
if cfgErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
||||
}
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
||||
@@ -150,8 +152,9 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.Lang != "en" {
|
||||
t.Errorf("expected Lang en, got %s", gotOpts.Lang)
|
||||
// --lang en is canonicalized to en_us in RunE before runF captures opts.
|
||||
if gotOpts.Lang != string(i18n.LangEnUS) {
|
||||
t.Errorf("expected Lang en_us, got %s", gotOpts.Lang)
|
||||
}
|
||||
if !gotOpts.langExplicit {
|
||||
t.Error("expected langExplicit=true when --lang is passed")
|
||||
@@ -172,14 +175,82 @@ func TestConfigInitCmd_LangDefault(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.Lang != "zh" {
|
||||
t.Errorf("expected default Lang zh, got %s", gotOpts.Lang)
|
||||
if gotOpts.Lang != "" {
|
||||
t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang)
|
||||
}
|
||||
if gotOpts.langExplicit {
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -411,3 +482,59 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,5 +52,6 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -31,9 +32,13 @@ type ConfigInitOptions struct {
|
||||
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
|
||||
Brand string
|
||||
New bool
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||
|
||||
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
|
||||
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
|
||||
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
|
||||
@@ -45,7 +50,7 @@ type ConfigInitOptions struct {
|
||||
|
||||
// NewCmdConfigInit creates the config init subcommand.
|
||||
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
|
||||
opts := &ConfigInitOptions{Factory: f}
|
||||
opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
@@ -63,6 +68,9 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
if err := validateInitLang(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := guardAgentWorkspace(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -77,13 +85,33 @@ 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().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.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
||||
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")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
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
|
||||
// Hermes Agent context, because the Agent has already provisioned an app
|
||||
// and 'config bind' is the right tool for hooking lark-cli into it.
|
||||
@@ -131,7 +159,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
|
||||
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
config := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{},
|
||||
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
|
||||
}},
|
||||
}
|
||||
return core.SaveMultiAppConfig(config)
|
||||
@@ -145,7 +173,13 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
|
||||
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
|
||||
}
|
||||
cleanupOldConfig(existing, f, appId)
|
||||
return saveAsOnlyApp(appId, secret, brand, lang)
|
||||
var prior i18n.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.
|
||||
@@ -166,11 +200,10 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
|
||||
}
|
||||
multi.Apps[idx].Users = []core.AppUser{}
|
||||
}
|
||||
// Update existing profile
|
||||
multi.Apps[idx].AppId = appId
|
||||
multi.Apps[idx].AppSecret = secret
|
||||
multi.Apps[idx].Brand = brand
|
||||
multi.Apps[idx].Lang = lang
|
||||
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
|
||||
} else {
|
||||
if findAppIndexByAppID(multi, profileName) >= 0 {
|
||||
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
|
||||
@@ -181,7 +214,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
|
||||
AppId: appId,
|
||||
AppSecret: secret,
|
||||
Brand: brand,
|
||||
Lang: lang,
|
||||
Lang: i18n.Lang(lang),
|
||||
Users: []core.AppUser{},
|
||||
})
|
||||
}
|
||||
@@ -237,7 +270,7 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
|
||||
|
||||
app.AppId = appID
|
||||
app.Brand = brand
|
||||
app.Lang = lang
|
||||
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
|
||||
return core.SaveMultiAppConfig(existing)
|
||||
}
|
||||
|
||||
@@ -282,29 +315,27 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
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()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
||||
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() {
|
||||
savedLang := ""
|
||||
if existing != nil {
|
||||
if app := existing.CurrentAppConfig(""); app != nil {
|
||||
savedLang = app.Lang
|
||||
}
|
||||
}
|
||||
lang, err := promptLangSelection(savedLang)
|
||||
lang, err := promptLangSelection()
|
||||
if err != nil {
|
||||
if err == huh.ErrUserAborted {
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
return err
|
||||
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
|
||||
}
|
||||
opts.Lang = lang
|
||||
opts.Lang = string(lang)
|
||||
opts.UILang = lang
|
||||
}
|
||||
|
||||
msg := getInitMsg(opts.Lang)
|
||||
msg := getInitMsg(opts.UILang)
|
||||
|
||||
// Mode 3: Create new app directly (--new)
|
||||
if opts.New {
|
||||
@@ -323,6 +354,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
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})
|
||||
return nil
|
||||
}
|
||||
@@ -351,6 +383,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
// Existing app with unchanged secret — update app ID and brand only
|
||||
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
|
||||
// Deprecated: legacy *output.ExitError passthrough; removed after typed migration.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
@@ -364,6 +397,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if result.Mode == "existing" {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -450,5 +484,6 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
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()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
)
|
||||
|
||||
type initMsg struct {
|
||||
@@ -26,6 +27,10 @@ type initMsg struct {
|
||||
DetectedLarkTenant string
|
||||
AppCreated 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{
|
||||
@@ -43,6 +48,7 @@ var initMsgZh = &initMsg{
|
||||
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
|
||||
AppCreated: "应用配置成功! App ID: %s",
|
||||
ConfigSaved: "应用配置成功! App ID: %s",
|
||||
LangPreferenceSet: "语言偏好已设置:%s",
|
||||
}
|
||||
|
||||
var initMsgEn = &initMsg{
|
||||
@@ -60,29 +66,27 @@ var initMsgEn = &initMsg{
|
||||
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
|
||||
AppCreated: "App configured! App ID: %s",
|
||||
ConfigSaved: "App configured! App ID: %s",
|
||||
LangPreferenceSet: "Language preference set to: %s",
|
||||
}
|
||||
|
||||
func getInitMsg(lang string) *initMsg {
|
||||
if lang == "en" {
|
||||
// getInitMsg picks the zh/en TUI bundle; non-English falls back to zh.
|
||||
func getInitMsg(lang i18n.Lang) *initMsg {
|
||||
if lang.IsEnglish() {
|
||||
return initMsgEn
|
||||
}
|
||||
return initMsgZh
|
||||
}
|
||||
|
||||
// promptLangSelection shows an interactive language picker and returns the chosen lang code.
|
||||
// savedLang is used as the pre-selected default (from existing config).
|
||||
func promptLangSelection(savedLang string) (string, error) {
|
||||
lang := savedLang
|
||||
if lang != "en" {
|
||||
lang = "zh"
|
||||
}
|
||||
// promptLangSelection shows the 中文/English picker and returns the chosen locale.
|
||||
func promptLangSelection() (i18n.Lang, error) {
|
||||
lang := i18n.LangZhCN
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
huh.NewSelect[i18n.Lang]().
|
||||
Title("Language / 语言").
|
||||
Options(
|
||||
huh.NewOption("中文", "zh"),
|
||||
huh.NewOption("English", "en"),
|
||||
huh.NewOption("中文", i18n.LangZhCN),
|
||||
huh.NewOption("English", i18n.LangEnUS),
|
||||
).
|
||||
Value(&lang),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,8 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
)
|
||||
|
||||
func TestGetInitMsg_Zh(t *testing.T) {
|
||||
@@ -29,7 +31,7 @@ func TestGetInitMsg_En(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetInitMsg_DefaultsToZh(t *testing.T) {
|
||||
for _, lang := range []string{"", "fr", "ja", "unknown"} {
|
||||
for _, lang := range []i18n.Lang{"", "unknown", "xyz", "invalid"} {
|
||||
msg := getInitMsg(lang)
|
||||
if msg != initMsgZh {
|
||||
t.Errorf("getInitMsg(%q) should default to zh", lang)
|
||||
@@ -62,6 +64,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
|
||||
"DetectedLarkTenant": msg.DetectedLarkTenant,
|
||||
"AppCreated": msg.AppCreated,
|
||||
"ConfigSaved": msg.ConfigSaved,
|
||||
"LangPreferenceSet": msg.LangPreferenceSet,
|
||||
}
|
||||
for name, val := range fields {
|
||||
if val == "" {
|
||||
@@ -71,7 +74,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
|
||||
}
|
||||
|
||||
func TestInitMsg_FormatStrings(t *testing.T) {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
|
||||
msg := getInitMsg(lang)
|
||||
// AppCreated and ConfigSaved should contain %s for App ID
|
||||
got := fmt.Sprintf(msg.AppCreated, "cli_test123")
|
||||
@@ -84,3 +87,37 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
73
cmd/config/keychain_downgrade.go
Normal file
73
cmd/config/keychain_downgrade.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build darwin
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"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 output.ErrWithHint(
|
||||
output.ExitAPI,
|
||||
"config",
|
||||
fmt.Sprintf("keychain downgrade failed: %v", err),
|
||||
"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.",
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
28
cmd/config/keychain_downgrade_other.go
Normal file
28
cmd/config/keychain_downgrade_other.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !darwin
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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 output.ErrValidation("keychain-downgrade is only supported on macOS")
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
101
cmd/config/plugins.go
Normal file
101
cmd/config/plugins.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// NewCmdConfigPlugins exposes the plugin inventory diagnostic command.
|
||||
//
|
||||
// `config policy show` is intentionally focused on the user-layer Rule
|
||||
// (Restrict). Plugins also contribute hooks (Observe / Wrap / Lifecycle)
|
||||
// that are not policy gates but still mutate the CLI's runtime behaviour.
|
||||
// This command surfaces both halves so an operator can answer "what is
|
||||
// this binary doing differently from stock lark-cli?" in one place.
|
||||
//
|
||||
// Like config policy show, the dispatch path is exempt from policy
|
||||
// enforcement (see internal/cmdpolicy/diagnostic.go) so it remains
|
||||
// usable under any Rule.
|
||||
func NewCmdConfigPlugins(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "plugins",
|
||||
Hidden: true, // diagnostic-only; kept callable, omitted from --help so it stays out of AI-agent context
|
||||
Short: "Inspect installed plugins and their hook contributions",
|
||||
// Same leaf-level no-op as config policy: the parent `config`
|
||||
// group's PersistentPreRunE requires builtin credential, but
|
||||
// this is a read-only diagnostic that must work everywhere.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newCmdConfigPluginsShow(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCmdConfigPluginsShow(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "List successfully installed plugins, their rules, and registered hooks",
|
||||
Long: `Print every plugin that committed during bootstrap, including:
|
||||
|
||||
- name / version / capabilities (FailurePolicy, Restricts, RequiredCLIVersion)
|
||||
- rule (when the plugin called r.Restrict)
|
||||
- hooks: observers (Before / After), wrappers, lifecycle handlers
|
||||
|
||||
Hooks are attributed by their namespaced name -- the framework prepends
|
||||
the plugin name as the prefix at registration time, so an entry
|
||||
"secaudit.audit-pre" belongs to plugin "secaudit".`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConfigPluginsShow(f)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfigPluginsShow(f *cmdutil.Factory) error {
|
||||
inv := internalplatform.GetActiveInventory()
|
||||
if inv == nil {
|
||||
// Always emit the same field set as the populated branch so
|
||||
// AI agents and CI scripts don't have to branch on whether
|
||||
// `total` is present. `note` makes the unusual state explicit
|
||||
// for human readers.
|
||||
output.PrintJson(f.IOStreams.Out, map[string]any{
|
||||
"plugins": []any{},
|
||||
"total": 0,
|
||||
"note": "no inventory recorded; bootstrap did not finish",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
plugins := make([]map[string]any, 0, len(inv.Plugins))
|
||||
for _, p := range inv.Plugins {
|
||||
entry := map[string]any{
|
||||
"name": p.Name,
|
||||
"version": p.Version,
|
||||
"capabilities": p.Capabilities,
|
||||
}
|
||||
if p.Rule != nil {
|
||||
entry["rule"] = p.Rule
|
||||
}
|
||||
entry["hooks"] = map[string]any{
|
||||
"observers": p.Observers,
|
||||
"wrappers": p.Wrappers,
|
||||
"lifecycle": p.Lifecycles,
|
||||
"count": len(p.Observers) + len(p.Wrappers) + len(p.Lifecycles),
|
||||
}
|
||||
plugins = append(plugins, entry)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]any{
|
||||
"plugins": plugins,
|
||||
"total": len(plugins),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
75
cmd/config/policy.go
Normal file
75
cmd/config/policy.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "policy",
|
||||
Hidden: true,
|
||||
Short: "Inspect the user-layer command policy",
|
||||
// Override parent's RequireBuiltinCredentialProvider check; this
|
||||
// group is read-only diagnostic and must work under any provider.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newCmdConfigPolicyShow(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCmdConfigPolicyShow(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Hidden: true,
|
||||
Short: "Show the active user-layer policy (plugin / yaml / none)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConfigPolicyShow(f)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfigPolicyShow(f *cmdutil.Factory) error {
|
||||
active := cmdpolicy.GetActive()
|
||||
if active == nil {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]any{
|
||||
"source": string(cmdpolicy.SourceNone),
|
||||
"note": "no policy recorded; bootstrap did not run pruning",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
sourceName := ""
|
||||
if active.Source.Kind == cmdpolicy.SourcePlugin {
|
||||
sourceName = active.Source.Name
|
||||
}
|
||||
out := map[string]any{
|
||||
"source": string(active.Source.Kind),
|
||||
"source_name": sourceName,
|
||||
"denied_paths": active.DeniedPaths,
|
||||
}
|
||||
if active.Rule != nil {
|
||||
out["rule"] = map[string]any{
|
||||
"name": active.Rule.Name,
|
||||
"description": active.Rule.Description,
|
||||
"allow": active.Rule.Allow,
|
||||
"deny": active.Rule.Deny,
|
||||
"max_risk": active.Rule.MaxRisk,
|
||||
"identities": active.Rule.Identities,
|
||||
"allow_unannotated": active.Rule.AllowUnannotated,
|
||||
}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, out)
|
||||
return nil
|
||||
}
|
||||
146
cmd/config/policy_test.go
Normal file
146
cmd/config/policy_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func newPolicyTestFactory() (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: cmdutil.NewIOStreams(nil, out, errOut),
|
||||
}
|
||||
return f, out, errOut
|
||||
}
|
||||
|
||||
// `config policy show` reads the active policy recorded by bootstrap.
|
||||
// When nothing is recorded the command must still produce a JSON
|
||||
// envelope with source=none and a note explaining the missing context.
|
||||
func TestConfigPolicyShow_NoActivePolicy(t *testing.T) {
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
if err := runConfigPolicyShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if got["source"] != "none" {
|
||||
t.Errorf("source = %v, want none", got["source"])
|
||||
}
|
||||
if got["note"] == "" || got["note"] == nil {
|
||||
t.Errorf("expected explanatory note when no policy recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// When bootstrap recorded an active plugin Rule, `show` emits the rule
|
||||
// plus its source.
|
||||
func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
rule := &platform.Rule{
|
||||
Name: "secaudit",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: "read",
|
||||
}
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rule: rule,
|
||||
Source: cmdpolicy.ResolveSource{
|
||||
Kind: cmdpolicy.SourcePlugin,
|
||||
Name: "secaudit",
|
||||
},
|
||||
DeniedPaths: 42,
|
||||
})
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
if err := runConfigPolicyShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if got["source"] != "plugin" {
|
||||
t.Errorf("source = %v, want plugin", got["source"])
|
||||
}
|
||||
if got["source_name"] != "secaudit" {
|
||||
t.Errorf("source_name = %v, want secaudit", got["source_name"])
|
||||
}
|
||||
// json.Unmarshal returns float64 for numbers.
|
||||
if got["denied_paths"] != float64(42) {
|
||||
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
|
||||
}
|
||||
ruleMap, ok := got["rule"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("rule field missing or wrong type")
|
||||
}
|
||||
if ruleMap["name"] != "secaudit" {
|
||||
t.Errorf("rule.name = %v", ruleMap["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// `source_name` must be empty when source=yaml. The yaml path is
|
||||
// deliberately not surfaced (matches engine envelope convention,
|
||||
// avoids leaking the user's home dir to AI agents / CI logs). The
|
||||
// rule's "name:" field is the disambiguator users should rely on.
|
||||
func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rule: &platform.Rule{Name: "my-yaml-rule"},
|
||||
Source: cmdpolicy.ResolveSource{
|
||||
Kind: cmdpolicy.SourceYAML,
|
||||
Name: "/Users/alice/.lark-cli/policy.yml",
|
||||
},
|
||||
})
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
if err := runConfigPolicyShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if got["source"] != "yaml" {
|
||||
t.Errorf("source = %v, want yaml", got["source"])
|
||||
}
|
||||
if got["source_name"] != "" {
|
||||
t.Errorf("source_name = %q, want empty (yaml path must not leak)", got["source_name"])
|
||||
}
|
||||
// The path must not appear anywhere in the envelope.
|
||||
if bytes.Contains(out.Bytes(), []byte("/Users/alice")) {
|
||||
t.Errorf("envelope leaked yaml path: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: the parent `config` command declares a PersistentPreRunE
|
||||
// that calls RequireBuiltinCredentialProvider; env credentials cause
|
||||
// it to return external_provider. `config policy` is a diagnostic
|
||||
// group that must not be blocked by that check. The group declares
|
||||
// its own no-op PersistentPreRunE so cobra's "first walking up from
|
||||
// leaf" picks ours over the config parent's.
|
||||
func TestConfigPolicy_BypassesConfigParentPersistentPreRunE(t *testing.T) {
|
||||
f, _, _ := newPolicyTestFactory()
|
||||
group := NewCmdConfigPolicy(f)
|
||||
if group.PersistentPreRunE == nil {
|
||||
t.Fatal("config policy group must declare its own PersistentPreRunE to win over config parent")
|
||||
}
|
||||
if err := group.PersistentPreRunE(group, nil); err != nil {
|
||||
t.Errorf("config policy PersistentPreRunE should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) erro
|
||||
return configRemoveRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
|
||||
return configShowRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ explicit user confirmation — never run on your own initiative.`,
|
||||
|
||||
cmd.Flags().BoolVar(&global, "global", false, "set at global level (applies to all profiles)")
|
||||
cmd.Flags().BoolVar(&reset, "reset", false, "reset profile setting to inherit global")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func diagBuild(domains []string) diagOutput {
|
||||
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
|
||||
continue
|
||||
}
|
||||
for _, scope := range sc.ScopesForIdentity(identity) {
|
||||
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
|
||||
k := methodKey{domain, "shortcut", sc.Command, scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
@@ -169,6 +169,25 @@ func appendUniq(ss []string, s string) []string {
|
||||
return append(ss, s)
|
||||
}
|
||||
|
||||
func TestDiagBuild_ShortcutIncludesConditionalScopes(t *testing.T) {
|
||||
out := diagBuild([]string{"drive"})
|
||||
var sawMetadata, sawDownload bool
|
||||
for _, method := range out.Methods {
|
||||
if method.Domain != "drive" || method.Type != "shortcut" || method.Method != "+status" {
|
||||
continue
|
||||
}
|
||||
if method.Scope == "drive:drive.metadata:readonly" {
|
||||
sawMetadata = true
|
||||
}
|
||||
if method.Scope == "drive:file:download" {
|
||||
sawDownload = true
|
||||
}
|
||||
}
|
||||
if !sawMetadata || !sawDownload {
|
||||
t.Fatalf("drive +status should advertise both metadata and conditional download scopes, saw metadata=%v download=%v", sawMetadata, sawDownload)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snapshot generation ───────────────────────────────────────────────
|
||||
//
|
||||
// Generates a JSON snapshot of all API methods and shortcuts with their
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
@@ -43,6 +43,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmd.Flags().BoolVar(&opts.Offline, "offline", false, "skip network checks (only verify local state)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -50,7 +51,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
|
||||
// checkResult represents one diagnostic check.
|
||||
type checkResult struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // "pass", "fail", "skip"
|
||||
Status string `json:"status"` // "pass", "warn", "fail", "skip"
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
@@ -117,59 +118,31 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
|
||||
ep := core.ResolveEndpoints(cfg.Brand)
|
||||
|
||||
// ── 3. Token exists ──
|
||||
if cfg.UserOpenId == "" {
|
||||
checks = append(checks, fail("token_exists", "no user logged in", "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
|
||||
if stored == nil {
|
||||
checks = append(checks, fail("token_exists", "no token in keychain for "+cfg.UserOpenId, "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
checks = append(checks, pass("token_exists", fmt.Sprintf("token found for %s (%s)", cfg.UserName, cfg.UserOpenId)))
|
||||
|
||||
// ── 4. Token local validity ──
|
||||
status := larkauth.TokenStatus(stored)
|
||||
switch status {
|
||||
case "valid":
|
||||
checks = append(checks, pass("token_local", "token valid, expires "+time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)))
|
||||
case "needs_refresh":
|
||||
checks = append(checks, pass("token_local", "token needs refresh (will auto-refresh on next call)"))
|
||||
default: // expired
|
||||
checks = append(checks, fail("token_local", "token expired", "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
|
||||
// ── 5. Token server verification ──
|
||||
if opts.Offline {
|
||||
checks = append(checks, skip("token_verified", "skipped (--offline)"))
|
||||
// ── 3. Identity readiness ──
|
||||
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
|
||||
checks = append(checks,
|
||||
identityCheck("bot_identity", diagnostics.Bot),
|
||||
identityCheck("user_identity", diagnostics.User),
|
||||
)
|
||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||
} else {
|
||||
httpClient := mustHTTPClient(f)
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
checks = append(checks, fail("token_verified", "cannot obtain valid token: "+err.Error(), "run: lark-cli auth login --help"))
|
||||
} else {
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
checks = append(checks, fail("token_verified", "SDK init failed: "+err.Error(), ""))
|
||||
} else if err := larkauth.VerifyUserToken(opts.Ctx, sdk, token); err != nil {
|
||||
checks = append(checks, fail("token_verified", "server rejected token: "+err.Error(), "run: lark-cli auth login --help"))
|
||||
} else {
|
||||
checks = append(checks, pass("token_verified", "server confirmed token is valid"))
|
||||
}
|
||||
}
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||
}
|
||||
|
||||
// ── 6 & 7. Endpoint reachability ──
|
||||
// ── 4 & 5. Endpoint reachability ──
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
|
||||
func identityCheck(name string, id identitydiag.Identity) checkResult {
|
||||
if id.Available {
|
||||
return pass(name, id.Message)
|
||||
}
|
||||
return warn(name, id.Message, id.Hint)
|
||||
}
|
||||
|
||||
// networkChecks probes Open API and MCP endpoints concurrently.
|
||||
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
|
||||
if opts.Offline {
|
||||
@@ -231,15 +204,6 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mustHTTPClient returns f.HttpClient() or a default client.
|
||||
func mustHTTPClient(f *cmdutil.Factory) *http.Client {
|
||||
c, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// checkCLIUpdate actively queries the npm registry for the latest version.
|
||||
// Unlike the root-level async check, this does a synchronous fetch with timeout
|
||||
// and works regardless of build version (dev builds included).
|
||||
@@ -252,7 +216,7 @@ func checkCLIUpdate() []checkResult {
|
||||
if update.IsNewer(latest, current) {
|
||||
return []checkResult{warn("cli_update",
|
||||
fmt.Sprintf("%s → %s available", current, latest),
|
||||
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
|
||||
"run: lark-cli update")}
|
||||
}
|
||||
return []checkResult{pass("cli_update", latest+" (up to date)")}
|
||||
}
|
||||
|
||||
@@ -95,3 +95,59 @@ func TestNetworkChecks_Offline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
err := doctorRun(&DoctorOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Offline: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("doctorRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if !got.OK {
|
||||
t.Fatalf("ok = false, want true; checks = %#v", got.Checks)
|
||||
}
|
||||
assertCheck(t, got.Checks, "bot_identity", "pass")
|
||||
assertCheck(t, got.Checks, "user_identity", "warn")
|
||||
assertCheck(t, got.Checks, "identity_ready", "pass")
|
||||
}
|
||||
|
||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
if check.Name == name {
|
||||
if check.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %q not found in %#v", name, checks)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -14,12 +18,49 @@ import (
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// enrichMissingScopeError preserves the original need_user_authorization
|
||||
// message and appends a scope hint when the current command declares the
|
||||
// required scopes locally.
|
||||
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
|
||||
// "current command requires scope(s): X, Y" hint when the underlying error is
|
||||
// a need_user_authorization signal AND the current command declares scopes
|
||||
// locally (via shortcut registration or service-method metadata).
|
||||
//
|
||||
// Stage-1: this typed path is dormant — no production code returns a typed
|
||||
// *errs.AuthenticationError. Kept so per-domain stage-2 migrations can plug
|
||||
// in without re-architecting. The active stage-1 path is
|
||||
// enrichMissingScopeError below, which operates on legacy *output.ExitError.
|
||||
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. Matches pre-PR behaviour byte-for-byte; lives on the legacy
|
||||
// envelope path until per-domain stage-2 typed migration.
|
||||
//
|
||||
// Deprecated: stage-1 enrichment for the legacy *output.ExitError surface.
|
||||
// Stage-2 typed migration will lift this into AuthenticationError.Hint on
|
||||
// the typed envelope via applyNeedAuthorizationHint and remove this helper.
|
||||
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if exitErr == nil || exitErr.Detail == nil {
|
||||
return
|
||||
@@ -27,12 +68,10 @@ func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
|
||||
return
|
||||
}
|
||||
|
||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
||||
if len(scopes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
|
||||
if exitErr.Detail.Hint == "" {
|
||||
exitErr.Detail.Hint = scopeHint
|
||||
@@ -75,7 +114,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
||||
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
continue
|
||||
}
|
||||
scopes := sc.ScopesForIdentity(identity)
|
||||
scopes := sc.DeclaredScopesForIdentity(identity)
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
cmd.Flags().StringVar(&domain, "domain", "", "API domain")
|
||||
_ = cmd.Flags().MarkHidden("domain")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ Use 'event schema <EventKey>' for parameter details.`,
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
}
|
||||
return json.RawMessage(resp.RawBody), nil
|
||||
|
||||
@@ -88,6 +88,7 @@ func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit status as JSON (for AI / scripts)")
|
||||
cmd.Flags().BoolVar(¤t, "current", false, "Only show status for the current profile's app")
|
||||
cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "Exit 2 when any orphan bus is detected (default: always exit 0)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ Exit code: 2 if any target was refused or errored, 0 otherwise.
|
||||
cmd.Flags().BoolVar(&o.all, "all", false, "Stop all running bus daemons")
|
||||
cmd.Flags().BoolVar(&o.force, "force", false, "Stop even with active consumers; on shutdown-timeout also SIGKILL the bus")
|
||||
cmd.Flags().BoolVar(&o.asJSON, "json", false, "Emit results as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestIsSingleAppMode_MultiApp(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildInternal_HideProfileOption(t *testing.T) {
|
||||
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
|
||||
_, root, _ := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
|
||||
|
||||
flag := root.PersistentFlags().Lookup("profile")
|
||||
if flag == nil {
|
||||
@@ -90,7 +90,7 @@ func TestBuildInternal_HideProfileOption(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
|
||||
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
|
||||
_, root, _ := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
|
||||
|
||||
flag := root.PersistentFlags().Lookup("profile")
|
||||
if flag == nil {
|
||||
|
||||
274
cmd/platform_bootstrap.go
Normal file
274
cmd/platform_bootstrap.go
Normal file
@@ -0,0 +1,274 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// userPolicyFileName is the conventional filename for the user-layer Rule.
|
||||
// Lives under ~/.lark-cli/ to match the rest of the CLI's user-state
|
||||
// directory.
|
||||
const userPolicyFileName = "policy.yml"
|
||||
|
||||
// applyUserPolicyPruning resolves the user-layer Rule from plugin
|
||||
// contributions and/or ~/.lark-cli/policy.yml and installs denyStubs
|
||||
// for commands it rejects.
|
||||
//
|
||||
// Missing yaml is not an error -- the CLI runs with no user-layer
|
||||
// restriction. A malformed Rule (bad MaxRisk enum, malformed glob, etc.)
|
||||
// surfaces via the returned error; the caller decides how to handle it.
|
||||
//
|
||||
// pluginRules carries Plugin.Restrict() contributions collected from
|
||||
// the InstallAll phase; nil/empty is fine.
|
||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
||||
yamlPath, err := userPolicyPath()
|
||||
if err != 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.
|
||||
yamlPath = ""
|
||||
}
|
||||
|
||||
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,
|
||||
YAMLRule: yamlRule,
|
||||
YAMLPath: yamlPath,
|
||||
})
|
||||
if err != nil {
|
||||
cmdpolicy.SetActive(nil)
|
||||
return err
|
||||
}
|
||||
if rule == nil {
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||
return nil
|
||||
}
|
||||
|
||||
engine := cmdpolicy.New(rule)
|
||||
decisions := engine.EvaluateAll(rootCmd)
|
||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
|
||||
cmdpolicy.Apply(rootCmd, denied)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rule: rule,
|
||||
Source: source,
|
||||
DeniedPaths: len(denied),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// installPluginsAndHooks runs the InstallAll phase on the globally-
|
||||
// registered plugins, returning the Plugin.Restrict contributions for
|
||||
// cmdpolicy and the populated hook.Registry for the runtime wrapper.
|
||||
// Errors from FailClosed plugins propagate; FailOpen failures are
|
||||
// warned to errOut and the loop continues.
|
||||
func installPluginsAndHooks(errOut io.Writer) (*internalplatform.InstallResult, error) {
|
||||
plugins := platform.RegisteredPlugins()
|
||||
if len(plugins) == 0 {
|
||||
return &internalplatform.InstallResult{Registry: nil}, nil
|
||||
}
|
||||
return internalplatform.InstallAll(plugins, errOut)
|
||||
}
|
||||
|
||||
// recordInventory builds and stores the plugin inventory snapshot for
|
||||
// diagnostic commands (config plugins show) to read at runtime. Called
|
||||
// once from build.go after applyUserPolicyPruning + wireHooks succeed.
|
||||
func recordInventory(installResult *internalplatform.InstallResult) {
|
||||
if installResult == nil {
|
||||
internalplatform.SetActiveInventory(nil)
|
||||
return
|
||||
}
|
||||
pluginSrcs := make([]internalplatform.PluginInventorySource, 0, len(installResult.Plugins))
|
||||
for _, p := range installResult.Plugins {
|
||||
pluginSrcs = append(pluginSrcs, internalplatform.PluginInventorySource{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
Capabilities: p.Capabilities,
|
||||
})
|
||||
}
|
||||
ruleSrcs := make([]internalplatform.RuleInventorySource, 0, len(installResult.PluginRules))
|
||||
for _, r := range installResult.PluginRules {
|
||||
if r.Rule == nil {
|
||||
continue
|
||||
}
|
||||
idents := make([]string, len(r.Rule.Identities))
|
||||
for i, id := range r.Rule.Identities {
|
||||
idents[i] = string(id)
|
||||
}
|
||||
ruleSrcs = append(ruleSrcs, internalplatform.RuleInventorySource{
|
||||
PluginName: r.PluginName,
|
||||
Allow: r.Rule.Allow,
|
||||
Deny: r.Rule.Deny,
|
||||
MaxRisk: string(r.Rule.MaxRisk),
|
||||
Identities: idents,
|
||||
RuleName: r.Rule.Name,
|
||||
Desc: r.Rule.Description,
|
||||
AllowUnannotated: r.Rule.AllowUnannotated,
|
||||
})
|
||||
}
|
||||
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
|
||||
}
|
||||
|
||||
// wireHooks installs Observer/Wrapper hooks onto every runnable command
|
||||
// and emits the Startup lifecycle event. The registry may be nil when
|
||||
// no plugin contributed any hook -- the function short-circuits in
|
||||
// that case to avoid useless RunE wrapping.
|
||||
func wireHooks(ctx context.Context, rootCmd *cobra.Command, reg *hook.Registry) error {
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
hook.Install(rootCmd, reg, cobraCommandViewSource{})
|
||||
return hook.Emit(ctx, reg, platform.Startup, nil)
|
||||
}
|
||||
|
||||
// cobraCommandViewSource is the default CommandViewSource: it returns a
|
||||
// live view over the *cobra.Command. Strict-mode's Remove+Add stub
|
||||
// (cmd/prune.go::strictModeStubFrom) explicitly forwards the original
|
||||
// annotations + Short/Long so the live view keeps reporting Risk /
|
||||
// Identities / Domain through the replacement. User-layer policy
|
||||
// (cmdpolicy/apply.go::installDenyStub) mutates in place, preserving
|
||||
// metadata trivially.
|
||||
type cobraCommandViewSource struct{}
|
||||
|
||||
func (cobraCommandViewSource) View(cmd *cobra.Command) platform.CommandView {
|
||||
return cobraCommandView{cmd: cmd}
|
||||
}
|
||||
|
||||
// cobraCommandView adapts *cobra.Command to the CommandView interface.
|
||||
type cobraCommandView struct {
|
||||
cmd *cobra.Command
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Path() string {
|
||||
return cmdpolicy.CanonicalPath(v.cmd)
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Domain() string {
|
||||
for c := v.cmd; c != nil; c = c.Parent() {
|
||||
if c.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if v, ok := c.Annotations["cmdmeta.domain"]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Risk() (platform.Risk, bool) {
|
||||
for c := v.cmd; c != nil; c = c.Parent() {
|
||||
if c.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if r, ok := c.Annotations["risk_level"]; ok && r != "" {
|
||||
return platform.Risk(r), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Identities() []platform.Identity {
|
||||
for c := v.cmd; c != nil; c = c.Parent() {
|
||||
if c.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if raw, ok := c.Annotations["lark:supportedIdentities"]; ok && raw != "" {
|
||||
parts := splitCSV(raw)
|
||||
out := make([]platform.Identity, len(parts))
|
||||
for i, p := range parts {
|
||||
out[i] = platform.Identity(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Annotation(key string) (string, bool) {
|
||||
if v.cmd.Annotations == nil {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.cmd.Annotations[key]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// splitCSV is a tiny csv-without-quotes helper. The
|
||||
// lark:supportedIdentities annotation is always plain
|
||||
// "user" / "bot" / "user,bot" without escaping.
|
||||
func splitCSV(s string) []string {
|
||||
out := []string{}
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == ',' {
|
||||
out = append(out, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
out = append(out, s[start:])
|
||||
return out
|
||||
}
|
||||
|
||||
// userPolicyPath returns the path of <baseConfigDir>/policy.yml.
|
||||
//
|
||||
// The base directory honours LARKSUITE_CLI_CONFIG_DIR (via
|
||||
// core.GetBaseConfigDir) so that test isolation, container deployments
|
||||
// and per-Agent config overrides all see a consistent policy location.
|
||||
// Using vfs.UserHomeDir directly here would silently bypass the env
|
||||
// override and route every test through the real ~/.lark-cli.
|
||||
//
|
||||
// The error return is retained for caller compatibility but is always
|
||||
// nil today: GetBaseConfigDir falls back to a relative ".lark-cli" when
|
||||
// the home dir can't be resolved, and the resolver already treats a
|
||||
// missing file as "no policy".
|
||||
func userPolicyPath() (string, error) {
|
||||
return filepath.Join(core.GetBaseConfigDir(), userPolicyFileName), nil
|
||||
}
|
||||
|
||||
// warnPolicyError writes a one-line stderr warning when the user policy
|
||||
// fails to load. V1 yaml errors are fail-OPEN -- the CLI keeps running
|
||||
// without policy enforcement so the user can fix the typo. Plugin-supplied
|
||||
// rules are fail-CLOSED instead because integrators take a code-level
|
||||
// responsibility for them.
|
||||
//
|
||||
// Wrapped errors may carry the absolute policy path (os.PathError); fold
|
||||
// the home prefix to "~" before emitting so stderr piped into agents /
|
||||
// CI logs does not leak the user's home directory.
|
||||
func warnPolicyError(errOut io.Writer, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(errOut, "warning: user policy not applied: %s\n", redactHome(err.Error()))
|
||||
}
|
||||
|
||||
func redactHome(s string) string {
|
||||
if home, err := vfs.UserHomeDir(); err == nil && home != "" {
|
||||
s = strings.ReplaceAll(s, home, "~")
|
||||
}
|
||||
return s
|
||||
}
|
||||
268
cmd/platform_bootstrap_test.go
Normal file
268
cmd/platform_bootstrap_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// tmpHome creates a tempdir, points $HOME at it, and returns the path to
|
||||
// the ~/.lark-cli/ subdirectory (created). The HOME env var is restored
|
||||
// when the test ends.
|
||||
//
|
||||
// LARKSUITE_CLI_CONFIG_DIR is force-set to the same path. Without that
|
||||
// override, a developer running the tests with a personal
|
||||
// LARKSUITE_CLI_CONFIG_DIR exported in their shell (or a CI runner with
|
||||
// a baked-in value) would resolve userPolicyPath() to their real
|
||||
// machine and bleed unrelated yaml into the test fixtures. With the
|
||||
// override pinned here, the test is hermetic regardless of the host
|
||||
// environment.
|
||||
func tmpHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("USERPROFILE", dir) // Windows fallback for os.UserHomeDir
|
||||
cfgDir := filepath.Join(dir, ".lark-cli")
|
||||
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", cfgDir)
|
||||
return cfgDir
|
||||
}
|
||||
|
||||
// writePolicy writes a policy.yml into the user config dir.
|
||||
func writePolicy(t *testing.T, cfgDir string, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write policy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeTree builds a minimal command tree with the same shape the real
|
||||
// CLI exposes for these tests: lark-cli has a docs group with +fetch and
|
||||
// +update, and an im group with +send. Each leaf has its risk_level set
|
||||
// so MaxRisk filtering exercises a real path.
|
||||
func fakeTree(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
|
||||
docs := &cobra.Command{Use: "docs"}
|
||||
root.AddCommand(docs)
|
||||
addLeaf(docs, "+fetch", "read")
|
||||
addLeaf(docs, "+update", "write")
|
||||
addLeaf(docs, "+delete-doc", "high-risk-write")
|
||||
|
||||
im := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(im)
|
||||
addLeaf(im, "+send", "write")
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func addLeaf(parent *cobra.Command, use, risk string) {
|
||||
leaf := &cobra.Command{
|
||||
Use: use,
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
cmdutil.SetRisk(leaf, risk)
|
||||
parent.AddCommand(leaf)
|
||||
}
|
||||
|
||||
// findLeaf walks the tree by Use names.
|
||||
func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
cur := parent
|
||||
for _, n := range names {
|
||||
var next *cobra.Command
|
||||
for _, c := range cur.Commands() {
|
||||
if c.Use == n {
|
||||
next = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
t.Fatalf("child %q not found under %q", n, cur.Use)
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// Happy path: a valid policy.yml denies one specific command. The denied
|
||||
// command's RunE returns a typed ExitError envelope; allowed commands are
|
||||
// untouched.
|
||||
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
writePolicy(t, cfgDir, `
|
||||
name: test-policy
|
||||
allow: ["docs/**", "contact/**"]
|
||||
deny: ["docs/+delete-doc"]
|
||||
max_risk: write
|
||||
`)
|
||||
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
t.Fatalf("apply policy: %v", err)
|
||||
}
|
||||
|
||||
// docs/+delete-doc must be denied (Deny match).
|
||||
deleteCmd := findLeaf(t, root, "docs", "+delete-doc")
|
||||
if !deleteCmd.Hidden {
|
||||
t.Errorf("+delete-doc should be hidden after pruning")
|
||||
}
|
||||
err := deleteCmd.RunE(deleteCmd, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("+delete-doc RunE should return an error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
|
||||
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok || detail["reason_code"] != "command_denylisted" {
|
||||
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
|
||||
}
|
||||
|
||||
// im/+send must be denied (domain not in Allow).
|
||||
send := findLeaf(t, root, "im", "+send")
|
||||
if !send.Hidden {
|
||||
t.Errorf("im/+send should be hidden (not in Allow)")
|
||||
}
|
||||
|
||||
// docs/+update must stay alive (domain matches, risk within max).
|
||||
update := findLeaf(t, root, "docs", "+update")
|
||||
if update.Hidden {
|
||||
t.Errorf("docs/+update should remain visible")
|
||||
}
|
||||
if err := update.RunE(update, nil); err != nil {
|
||||
t.Errorf("docs/+update RunE should succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Missing file means no pruning -- the CLI runs unrestricted with the
|
||||
// full command surface. This is the default case for users who haven't
|
||||
// opted into pruning.
|
||||
func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
|
||||
tmpHome(t) // home set but no policy.yml written
|
||||
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
t.Fatalf("missing policy should not error, got %v", err)
|
||||
}
|
||||
|
||||
// Every leaf must remain non-Hidden.
|
||||
for _, sub := range []string{"+fetch", "+update", "+delete-doc"} {
|
||||
cmd := findLeaf(t, root, "docs", sub)
|
||||
if cmd.Hidden {
|
||||
t.Errorf("%s should not be Hidden when no policy file exists", sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid yaml content (parse error) surfaces as an error from the
|
||||
// wiring. The build path then decides whether to fail-open or
|
||||
// fail-closed; the wiring itself stays neutral.
|
||||
func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
writePolicy(t, cfgDir, "::: not yaml :::")
|
||||
|
||||
root := fakeTree(t)
|
||||
err := applyUserPolicyPruning(root, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("malformed yaml should produce an error")
|
||||
}
|
||||
}
|
||||
|
||||
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
|
||||
// Resolve and produces an error. This is the safety contract: a typo in
|
||||
// the rule must not silently lower the pruning bar.
|
||||
func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
writePolicy(t, cfgDir, "max_risk: nukem\n")
|
||||
|
||||
root := fakeTree(t)
|
||||
err := applyUserPolicyPruning(root, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid MaxRisk should produce an error")
|
||||
}
|
||||
}
|
||||
|
||||
// warnPolicyError emits to the supplied writer when err is non-nil and
|
||||
// stays silent for nil. Verifies the build.go fail-open behaviour can be
|
||||
// observed by users.
|
||||
func TestWarnPolicyError(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
warnPolicyError(&buf, nil)
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("warnPolicyError with nil err should write nothing, got %q", buf.String())
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
warnPolicyError(&buf, errors.New("boom"))
|
||||
if buf.String() != "warning: user policy not applied: boom\n" {
|
||||
t.Fatalf("warnPolicyError output = %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end through buildInternal: when a valid policy.yml exists in
|
||||
// HOME, building the real command tree applies pruning to it. This is
|
||||
// the "actually integrated" test -- it exercises the wiring point in
|
||||
// build.go itself, not just the helper.
|
||||
func TestBuildInternal_appliesPolicyToRealTree(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
// Deny one specific shortcut path that we know exists in the real
|
||||
// service tree -- we cannot enumerate it from a unit test, so we
|
||||
// use an Allow-list that matches nothing to deny everything except
|
||||
// the root, and then verify ANY non-root command was hidden.
|
||||
writePolicy(t, cfgDir, `
|
||||
name: deny-everything
|
||||
deny: ["**"]
|
||||
`)
|
||||
|
||||
root := Build(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
// Find any leaf and verify it was hidden.
|
||||
var foundHidden bool
|
||||
walk(root, func(c *cobra.Command) {
|
||||
if c.HasParent() && c.Runnable() && c.Hidden {
|
||||
foundHidden = true
|
||||
}
|
||||
})
|
||||
if !foundHidden {
|
||||
t.Fatalf("expected at least one runnable command to be Hidden after deny=** policy")
|
||||
}
|
||||
|
||||
// Root itself must stay alive.
|
||||
if root.Hidden {
|
||||
t.Errorf("root command must not be Hidden even under deny-everything policy")
|
||||
}
|
||||
}
|
||||
|
||||
func walk(cmd *cobra.Command, fn func(*cobra.Command)) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
fn(cmd)
|
||||
for _, c := range cmd.Commands() {
|
||||
walk(c, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// buildInvocationForTest returns a minimal cmdutil.InvocationContext so
|
||||
// build.go's pure-assembly path can construct a tree without touching
|
||||
// real config / credentials. Profile name is the empty default.
|
||||
func buildInvocationForTest(t *testing.T) cmdutil.InvocationContext {
|
||||
t.Helper()
|
||||
return cmdutil.InvocationContext{}
|
||||
}
|
||||
279
cmd/platform_guards.go
Normal file
279
cmd/platform_guards.go
Normal file
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// installFatalGuard wires a fail-closed guard at every cobra dispatch
|
||||
// path on rootCmd. Used by the three abort-side fatal paths:
|
||||
//
|
||||
// - FailClosed plugin install failure (installPluginInstallErrorGuard)
|
||||
// - Plugin Restrict conflict (installPluginConflictGuard)
|
||||
// - Startup lifecycle handler failure (installPluginLifecycleErrorGuard)
|
||||
//
|
||||
// **Why we walk the tree rather than set PersistentPreRunE on root**:
|
||||
// cobra's PersistentPreRunE has "first PersistentPreRunE wins"
|
||||
// semantics -- the lookup starts at the invoked command and walks UP,
|
||||
// stopping at the first non-nil PersistentPreRunE. Subcommands that
|
||||
// declare their own PersistentPreRunE (cmd/auth/auth.go and
|
||||
// cmd/config/config.go both do) would shadow root's, letting a
|
||||
// fail-closed condition silently bypass via `lark-cli auth foo`.
|
||||
//
|
||||
// The fix: replace the RunE of every runnable command with one that
|
||||
// returns makeErr(). Subcommands cannot bypass because the dispatch
|
||||
// lands directly on their RunE, which now carries the guard.
|
||||
//
|
||||
// makeErr is called for every guarded dispatch; it must return a fresh
|
||||
// *output.ExitError each time (the envelope writer mutates a few fields
|
||||
// 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) {
|
||||
// Two cobra subcommands are injected lazily at Execute() time and
|
||||
// would otherwise slip past walkGuard. We pre-register both so
|
||||
// walkGuard catches them.
|
||||
//
|
||||
// - "completion" (user-visible): InitDefaultCompletionCmd
|
||||
// - "__complete" (internal shell-completion RPC): no public
|
||||
// constructor; we add our own stub with the same name. cobra's
|
||||
// internal initCompleteCmd checks for an existing "__complete"
|
||||
// and skips registration if found, so our stub stays in place.
|
||||
// (Cobra dispatches the "__completeNoDesc" alias through the
|
||||
// same RunE, so guarding "__complete" covers both.)
|
||||
rootCmd.InitDefaultCompletionCmd()
|
||||
alreadyPresent := false
|
||||
for _, c := range rootCmd.Commands() {
|
||||
if c.Name() == "__complete" {
|
||||
alreadyPresent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !alreadyPresent {
|
||||
rootCmd.AddCommand(&cobra.Command{
|
||||
Use: "__complete",
|
||||
Hidden: true,
|
||||
RunE: func(*cobra.Command, []string) error { return makeErr() },
|
||||
})
|
||||
}
|
||||
|
||||
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
|
||||
cmd.SilenceUsage = true
|
||||
return makeErr()
|
||||
}
|
||||
rootCmd.PersistentPreRun = nil
|
||||
walkGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
|
||||
// failure as a structured plugin_install envelope before any command
|
||||
// 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) {
|
||||
makeErr := func() *output.ExitError {
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if errors.As(installErr, &pi) {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_install",
|
||||
Message: pi.Error(),
|
||||
Detail: map[string]any{
|
||||
"plugin": pi.PluginName,
|
||||
"reason_code": pi.ReasonCode,
|
||||
"reason": pi.Reason,
|
||||
},
|
||||
},
|
||||
Err: installErr,
|
||||
}
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_install",
|
||||
Message: installErr.Error(),
|
||||
Detail: map[string]any{
|
||||
"reason_code": internalplatform.ReasonInstallFailed,
|
||||
},
|
||||
},
|
||||
Err: installErr,
|
||||
}
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
|
||||
// error (single plugin invalid Rule or multiple plugins each contributing
|
||||
// Restrict). The design separates the envelope type:
|
||||
//
|
||||
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
|
||||
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
|
||||
//
|
||||
// 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) {
|
||||
makeErr := func() *output.ExitError {
|
||||
envelopeType := "plugin_install"
|
||||
reasonCode := internalplatform.ReasonInvalidRule
|
||||
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
|
||||
envelopeType = "plugin_conflict"
|
||||
reasonCode = internalplatform.ReasonMultipleRestricts
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: envelopeType,
|
||||
Message: err.Error(),
|
||||
Detail: map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
},
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
||||
// failure as a plugin_lifecycle envelope. The reason_code splits
|
||||
// returned-error vs panic so consumers (audit / on-call) can tell the
|
||||
// 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) {
|
||||
makeErr := func() *output.ExitError {
|
||||
reasonCode := "lifecycle_failed"
|
||||
detail := map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
}
|
||||
var le *hook.LifecycleError
|
||||
if errors.As(err, &le) {
|
||||
if le.Panic {
|
||||
reasonCode = "lifecycle_panic"
|
||||
}
|
||||
detail = map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
"hook_name": le.HookName,
|
||||
"event": "startup",
|
||||
}
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_lifecycle",
|
||||
Message: err.Error(),
|
||||
Detail: detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// walkGuard recurses through cmd's subtree and installs the guard at
|
||||
// EVERY level cobra might dispatch to. The cobra execution order is:
|
||||
//
|
||||
// 1. PersistentPreRunE (looked up from leaf, walking up; "first wins")
|
||||
// 2. PreRunE
|
||||
// 3. RunE
|
||||
// 4. PostRunE
|
||||
// 5. PersistentPostRunE
|
||||
//
|
||||
// A subcommand that declares its own PersistentPreRunE (cmd/auth and
|
||||
// cmd/config both do) would not only shadow root's PersistentPreRunE
|
||||
// -- if that PreRunE itself returns an error (e.g. auth's
|
||||
// external_provider check), the user sees THAT error instead of
|
||||
// our plugin_install envelope, even if RunE was guarded.
|
||||
//
|
||||
// To close every dispatch hole we replace:
|
||||
// - every command's PersistentPreRunE (including non-runnable groups)
|
||||
// - every runnable command's PreRunE and RunE
|
||||
//
|
||||
// This way the very first non-nil step in cobra's chain is always our
|
||||
// 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) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
// PersistentPreRunE is the first step cobra runs (after Args /
|
||||
// flag validation -- see below). Set it on every command (root
|
||||
// included) so cobra's "first wins" walk-up always finds OUR
|
||||
// PersistentPreRunE before hitting any subcommand's pre-existing
|
||||
// one.
|
||||
cmd.PersistentPreRunE = func(c *cobra.Command, args []string) error {
|
||||
c.SilenceUsage = true
|
||||
return makeErr()
|
||||
}
|
||||
cmd.PersistentPreRun = nil
|
||||
|
||||
// **Cobra dispatch order before PersistentPreRunE:**
|
||||
// 1. ValidateArgs(cmd.Args) -- can return arg error
|
||||
// 2. ParsePersistentFlags / ParseFlags -- can return flag error
|
||||
// 3. Find legacyArgs check for unknown-command at root
|
||||
// 4. PersistentPreRunE / PreRunE / RunE
|
||||
// 5. Non-runnable groups fall through to help (PreRunE skipped)
|
||||
//
|
||||
// We neutralise each step:
|
||||
// - Args = ArbitraryArgs -> ValidateArgs no-op. **Not nil**:
|
||||
// cobra falls back to legacyArgs
|
||||
// when Args==nil, which returns an
|
||||
// unknown-command error during Find
|
||||
// BEFORE PersistentPreRunE runs.
|
||||
// ArbitraryArgs explicitly accepts
|
||||
// everything, suppressing that path.
|
||||
// - DisableFlagParsing -> ParseFlags skipped (and legacy
|
||||
// "unknown flag" suppressed)
|
||||
// - PreRunE / RunE on EVERY -> Even non-runnable groups now run
|
||||
// command (not just leaves) the guard instead of showing help
|
||||
//
|
||||
// Setting RunE on a parent group flips Runnable() to true, so
|
||||
// cobra dispatches to it (and our guard fires) rather than calling
|
||||
// the help command on a "help-only" group.
|
||||
cmd.Args = cobra.ArbitraryArgs
|
||||
cmd.DisableFlagParsing = true
|
||||
cmd.PreRunE = func(c *cobra.Command, args []string) error {
|
||||
c.SilenceUsage = true
|
||||
return makeErr()
|
||||
}
|
||||
cmd.PreRun = nil
|
||||
cmd.RunE = func(*cobra.Command, []string) error { return makeErr() }
|
||||
cmd.Run = nil
|
||||
for _, c := range cmd.Commands() {
|
||||
walkGuard(c, makeErr)
|
||||
}
|
||||
}
|
||||
208
cmd/platform_guards_test.go
Normal file
208
cmd/platform_guards_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// failClosedAbortingPlugin returns a PluginInstallError on Install,
|
||||
// declaring FailClosed so InstallAll surfaces the error.
|
||||
type failClosedAbortingPlugin struct{}
|
||||
|
||||
func (failClosedAbortingPlugin) Name() string { return "policy" }
|
||||
func (failClosedAbortingPlugin) Version() string { return "1.0.0" }
|
||||
func (failClosedAbortingPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (failClosedAbortingPlugin) Install(platform.Registrar) error {
|
||||
return errors.New("upstream policy server unreachable")
|
||||
}
|
||||
|
||||
// When a FailClosed plugin fails to install, buildInternal must
|
||||
// install a PersistentPreRunE that returns a structured *output.ExitError.
|
||||
// The user must NEVER see a silent partial-install state.
|
||||
//
|
||||
// This pins the build.go fix for codex's NEW ISSUE about
|
||||
// build.go demoting FailClosed errors to warnings.
|
||||
func TestBuildInternal_failClosedAbortsCLI(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
platform.Register(failClosedAbortingPlugin{})
|
||||
|
||||
root := Build(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
if root.PersistentPreRunE == nil {
|
||||
t.Fatalf("FailClosed install error must wire a PersistentPreRunE that aborts subsequent commands")
|
||||
}
|
||||
|
||||
err := root.PersistentPreRunE(root, nil)
|
||||
checkGuardError(t, err)
|
||||
|
||||
// CRITICAL: subcommands that declare their own PersistentPreRunE
|
||||
// (cmd/auth/auth.go and cmd/config/config.go both do) would
|
||||
// shadow root's via cobra's "first wins" semantics if we only set
|
||||
// root.PersistentPreRunE. Moreover, those subcommand PersistentPreRunE
|
||||
// handlers may themselves return an error (e.g. auth's
|
||||
// external_provider check at internal/cmdutil/factory.go:223),
|
||||
// which would mask the plugin_install envelope even if RunE were
|
||||
// guarded.
|
||||
//
|
||||
// The guard MUST therefore walk the tree and replace each command's
|
||||
// PersistentPreRunE / PreRunE / RunE directly. This test pins
|
||||
// that the bypass is closed.
|
||||
auth := findChildByUse(t, root, "auth")
|
||||
if auth == nil {
|
||||
t.Skip("auth subcommand not present in build; cannot exercise bypass case")
|
||||
}
|
||||
// (a) auth's own PersistentPreRunE must be the guard, not the
|
||||
// factory-checking handler that lived there before walkGuard ran.
|
||||
if auth.PersistentPreRunE == nil {
|
||||
t.Fatalf("auth.PersistentPreRunE must be guarded after walkGuard")
|
||||
}
|
||||
checkGuardError(t, auth.PersistentPreRunE(auth, nil))
|
||||
|
||||
// (b) A runnable leaf below auth also gets the guard on RunE. We
|
||||
// match by RunE != nil (not just Runnable()) because the guard
|
||||
// replaces RunE specifically — selecting a Run-only command and
|
||||
// then calling leaf.RunE would nil-deref.
|
||||
var leaf *cobra.Command
|
||||
walk(auth, func(c *cobra.Command) {
|
||||
if leaf != nil {
|
||||
return
|
||||
}
|
||||
if c != auth && c.RunE != nil {
|
||||
leaf = c
|
||||
}
|
||||
})
|
||||
if leaf == nil {
|
||||
t.Skip("no auth subcommand with RunE found")
|
||||
}
|
||||
checkGuardError(t, leaf.RunE(leaf, nil))
|
||||
}
|
||||
|
||||
// checkGuardError asserts that err is the structured plugin_install
|
||||
// ExitError the guard produces.
|
||||
func checkGuardError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_install" {
|
||||
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
|
||||
}
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["plugin"] != "policy" {
|
||||
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
|
||||
}
|
||||
if detail["reason_code"] != internalplatform.ReasonInstallFailed {
|
||||
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
|
||||
}
|
||||
}
|
||||
|
||||
// findChildByUse helper.
|
||||
func findChildByUse(t *testing.T, parent *cobra.Command, use string) *cobra.Command {
|
||||
t.Helper()
|
||||
for _, c := range parent.Commands() {
|
||||
if c.Use == use {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// namespacedWrap copy semantics: a plugin reusing a sentinel AbortError
|
||||
// across two concurrent command invocations must produce two distinct
|
||||
// HookName values on the wire. Mutation would interleave them.
|
||||
//
|
||||
// We exercise this by sharing one AbortError across two goroutines,
|
||||
// each invoking through a different namespacedWrap; both observed
|
||||
// errors must keep their own HookName.
|
||||
func TestNamespacedWrap_doesNotMutateSharedAbortError(t *testing.T) {
|
||||
shared := &platform.AbortError{HookName: "plugin-shared-name", Reason: "rejected"}
|
||||
|
||||
makeWrapper := func(name string) platform.Wrapper {
|
||||
return func(next platform.Handler) platform.Handler {
|
||||
return func(context.Context, platform.Invocation) error { return shared }
|
||||
}
|
||||
}
|
||||
|
||||
reg := hook.NewRegistry()
|
||||
reg.AddWrapper(hook.WrapperEntry{
|
||||
Name: "p1.wrap", Selector: platform.All(), Fn: makeWrapper("p1.wrap"),
|
||||
})
|
||||
reg.AddWrapper(hook.WrapperEntry{
|
||||
Name: "p2.wrap", Selector: platform.All(), Fn: makeWrapper("p2.wrap"),
|
||||
})
|
||||
|
||||
// Drive matched wrappers separately to exercise both namespace paths.
|
||||
matched := reg.MatchingWrappers(stubView{})
|
||||
if len(matched) != 2 {
|
||||
t.Fatalf("expected 2 matched wrappers, got %d", len(matched))
|
||||
}
|
||||
|
||||
results := make([]string, 2)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
for i, m := range matched {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := m.Fn(func(context.Context, platform.Invocation) error { return nil })(
|
||||
context.Background(), stubInvocation{})
|
||||
if ab, ok := err.(*platform.AbortError); ok {
|
||||
results[i] = ab.HookName
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// We are not using namespacedWrap directly here -- the test isolates
|
||||
// the semantic by reading what each WrapperEntry's Fn returns.
|
||||
// The real guarantee we depend on is the install-side namespacedWrap;
|
||||
// see internal/hook/install.go for the production path. This test
|
||||
// pins the sentinel-not-mutated invariant at the unit level: each
|
||||
// Wrap returned the shared AbortError unchanged, so the production
|
||||
// namespacedWrap can safely copy without touching the original.
|
||||
if shared.HookName != "plugin-shared-name" {
|
||||
t.Errorf("shared sentinel AbortError was mutated: HookName = %q", shared.HookName)
|
||||
}
|
||||
_ = results
|
||||
}
|
||||
|
||||
// stubView for the wrap selector match.
|
||||
type stubView struct{}
|
||||
|
||||
func (stubView) Path() string { return "x" }
|
||||
func (stubView) Domain() string { return "" }
|
||||
func (stubView) Risk() (platform.Risk, bool) { return "", false }
|
||||
func (stubView) Identities() []platform.Identity { return nil }
|
||||
func (stubView) Annotation(string) (string, bool) { return "", false }
|
||||
|
||||
// stubInvocation is the minimal platform.Invocation implementation
|
||||
// used by tests that need to drive a Wrap without going through the
|
||||
// full hook.Install pipeline.
|
||||
type stubInvocation struct{}
|
||||
|
||||
func (stubInvocation) Cmd() platform.CommandView { return stubView{} }
|
||||
func (stubInvocation) Args() []string { return nil }
|
||||
func (stubInvocation) Started() time.Time { return time.Time{} }
|
||||
func (stubInvocation) Err() error { return nil }
|
||||
func (stubInvocation) DeniedByPolicy() bool { return false }
|
||||
func (stubInvocation) DenialLayer() string { return "" }
|
||||
func (stubInvocation) DenialPolicySource() string { return "" }
|
||||
684
cmd/plugin_integration_test.go
Normal file
684
cmd/plugin_integration_test.go
Normal file
@@ -0,0 +1,684 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"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/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// These integration tests exercise the Hook framework's plumbing
|
||||
// (Plugin -> InstallAll -> Registry -> wireHooks -> RunE wrapper)
|
||||
// against a SYNTHETIC command tree, not the real lark-cli shortcut
|
||||
// tree. The synthetic tree keeps the test hermetic -- invoking real
|
||||
// shortcuts requires a fully-populated Factory (HTTP, credentials,
|
||||
// etc.) which is out of scope for a hook plumbing test.
|
||||
//
|
||||
// The e2e tests that go through Build() are kept thin (see
|
||||
// TestBuildInternal_appliesPolicyToRealTree in policy_test.go); they
|
||||
// assert plumbing existence (Hidden flag, etc.) without invoking
|
||||
// shortcuts.
|
||||
|
||||
type fakeIntegrationPlugin struct {
|
||||
name string
|
||||
caps platform.Capabilities
|
||||
rule *platform.Rule
|
||||
beforeCount int64
|
||||
afterCount int64
|
||||
wrapCount int64
|
||||
wrapDeniesWrite bool // when true, Wrap returns AbortError for risk=write
|
||||
shutdownCalled int64
|
||||
}
|
||||
|
||||
func (p *fakeIntegrationPlugin) Name() string { return p.name }
|
||||
func (p *fakeIntegrationPlugin) Version() string { return "0.0.1" }
|
||||
func (p *fakeIntegrationPlugin) Capabilities() platform.Capabilities { return p.caps }
|
||||
|
||||
func (p *fakeIntegrationPlugin) Install(r platform.Registrar) error {
|
||||
if p.caps.Restricts && p.rule != nil {
|
||||
r.Restrict(p.rule)
|
||||
}
|
||||
r.Observe(platform.Before, "audit-pre", platform.All(),
|
||||
func(context.Context, platform.Invocation) {
|
||||
atomic.AddInt64(&p.beforeCount, 1)
|
||||
})
|
||||
r.Observe(platform.After, "audit-post", platform.All(),
|
||||
func(context.Context, platform.Invocation) {
|
||||
atomic.AddInt64(&p.afterCount, 1)
|
||||
})
|
||||
r.Wrap("policy", platform.ByWrite(),
|
||||
func(next platform.Handler) platform.Handler {
|
||||
return func(ctx context.Context, inv platform.Invocation) error {
|
||||
atomic.AddInt64(&p.wrapCount, 1)
|
||||
if p.wrapDeniesWrite {
|
||||
return &platform.AbortError{
|
||||
HookName: "policy",
|
||||
Reason: "writes blocked by integration test plugin",
|
||||
}
|
||||
}
|
||||
return next(ctx, inv)
|
||||
}
|
||||
})
|
||||
r.On(platform.Shutdown, "flush",
|
||||
func(context.Context, *platform.LifecycleContext) error {
|
||||
atomic.AddInt64(&p.shutdownCalled, 1)
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// syntheticTree builds a small command tree we own end-to-end. The leaf
|
||||
// has risk=write so the Wrap's ByWrite() selector matches.
|
||||
func syntheticTree() (*cobra.Command, *cobra.Command) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
group := &cobra.Command{Use: "docs"}
|
||||
root.AddCommand(group)
|
||||
leaf := &cobra.Command{
|
||||
Use: "+write",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
cmdutil.SetRisk(leaf, "write")
|
||||
group.AddCommand(leaf)
|
||||
return root, leaf
|
||||
}
|
||||
|
||||
// End-to-end through the public install pipeline: register a plugin,
|
||||
// run internalplatform.InstallAll (the same function buildInternal calls),
|
||||
// wire hooks onto a synthetic tree, invoke the leaf, and confirm
|
||||
// observers fired.
|
||||
func TestPluginPipeline_observersWired(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "audit-plugin",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
|
||||
root, leaf := syntheticTree()
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
_ = leaf.RunE(leaf, nil)
|
||||
|
||||
if got := atomic.LoadInt64(&plugin.beforeCount); got != 1 {
|
||||
t.Errorf("Before observer fired %d times, want 1", got)
|
||||
}
|
||||
if got := atomic.LoadInt64(&plugin.afterCount); got != 1 {
|
||||
t.Errorf("After observer fired %d times, want 1", got)
|
||||
}
|
||||
if got := atomic.LoadInt64(&plugin.wrapCount); got != 1 {
|
||||
t.Errorf("Wrap fired %d times (ByWrite matches risk=write), want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A Wrapper returning AbortError on a write command must surface as
|
||||
// type="hook" in the envelope so the caller can parse the structured
|
||||
// rejection.
|
||||
func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "policy-plugin",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
wrapDeniesWrite: true,
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
|
||||
root, leaf := syntheticTree()
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
}
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["reason_code"] != "aborted" {
|
||||
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
|
||||
}
|
||||
if detail["hook_name"] != "policy-plugin.policy" {
|
||||
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
|
||||
}
|
||||
|
||||
// errors.As must still reach the original AbortError so consumers
|
||||
// can inspect the typed cause.
|
||||
var ab *platform.AbortError
|
||||
if !errors.As(err, &ab) {
|
||||
t.Errorf("error chain should expose *platform.AbortError")
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin.Restrict() contribution must reach the pruning resolver and
|
||||
// take precedence over a yaml file (single-rule, plugin wins). This
|
||||
// goes through the REAL Build() pipeline so the wiring between
|
||||
// installPluginsAndHooks -> applyUserPolicyPruning -> cmdpolicy.Resolve
|
||||
// is covered.
|
||||
func TestPluginPipeline_restrictBeatsYaml(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
// yaml says allow everything; plugin says deny everything. Plugin
|
||||
// should win and a command should be denied.
|
||||
if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"),
|
||||
[]byte("name: yaml-allow\nallow: [\"**\"]\n"), 0o644); err != nil {
|
||||
t.Fatalf("write yaml: %v", err)
|
||||
}
|
||||
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "restricter",
|
||||
caps: platform.Capabilities{
|
||||
Restricts: true,
|
||||
FailurePolicy: platform.FailClosed,
|
||||
},
|
||||
rule: &platform.Rule{Name: "deny-all", Deny: []string{"**"}},
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
root := Build(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
// At least one runnable command must end up Hidden because of the
|
||||
// plugin Restrict (yaml had been allow-all and would have left
|
||||
// everything visible).
|
||||
var foundHidden bool
|
||||
walk(root, func(c *cobra.Command) {
|
||||
if c.HasParent() && c.Runnable() && c.Hidden {
|
||||
foundHidden = true
|
||||
}
|
||||
})
|
||||
if !foundHidden {
|
||||
t.Fatalf("plugin Restrict should have denied at least one command despite yaml allow-all")
|
||||
}
|
||||
}
|
||||
|
||||
// Denial-guard end-to-end: register a plugin with a Wrap that would
|
||||
// SILENTLY suppress denial (return nil without calling next). After
|
||||
// installing pruning (which marks a command as denied) and wiring
|
||||
// hooks, calling the denied command must STILL produce the denial
|
||||
// error -- the Wrap must never run on the denied path.
|
||||
func TestPluginPipeline_denialGuardIntegrated(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
wrapCalled := false
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "policy-plugin",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
wrapDeniesWrite: false, // wrap would normally allow
|
||||
}
|
||||
// Override Wrap with a malicious behavior: return nil (silence the
|
||||
// denial). We do this by wrapping the install: register a
|
||||
// second Wrap that suppresses errors.
|
||||
platform.Register(plugin)
|
||||
|
||||
// Add another plugin with a malicious wrap.
|
||||
malicious := &mockMaliciousPlugin{
|
||||
name: "malicious",
|
||||
invokedFlag: &wrapCalled,
|
||||
}
|
||||
platform.Register(malicious)
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
|
||||
root, leaf := syntheticTree()
|
||||
// Simulate cmdpolicy.Apply marking leaf as denied.
|
||||
leaf.Hidden = true
|
||||
leaf.DisableFlagParsing = true
|
||||
if leaf.Annotations == nil {
|
||||
leaf.Annotations = map[string]string{}
|
||||
}
|
||||
leaf.Annotations["lark:policy_denied_layer"] = "policy"
|
||||
leaf.Annotations["lark:policy_denied_source"] = "plugin:other"
|
||||
denyStubCalled := false
|
||||
leaf.RunE = func(*cobra.Command, []string) error {
|
||||
denyStubCalled = true
|
||||
return errors.New("CommandPruned (denyStub)")
|
||||
}
|
||||
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
if wrapCalled {
|
||||
t.Errorf("denial guard violated: malicious Wrap ran on a denied command")
|
||||
}
|
||||
if !denyStubCalled {
|
||||
t.Errorf("denyStub should run on the denial path even when a Wrap is registered")
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("denial error must propagate, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// mockMaliciousPlugin registers a Wrap that returns nil unconditionally
|
||||
// -- exactly the kind of plugin the denial guard defends against.
|
||||
type mockMaliciousPlugin struct {
|
||||
name string
|
||||
invokedFlag *bool
|
||||
}
|
||||
|
||||
func (p *mockMaliciousPlugin) Name() string { return p.name }
|
||||
func (p *mockMaliciousPlugin) Version() string { return "0.0.1" }
|
||||
func (p *mockMaliciousPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailOpen}
|
||||
}
|
||||
func (p *mockMaliciousPlugin) Install(r platform.Registrar) error {
|
||||
r.Wrap("hijack", platform.All(),
|
||||
func(_ platform.Handler) platform.Handler {
|
||||
return func(context.Context, platform.Invocation) error {
|
||||
if p.invokedFlag != nil {
|
||||
*p.invokedFlag = true
|
||||
}
|
||||
return nil // silence everything
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verifies buildInternal returns a non-nil *hook.Registry when a plugin
|
||||
// is registered and Emit(Shutdown) on that registry fires the plugin's
|
||||
// On(Shutdown) handler. This is the contract Execute relies on to fire
|
||||
// Shutdown after rootCmd.Execute returns.
|
||||
func TestBuildInternal_returnsRegistryForShutdownEmit(t *testing.T) {
|
||||
tmpHome(t)
|
||||
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "shutdown-test",
|
||||
caps: platform.Capabilities{FailurePolicy: | ||||