mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
155 Commits
v1.0.19
...
feat/sec_p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
054ff9339b | ||
|
|
bdb0cd14d1 | ||
|
|
6c41d12792 | ||
|
|
2286937366 | ||
|
|
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 | ||
|
|
4aceae9bff | ||
|
|
44ffa98b89 | ||
|
|
f9792f056e | ||
|
|
6e22a7e518 | ||
|
|
29a98966a0 | ||
|
|
a81d07ca4f | ||
|
|
e754b3bc1b | ||
|
|
a6de8360f0 | ||
|
|
88d7ec8ee7 | ||
|
|
90757887b2 | ||
|
|
88d4e3bd90 | ||
|
|
7c68639b31 | ||
|
|
8b80810fa0 | ||
|
|
eed802c814 | ||
|
|
8f410ab140 | ||
|
|
d9b9f094cf | ||
|
|
b65147f208 | ||
|
|
c3756f3642 | ||
|
|
27a2f2758b | ||
|
|
15ae1fabec | ||
|
|
d317493e49 | ||
|
|
a8f078478e | ||
|
|
06275415b1 | ||
|
|
b4c9c09de0 | ||
|
|
7fb71c6947 | ||
|
|
020aeb87ad | ||
|
|
686c91dc71 | ||
|
|
cfd89e0e28 | ||
|
|
ac4c34f2ad | ||
|
|
3ed691b25c | ||
|
|
30ad38d4b6 | ||
|
|
4fab062219 | ||
|
|
f27b8fdf40 | ||
|
|
c100ca049e | ||
|
|
4d68e09537 | ||
|
|
a3bbe00ee0 | ||
|
|
0250054a90 | ||
|
|
d7ee5b5769 | ||
|
|
b37adfd0ee | ||
|
|
082275f32b | ||
|
|
2eb9fae575 | ||
|
|
418192507e | ||
|
|
7752afab96 | ||
|
|
f7a56f38b1 | ||
|
|
ea056d132e | ||
|
|
7fc963f455 | ||
|
|
520acb618c | ||
|
|
dce2beb91c | ||
|
|
97968b6ef2 | ||
|
|
6bb988a655 | ||
|
|
4422265d5f | ||
|
|
7eb0ba3257 | ||
|
|
af2398d636 | ||
|
|
138bf36bb3 | ||
|
|
0bbd0f2c7d | ||
|
|
fc9f9c1f26 | ||
|
|
fc22e9a04b | ||
|
|
9ba0d15161 | ||
|
|
b8d0f96265 | ||
|
|
2e4cfb4921 | ||
|
|
23066c8eee | ||
|
|
c09b03f854 | ||
|
|
4d4508dfd7 | ||
|
|
05d8137c7d | ||
|
|
17a85d319d | ||
|
|
a16eb24ba9 | ||
|
|
f6f242ed57 | ||
|
|
7124b18baa | ||
|
|
78d92de6af | ||
|
|
8ec95a4e39 | ||
|
|
fe9dc4ce6a | ||
|
|
1e2144ee08 | ||
|
|
20fba1e601 | ||
|
|
97f817d088 | ||
|
|
ddf6f0cb7d | ||
|
|
834a899e2b | ||
|
|
aa48d70d7a | ||
|
|
2e7a11a8e8 |
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
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,8 +34,11 @@ 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
|
||||
|
||||
@@ -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
|
||||
@@ -54,6 +55,12 @@ linters:
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
# The shortcuts-no-raw-http forbidigo rule below is shortcuts-only;
|
||||
# internal/ legitimately wraps raw HTTP for the client / credential layer.
|
||||
- path-except: shortcuts/
|
||||
text: shortcuts-no-raw-http
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
@@ -70,16 +77,18 @@ linters:
|
||||
desc: >-
|
||||
shortcuts must not import internal/vfs/localfileio directly.
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
shortcuts-no-raw-http:
|
||||
files:
|
||||
- "**/shortcuts/**"
|
||||
deny:
|
||||
- pkg: "net/http"
|
||||
desc: >-
|
||||
use RuntimeContext.DoAPI/CallAPI/DoAPIJSON instead of raw net/http.
|
||||
The client layer handles auth, headers, and error normalization.
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── http: shortcuts must not construct raw HTTP requests ──
|
||||
# Bans request / client construction; constants (http.MethodPost,
|
||||
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
||||
# intentionally allowed since they don't bypass the runtime layer.
|
||||
- pattern: http\.(Client|NewRequest|NewRequestWithContext|Get|Post|PostForm|Head|DefaultClient|DefaultTransport|RoundTripper|Do|Serve|ListenAndServe)\b
|
||||
msg: >-
|
||||
[shortcuts-no-raw-http] use RuntimeContext.DoAPI/CallAPI/DoAPIJSON
|
||||
instead of constructing raw HTTP. The runtime handles auth, headers,
|
||||
and error normalization. (Constants and helpers like http.MethodPost,
|
||||
http.StatusOK, http.StatusText remain allowed.)
|
||||
# ── os: already wrapped in internal/vfs ──
|
||||
- pattern: os\.(Stat|Lstat|Open|OpenFile|Rename|ReadFile|WriteFile|Getwd|UserHomeDir|ReadDir)\b
|
||||
msg: "use the corresponding vfs.Xxx() from internal/vfs"
|
||||
|
||||
16
AGENTS.md
16
AGENTS.md
@@ -15,6 +15,22 @@ make unit-test # Required before PR (runs with -race)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## Notification Opt-Outs
|
||||
|
||||
`lark-cli` emits two notice types into JSON envelope `_notice` to nudge AI agents toward fixes:
|
||||
|
||||
- `_notice.update` — a newer binary is available on npm
|
||||
- `_notice.skills` — locally installed skills are out of sync with the running binary
|
||||
|
||||
To suppress them in non-CI scripts (CI envs are auto-skipped):
|
||||
|
||||
| Env var | Effect |
|
||||
|---------|--------|
|
||||
| `LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1` | Suppress `_notice.update` |
|
||||
| `LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1` | Suppress `_notice.skills` |
|
||||
|
||||
Both notices recommend the same fix command: `lark-cli update`. The skills notice's `current` field is `""` when skills have never been synced (cold start) and a version string when synced for an older binary (drift).
|
||||
|
||||
## Pre-PR Checks (match CI gates)
|
||||
|
||||
1. `make unit-test`
|
||||
|
||||
290
CHANGELOG.md
290
CHANGELOG.md
@@ -2,6 +2,281 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
|
||||
- **config**: Add `lark-channel` as a bind source (#786)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **install**: Fix installation errors when PowerShell is disabled by Group Policy (#789)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **task**: Clarify task member id types in references (#777)
|
||||
|
||||
## [v1.0.26] - 2026-05-08
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add `message_app_link` to message outputs (#668)
|
||||
- **auth**: Add scope hint for missing authorization errors (#776)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Clean error detail output (#783)
|
||||
- **whiteboard**: Reclassify `+update` as `write` risk (#775)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **mail**: Add data integrity and write-confirmation rules (#749)
|
||||
|
||||
## [v1.0.25] - 2026-05-07
|
||||
|
||||
### Features
|
||||
|
||||
- Add skills version drift notice and unify update flow (#723)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove misleading default value from `--as` flag help text (#769)
|
||||
- Handle negative truncate lengths (#744)
|
||||
- Reject invalid JSON pointer escapes (#741)
|
||||
- Migrate task shortcut errors to structured `output.Errorf`/`ErrValidation` (#740)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Clarify base `user_open_id` guidance (#763)
|
||||
|
||||
## [v1.0.24] - 2026-05-06
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add sheet management shortcuts (#722)
|
||||
- **base**: Support batch record get and delete (#630)
|
||||
- **task**: Add upload task attachment shortcut (#736)
|
||||
- **drive**: Pre-flight 10000-rune total cap for `+add-comment` `reply_elements` (#605)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **auth**: Handle missing scopes and device flow improvements (#752)
|
||||
- Add url to markdown `+create` output (#753)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Refine field update conversion guidance (#748)
|
||||
|
||||
## [v1.0.23] - 2026-04-30
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Add `+pull` shortcut for one-way Drive → local mirror (#696)
|
||||
- **drive**: Add `+push` shortcut for one-way local → Drive mirror (#709)
|
||||
- **drive**: Add `+status` shortcut for content-hash diff (#692)
|
||||
- **drive**: Support `--file-name` for drive export (#685)
|
||||
- **base**: Add markdown output for record reads (#726)
|
||||
- **minutes**: Add media upload shortcut (#725)
|
||||
- **doc**: Warn when callout uses `type=` without `background-color` (#467)
|
||||
- **cmdutil**: Support `@file` for params and data (#724)
|
||||
- Add markdown shortcuts and skill docs (#704)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Guide lark-doc v2 usage (#710)
|
||||
- **minutes**: Clarify minutes file-to-notes routing (#732)
|
||||
|
||||
## [v1.0.22] - 2026-04-29
|
||||
|
||||
### Features
|
||||
|
||||
- **task**: Add resource agent & `agent_task_step_info` (#693)
|
||||
- **task**: Support app task members by id (#712)
|
||||
- **contact**: Add `--queries` multi-name fanout to `+search-user` (#707)
|
||||
- **slides**: Add slide templates with template-first skill guidance (#684)
|
||||
- **mail**: Support calendar events in emails (#646)
|
||||
- **install**: Honor `npm_config_registry` for binary URL resolution with npmmirror fallback (#690)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **install**: Make Windows zip extraction resilient (#713)
|
||||
- **config/init**: Respect `--brand` flag in `--new` mode (#711)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Clarify base search routing (#708)
|
||||
- **base**: Align base skills and view config contracts (#653)
|
||||
|
||||
## [v1.0.21] - 2026-04-28
|
||||
|
||||
### Features
|
||||
|
||||
- **contact**: Add search filters and richer profile fields to `+search-user` (#648)
|
||||
- **common**: Backfill resource URL when create APIs omit it (#680)
|
||||
- **risk**: Add risk tiering for command sensitivity classification (#633)
|
||||
- **okr**: Add progress records support (#574)
|
||||
- **calendar**: Enhance event search and meeting room finding (#679)
|
||||
- **event**: Add event subscription & consume system (#654)
|
||||
- **drive**: Extend `+add-comment` to support slides targets (#674)
|
||||
- **slides**: Add font management for slides (#681)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cmdutil**: Default flag completions to disabled (#688)
|
||||
- **e2e/wiki**: Pass `obj_type` when deleting wiki nodes in cleanup (#687)
|
||||
- **readme**: Fix readme statistics (#691)
|
||||
|
||||
## [v1.0.20] - 2026-04-27
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: Add `+search` shortcut with flat filter flags (#658)
|
||||
- **mail**: Support sharing emails to IM chats via `+share-to-chat` (#637)
|
||||
- **calendar**: Add `+update` shortcut (#678)
|
||||
- **im**: Add `--at-chatter-ids` filter to `+messages-search` (#612)
|
||||
- **pagination**: Preserve pagination state on truncation and natural end (#659)
|
||||
- **lark-im**: Add `chat.members.bots` to skill docs (#616)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **strict-mode**: Reject explicit `--as` instead of silently overriding it (#673)
|
||||
- **whiteboard**: Manual disable edge case for svg compatibility (#661)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **lark-drive**: Add missing import command examples (#669)
|
||||
- **readme**: Add Project (Meegle) to Features table (#660)
|
||||
|
||||
## [v1.0.19] - 2026-04-24
|
||||
|
||||
### Features
|
||||
@@ -499,6 +774,21 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
[v1.0.24]: https://github.com/larksuite/cli/releases/tag/v1.0.24
|
||||
[v1.0.23]: https://github.com/larksuite/cli/releases/tag/v1.0.23
|
||||
[v1.0.22]: https://github.com/larksuite/cli/releases/tag/v1.0.22
|
||||
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21
|
||||
[v1.0.20]: https://github.com/larksuite/cli/releases/tag/v1.0.20
|
||||
[v1.0.19]: https://github.com/larksuite/cli/releases/tag/v1.0.19
|
||||
[v1.0.18]: https://github.com/larksuite/cli/releases/tag/v1.0.18
|
||||
[v1.0.17]: https://github.com/larksuite/cli/releases/tag/v1.0.17
|
||||
|
||||
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
|
||||
|
||||
32
README.md
32
README.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 22 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 22 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 14 business domains, 200+ curated commands, 22 AI Agent [Skills](./skills/)
|
||||
- **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/)
|
||||
- **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,10 +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, 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 |
|
||||
@@ -35,10 +36,11 @@ 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 and indicators. |
|
||||
| 🎯 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) |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
@@ -60,11 +62,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:**
|
||||
@@ -100,11 +98,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**
|
||||
@@ -134,10 +128,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, 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 |
|
||||
@@ -148,13 +143,14 @@ 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 |
|
||||
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
|
||||
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
|
||||
| `lark-okr` | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
|
||||
|
||||
## Authentication
|
||||
|
||||
|
||||
32
README.zh.md
32
README.zh.md
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 22 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
## 为什么选 lark-cli?
|
||||
|
||||
- **为 Agent 原生设计** — 22 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 14 大业务域、200+ 精选命令、22 个 AI Agent [Skills](./skills/)
|
||||
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书
|
||||
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
|
||||
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
|
||||
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
|
||||
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
|
||||
@@ -24,10 +24,11 @@
|
||||
|
||||
| 类别 | 能力 |
|
||||
| ------------- |--------------------------------------------|
|
||||
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
|
||||
| 📅 日历 | 查看、创建和更新日程,邀请参会人、查找会议室、回复日程邀请、查询忙闲与时间建议 |
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📝 Markdown | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 `.md` 文件 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
@@ -35,10 +36,11 @@
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
|
||||
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要产物与会议录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐和指标 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
@@ -60,11 +62,7 @@
|
||||
**方式一 — 从 npm 安装(推荐):**
|
||||
|
||||
```bash
|
||||
# 安装 CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
npx @larksuite/cli@latest install
|
||||
```
|
||||
|
||||
**方式二 — 从源码安装:**
|
||||
@@ -100,11 +98,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 步 — 配置应用凭证**
|
||||
@@ -135,10 +129,11 @@ lark-cli auth status
|
||||
| Skill | 说明 |
|
||||
| --------------------------------- |-------------------------------------------|
|
||||
| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) |
|
||||
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
|
||||
| `lark-calendar` | 日历日程(创建/更新)、议程查看、忙闲查询、时间建议、会议室查找、回复邀请 |
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-markdown` | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 Markdown 文件 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
@@ -149,13 +144,14 @@ 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` | 查询个人考勤打卡记录 |
|
||||
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
|
||||
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
|
||||
| `lark-okr` | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
|
||||
## 认证
|
||||
|
||||
|
||||
@@ -81,8 +81,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin, @file for file input)")
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
||||
@@ -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
|
||||
}
|
||||
@@ -112,6 +113,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
// Validate --file mutual exclusions first.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
|
||||
@@ -123,7 +125,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
@@ -145,7 +147,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
// Parse --data as JSON map for form fields (not as body).
|
||||
var dataFields any
|
||||
if opts.Data != "" {
|
||||
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
@@ -161,7 +163,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
}
|
||||
|
||||
fd, err := cmdutil.BuildFormdata(
|
||||
opts.Factory.ResolveFileIO(opts.Ctx),
|
||||
fileIO,
|
||||
fieldName, filePath, isStdin, stdin, dataFields,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -171,7 +173,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
|
||||
} else {
|
||||
// Normal path: JSON body.
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
|
||||
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
|
||||
@@ -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 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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -33,6 +34,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
return authListRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -42,7 +44,18 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not configured yet. Run `lark-cli config init` to initialize.")
|
||||
// auth list is a read-only probe; the "configured but no users"
|
||||
// branch below already returns exit 0 with a stderr hint, so we
|
||||
// keep the same contract here. We still want the hint to be
|
||||
// workspace-aware, so we pull the message+hint out of
|
||||
// NotConfiguredError() instead of hard-coding it.
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
||||
if cfgErr.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
59
cmd/auth/list_test.go
Normal file
59
cmd/auth/list_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
|
||||
// `lark-cli auth list` is a read-only probe and must not fail-hard when no
|
||||
// config exists yet — scripts and AI agents use it as an idempotent "do I
|
||||
// have any users?" check, so the exit code carries semantic weight. Pair
|
||||
// that with the existing "configured but no logged-in users" branch (also
|
||||
// exit 0) and both empty states are consistent.
|
||||
func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||
}
|
||||
// Local workspace → hint must mention init, not bind.
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "config init") {
|
||||
t.Errorf("local hint missing config init: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "config bind") {
|
||||
t.Errorf("local hint must not mention config bind: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
||||
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||
// `config bind --help` instead of the local-only `config init`.
|
||||
func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
prev := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(prev) })
|
||||
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should still succeed under agent workspace; got: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "config bind --help") {
|
||||
t.Errorf("agent hint must point at config bind --help: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "config init") {
|
||||
t.Errorf("agent hint must not mention config init: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ type LoginOptions struct {
|
||||
Scope string
|
||||
Recommend bool
|
||||
Domains []string
|
||||
Exclude []string
|
||||
NoWait bool
|
||||
DeviceCode string
|
||||
}
|
||||
@@ -46,13 +47,14 @@ 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 only delivers final turn messages, use --no-wait --json,
|
||||
send the verification URL to the user as your final message, end the turn, then
|
||||
run --device-code in a later step after the user confirms authorization.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, user login is not allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
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)")
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
@@ -62,12 +64,15 @@ 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()
|
||||
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")
|
||||
@@ -159,6 +164,10 @@ 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)
|
||||
@@ -181,19 +190,22 @@ 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")
|
||||
@@ -207,11 +219,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
|
||||
@@ -233,7 +269,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("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the URL the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
@@ -243,7 +279,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL
|
||||
// Step 2: Show user code and verification URL.
|
||||
// Both branches surface AgentTimeoutHint, but on different channels:
|
||||
// JSON mode embeds it as a structured field (so an agent that captures
|
||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
||||
// text mode prints to stderr (alongside the URL prompt).
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -251,6 +291,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_uri_complete": authResp.VerificationUriComplete,
|
||||
"user_code": authResp.UserCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"agent_hint": msg.AgentTimeoutHint,
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
@@ -260,6 +301,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -346,9 +388,15 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
|
||||
// device_code already returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
|
||||
opts.DeviceCode, 5, 600, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
if shouldRemoveLoginRequestedScope(result) {
|
||||
@@ -462,7 +510,7 @@ 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 domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.ScopesForIdentity(identity) {
|
||||
for _, s := range sc.DeclaredScopesForIdentity(identity) {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
}
|
||||
@@ -521,6 +569,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
|
||||
@@ -531,3 +613,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
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type loginMsg struct {
|
||||
// Non-interactive prompts (login.go)
|
||||
OpenURL string
|
||||
WaitingAuth string
|
||||
AgentTimeoutHint string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
AuthorizedUser string
|
||||
@@ -58,6 +59,7 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 只会把最终回复发给用户,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 和 verification_url,把 verification_url 作为本轮最终消息原样发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 `lark-cli auth login --device-code <code>` 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL,把它视为不可修改的 opaque string;不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。",
|
||||
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
@@ -93,6 +95,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 your harness only delivers final turn messages, use `lark-cli auth login --no-wait --json` to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run `lark-cli auth login --device-code <code>` in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
|
||||
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
@@ -122,5 +125,5 @@ func getLoginMsg(lang string) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs"}
|
||||
return []string{"base", "contact", "docs", "markdown"}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -94,3 +95,22 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentTimeoutHint_CarriesKeyInfo guards the contract that the synchronous
|
||||
// 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"} {
|
||||
hint := getLoginMsg(lang).AgentTimeoutHint
|
||||
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
|
||||
if lang == "zh" && want == "turn" {
|
||||
want = "本轮"
|
||||
}
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("%s AgentTimeoutHint missing %q: %s", lang, want, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
if loginSucceeded {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return nil
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
detail := map[string]interface{}{
|
||||
"requested": issue.Summary.Requested,
|
||||
@@ -200,9 +200,6 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
if issue.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
|
||||
}
|
||||
if loginSucceeded {
|
||||
return nil
|
||||
}
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ 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/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/zalando/go-keyring"
|
||||
@@ -69,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}
|
||||
@@ -288,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,8 +400,12 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
@@ -410,8 +443,12 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
@@ -616,8 +653,12 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
@@ -866,6 +907,70 @@ 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{
|
||||
"exactly as returned by the CLI",
|
||||
"opaque string",
|
||||
"Do not URL-encode or decode it",
|
||||
"do not add %20, spaces, or punctuation",
|
||||
"do not wrap it as Markdown link text",
|
||||
"fenced code block containing only the raw URL",
|
||||
"final message of the turn",
|
||||
"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",
|
||||
@@ -904,6 +1009,64 @@ 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",
|
||||
"逐字原样转发 CLI 返回的 URL",
|
||||
"opaque string",
|
||||
"不要做 URL 编码或解码",
|
||||
"不要补 `%20`、空格或标点",
|
||||
"不要改写成 Markdown 链接",
|
||||
"只包含该 URL 的代码块单独输出",
|
||||
} {
|
||||
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 {
|
||||
|
||||
@@ -33,6 +33,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
||||
return authLogoutRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -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,83 @@ 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)
|
||||
addLegacyUserFields(result, diagnostics.User)
|
||||
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 addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
|
||||
if user.OpenID == "" {
|
||||
return
|
||||
}
|
||||
result["userName"] = user.UserName
|
||||
result["userOpenId"] = user.OpenID
|
||||
if user.TokenStatus != "" {
|
||||
result["tokenStatus"] = user.TokenStatus
|
||||
}
|
||||
if user.Scope != "" {
|
||||
result["scope"] = user.Scope
|
||||
}
|
||||
if user.ExpiresAt != "" {
|
||||
result["expiresAt"] = user.ExpiresAt
|
||||
}
|
||||
if user.RefreshExpiresAt != "" {
|
||||
result["refreshExpiresAt"] = user.RefreshExpiresAt
|
||||
}
|
||||
if user.GrantedAt != "" {
|
||||
result["grantedAt"] = user.GrantedAt
|
||||
}
|
||||
}
|
||||
|
||||
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
|
||||
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"`
|
||||
}
|
||||
62
cmd/build.go
62
cmd/build.go
@@ -12,12 +12,17 @@ import (
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/doctor"
|
||||
cmdevent "github.com/larksuite/cli/cmd/event"
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/sec"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/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"
|
||||
@@ -57,18 +62,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{}
|
||||
@@ -107,6 +122,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
f.CurrentCommand = cmd
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
@@ -117,13 +133,47 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
rootCmd.AddCommand(sec.NewCmdSec(f))
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
67
cmd/build_memstats_test.go
Normal file
67
cmd/build_memstats_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// TestBuild_DefaultNoCompletionLeak verifies that, without any call to
|
||||
// SetFlagCompletionsEnabled, repeated cmd.Build invocations do not leak
|
||||
// *cobra.Command instances into cobra's package-global flag-completion map.
|
||||
//
|
||||
// This guards the new default (completions disabled) — if someone flips the
|
||||
// zero-value back to "enabled", the per-Build memory growth observed under
|
||||
// `scripts/bench_build` would resurface in production hot paths that build
|
||||
// the root command without serving a completion request.
|
||||
func TestBuild_DefaultNoCompletionLeak(t *testing.T) {
|
||||
if cmdutil.FlagCompletionsEnabled() {
|
||||
t.Fatalf("precondition: FlagCompletionsEnabled() = true, want false (state polluted by another test)")
|
||||
}
|
||||
|
||||
snap := func() (heapMB float64, objs uint64) {
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
return float64(m.HeapAlloc) / 1024 / 1024, m.HeapObjects
|
||||
}
|
||||
|
||||
// Warm one-time caches (registry JSON decode, embed reads) so the first
|
||||
// Build's lazy allocations don't skew the per-iteration delta.
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{})
|
||||
baseMB, baseObj := snap()
|
||||
|
||||
const N = 20
|
||||
for range N {
|
||||
_ = Build(context.Background(), cmdutil.InvocationContext{})
|
||||
}
|
||||
mb, obj := snap()
|
||||
|
||||
deltaMB := mb - baseMB
|
||||
deltaObj := int64(obj) - int64(baseObj)
|
||||
perBuildKB := deltaMB * 1024 / float64(N)
|
||||
perBuildObj := deltaObj / int64(N)
|
||||
|
||||
t.Logf("%d builds: +%.2f MB, +%d objects (%.1f KB/build, %d objs/build)",
|
||||
N, deltaMB, deltaObj, perBuildKB, perBuildObj)
|
||||
|
||||
// With completions disabled (the default), per-Build retained growth
|
||||
// should be minimal. Threshold is conservative: the previously observed
|
||||
// leak with completions enabled was ~hundreds of KB and thousands of
|
||||
// objects per Build, well above this bound.
|
||||
const maxKBPerBuild = 50.0
|
||||
const maxObjsPerBuild = 500
|
||||
if perBuildKB > maxKBPerBuild {
|
||||
t.Errorf("per-build heap growth = %.1f KB, want <= %.1f KB (completion registration may be leaking)", perBuildKB, maxKBPerBuild)
|
||||
}
|
||||
if perBuildObj > maxObjsPerBuild {
|
||||
t.Errorf("per-build object growth = %d, want <= %d", perBuildObj, maxObjsPerBuild)
|
||||
}
|
||||
}
|
||||
@@ -37,5 +37,6 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -60,13 +60,35 @@ func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.
|
||||
cmd := &cobra.Command{
|
||||
Use: "bind",
|
||||
Short: "Bind Agent config to a workspace (source / app-id / force)",
|
||||
Long: `Bind an AI Agent's (OpenClaw / Hermes) Feishu credentials to a lark-cli workspace.
|
||||
Long: `Bind an AI Agent's (OpenClaw / Hermes / Lark Channel) Feishu credentials to a lark-cli workspace.
|
||||
|
||||
For AI agents: pass --source and --app-id to bind non-interactively.
|
||||
Credentials are synced once; subsequent calls in the Agent's process
|
||||
context automatically use the bound workspace.`,
|
||||
Example: ` lark-cli config bind --source openclaw --app-id <id>
|
||||
lark-cli config bind --source hermes`,
|
||||
--source is auto-detected from env (OPENCLAW_HOME / HERMES_HOME / LARK_CHANNEL); pass it only to override.
|
||||
|
||||
For AI agents — DO NOT bind without user confirmation. Binding may
|
||||
overwrite an existing one and locks in an identity policy. Ask the user:
|
||||
|
||||
--identity bot-only bot only (safer default; no impersonation;
|
||||
cannot access user resources like personal
|
||||
calendar / mail / drive)
|
||||
--identity user-default user identity allowed (impersonates the user;
|
||||
needed for personal-resource access)
|
||||
|
||||
Default to bot-only if the user is unsure. Only run the command after
|
||||
the user confirms both intent and identity preset.
|
||||
|
||||
If lark-cli is already bound and the user only wants to change identity
|
||||
policy on the SAME app, use 'config strict-mode' — that's the policy
|
||||
switch and does not require re-bind. Use 'config bind' only when the
|
||||
underlying app itself changes.
|
||||
|
||||
Interactive terminal use: run with no flags to enter the TUI form.`,
|
||||
Example: ` # AI flow: confirm intent + identity with user FIRST, then run:
|
||||
lark-cli config bind --source openclaw --app-id <id> --identity bot-only
|
||||
lark-cli config bind --source hermes --identity user-default
|
||||
lark-cli config bind --source lark-channel
|
||||
|
||||
# Interactive (terminal user) — TUI prompts for everything:
|
||||
lark-cli config bind`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
if runF != nil {
|
||||
@@ -76,11 +98,12 @@ context automatically use the bound workspace.`,
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Source, "source", "", "Agent source to bind from (openclaw|hermes); auto-detected from env signals when omitted")
|
||||
cmd.Flags().StringVar(&opts.Source, "source", "", "Agent source to bind from (openclaw|hermes|lark-channel); auto-detected from env signals when omitted")
|
||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
|
||||
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -125,6 +148,7 @@ func configBindRun(opts *BindOptions) error {
|
||||
return err
|
||||
}
|
||||
applyPreferences(appConfig, opts)
|
||||
noticeUserDefaultRisk(opts)
|
||||
|
||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
||||
}
|
||||
@@ -153,8 +177,8 @@ type existingBinding struct {
|
||||
// fall back to a TUI prompt (TUI mode) or an error (flag mode).
|
||||
func finalizeSource(opts *BindOptions) (string, error) {
|
||||
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
|
||||
if explicit != "" && explicit != "openclaw" && explicit != "hermes" {
|
||||
return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes", explicit)
|
||||
if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" {
|
||||
return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit)
|
||||
}
|
||||
|
||||
var detected string
|
||||
@@ -163,6 +187,8 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
||||
detected = "openclaw"
|
||||
case core.WorkspaceHermes:
|
||||
detected = "hermes"
|
||||
case core.WorkspaceLarkChannel:
|
||||
detected = "lark-channel"
|
||||
}
|
||||
|
||||
// Explicit and env detection must agree when both are present. Reject
|
||||
@@ -199,7 +225,7 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
||||
}
|
||||
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
||||
"cannot determine Agent source: no --source flag and no Agent environment detected",
|
||||
"pass --source openclaw|hermes, or run this command inside an OpenClaw or Hermes chat")
|
||||
"pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context")
|
||||
}
|
||||
|
||||
// reconcileExistingBinding reads any existing config at configPath and decides
|
||||
@@ -308,6 +334,23 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
|
||||
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
|
||||
}
|
||||
|
||||
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
|
||||
// flag-mode bind that lands on user-default. The bot-only → user-default
|
||||
// escalation is already covered by warnIdentityEscalation (errors out before
|
||||
// applyPreferences runs), and the TUI flow shows IdentityUserDefaultDesc
|
||||
// during identity selection — so this fires specifically for the case those
|
||||
// two miss: a fresh flag-mode bind that goes directly to user-default with
|
||||
// no previous bot lock to escalate from. Without this, AI agents finish such
|
||||
// a bind with only a "配置成功" message and never relay to the user that the
|
||||
// AI can now act under their identity.
|
||||
func noticeUserDefaultRisk(opts *BindOptions) {
|
||||
if opts.IsTUI || opts.Identity != "user-default" {
|
||||
return
|
||||
}
|
||||
msg := getBindMsg(opts.Lang)
|
||||
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.
|
||||
@@ -428,6 +471,8 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||
source = "openclaw"
|
||||
case core.WorkspaceHermes:
|
||||
source = "hermes"
|
||||
case core.WorkspaceLarkChannel:
|
||||
source = "lark-channel"
|
||||
default:
|
||||
source = "openclaw" // default first option
|
||||
}
|
||||
@@ -435,6 +480,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||
// Resolve actual paths for display
|
||||
openclawPath := resolveOpenClawConfigPath()
|
||||
hermesEnvPath := resolveHermesEnvPath()
|
||||
larkChannelPath := resolveLarkChannelConfigPath()
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
@@ -444,6 +490,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
|
||||
Options(
|
||||
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
|
||||
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
|
||||
huh.NewOption(fmt.Sprintf(msg.SourceLarkChannel, larkChannelPath), "lark-channel"),
|
||||
).
|
||||
Value(&source),
|
||||
),
|
||||
|
||||
@@ -12,10 +12,11 @@ package config
|
||||
type bindMsg struct {
|
||||
// Source selection.
|
||||
// SelectSourceDesc format: brand.
|
||||
SelectSource string
|
||||
SelectSourceDesc string
|
||||
SourceOpenClaw string // format: resolved config path.
|
||||
SourceHermes string // format: resolved dotenv path.
|
||||
SelectSource string
|
||||
SelectSourceDesc string
|
||||
SourceOpenClaw string // format: resolved config path.
|
||||
SourceHermes string // format: resolved dotenv path.
|
||||
SourceLarkChannel string // format: resolved config path.
|
||||
|
||||
// Account selection (OpenClaw multi-account).
|
||||
// Format: source display name ("OpenClaw" | "Hermes"), brand.
|
||||
@@ -86,10 +87,11 @@ type bindMsg struct {
|
||||
}
|
||||
|
||||
var bindMsgZh = &bindMsg{
|
||||
SelectSource: "你想在哪个 Agent 中使用 lark-cli?",
|
||||
SelectSourceDesc: "从你选择的 Agent 中获取%s应用信息,并配置到 lark-cli 中",
|
||||
SourceOpenClaw: "OpenClaw — 配置文件: %s",
|
||||
SourceHermes: "Hermes — 配置文件: %s",
|
||||
SelectSource: "你想在哪个 Agent 中使用 lark-cli?",
|
||||
SelectSourceDesc: "从你选择的 Agent 中获取%s应用信息,并配置到 lark-cli 中",
|
||||
SourceOpenClaw: "OpenClaw — 配置文件: %s",
|
||||
SourceHermes: "Hermes — 配置文件: %s",
|
||||
SourceLarkChannel: "Lark Channel — 配置文件: %s",
|
||||
|
||||
SelectAccount: "检测到 %s 中已配置多个%s应用,请选择一个",
|
||||
|
||||
@@ -117,10 +119,11 @@ var bindMsgZh = &bindMsg{
|
||||
}
|
||||
|
||||
var bindMsgEn = &bindMsg{
|
||||
SelectSource: "Which Agent are you running?",
|
||||
SelectSourceDesc: "lark-cli will read your %s app credentials from the selected Agent and apply them automatically.",
|
||||
SourceOpenClaw: "OpenClaw — config: %s",
|
||||
SourceHermes: "Hermes — config: %s",
|
||||
SelectSource: "Which Agent are you running?",
|
||||
SelectSourceDesc: "lark-cli will read your %s app credentials from the selected Agent and apply them automatically.",
|
||||
SourceOpenClaw: "OpenClaw — config: %s",
|
||||
SourceHermes: "Hermes — config: %s",
|
||||
SourceLarkChannel: "Lark Channel — config: %s",
|
||||
|
||||
// Args order (source, brand) matches the Chinese template; %[N]s lets the
|
||||
// English reading order differ while the caller passes args in one order.
|
||||
|
||||
@@ -123,7 +123,7 @@ func TestConfigBindRun_InvalidSource(t *testing.T) {
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `invalid --source "invalid"; valid values: openclaw, hermes`,
|
||||
Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -141,21 +141,29 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "bind",
|
||||
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
|
||||
Hint: "pass --source openclaw|hermes, or run this command inside an OpenClaw or Hermes chat",
|
||||
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
|
||||
})
|
||||
}
|
||||
|
||||
// clearAgentEnv removes all env vars that DetectWorkspaceFromEnv checks, so
|
||||
// tests exercising the "no signals" path are not affected by whatever the
|
||||
// host shell happens to have exported. t.Setenv restores them after the
|
||||
// test returns.
|
||||
// clearAgentEnv removes every env var that DetectWorkspaceFromEnv treats as
|
||||
// an Agent signal, so tests exercising the "no signals" path stay isolated
|
||||
// from whatever the host shell exported. Prefix-based instead of an explicit
|
||||
// list — when DetectWorkspaceFromEnv gains a new OPENCLAW_* / HERMES_* signal,
|
||||
// this helper does not need to be updated and tests do not silently misroute.
|
||||
// t.Setenv restores the original values after the test returns.
|
||||
func clearAgentEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, k := range []string{
|
||||
"OPENCLAW_CLI", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH",
|
||||
"HERMES_HOME", "HERMES_QUIET", "HERMES_EXEC_ASK", "HERMES_GATEWAY_TOKEN", "HERMES_SESSION_KEY",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
for _, kv := range os.Environ() {
|
||||
idx := strings.IndexByte(kv, '=')
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
k := kv[:idx]
|
||||
if strings.HasPrefix(k, "OPENCLAW_") ||
|
||||
strings.HasPrefix(k, "HERMES_") ||
|
||||
k == "LARK_CHANNEL" {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +347,211 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// writeLarkChannelFixture writes a ~/.lark-channel/config.json under fakeHome
|
||||
// and returns the config path. resolveLarkChannelConfigPath reads HOME via
|
||||
// os.UserHomeDir, so callers must `t.Setenv("HOME", fakeHome)`.
|
||||
func writeLarkChannelFixture(t *testing.T, fakeHome, body string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(fakeHome, ".lark-channel")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// Happy-path: --source lark-channel reads ~/.lark-channel/config.json,
|
||||
// writes the workspace config, emits a JSON envelope with workspace:
|
||||
// "lark-channel" and brand from accounts.app.tenant.
|
||||
func TestConfigBindRun_LarkChannel_Success(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
clearAgentEnv(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_lc_main","secret":"lc_secret","tenant":"feishu"}}}`)
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
|
||||
envelope := map[string]any{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v", err)
|
||||
}
|
||||
if envelope["workspace"] != "lark-channel" {
|
||||
t.Errorf("workspace = %v, want %q", envelope["workspace"], "lark-channel")
|
||||
}
|
||||
if envelope["app_id"] != "cli_lc_main" {
|
||||
t.Errorf("app_id = %v, want %q", envelope["app_id"], "cli_lc_main")
|
||||
}
|
||||
|
||||
// Brand is not in the stdout envelope — read it back from the persisted
|
||||
// workspace config to verify accounts.app.tenant flowed through to the
|
||||
// stored AppConfig.Brand field.
|
||||
core.SetCurrentWorkspace(core.WorkspaceLarkChannel)
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load workspace config: %v", err)
|
||||
}
|
||||
if len(multi.Apps) != 1 {
|
||||
t.Fatalf("expected 1 app, got %d", len(multi.Apps))
|
||||
}
|
||||
if got := string(multi.Apps[0].Brand); got != "feishu" {
|
||||
t.Errorf("Brand = %q, want %q", got, "feishu")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_lc_lark","secret":"s","tenant":"lark"}}}`)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
core.SetCurrentWorkspace(core.WorkspaceLarkChannel)
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load workspace config: %v", err)
|
||||
}
|
||||
if got := string(multi.Apps[0].Brand); got != "lark" {
|
||||
t.Errorf("Brand = %q, want %q (tenant: lark must flow through to AppConfig.Brand)", got, "lark")
|
||||
}
|
||||
}
|
||||
|
||||
// LARK_CHANNEL=1 alone (no --source) auto-detects to the lark-channel
|
||||
// workspace, mirroring the OpenClaw/Hermes auto-detect flow.
|
||||
func TestConfigBindRun_AutoDetect_LarkChannelFromEnv(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
t.Setenv("LARK_CHANNEL", "1")
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_auto_lc","secret":"s","tenant":"feishu"}}}`)
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
envelope := map[string]any{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v", err)
|
||||
}
|
||||
if envelope["workspace"] != "lark-channel" {
|
||||
t.Errorf("workspace = %v, want %q (auto-detection should pick lark-channel from LARK_CHANNEL=1)", envelope["workspace"], "lark-channel")
|
||||
}
|
||||
}
|
||||
|
||||
// --source lark-channel while the env signals OpenClaw must fail loud, same
|
||||
// rule as OpenClaw/Hermes mismatch (running in the wrong Agent context).
|
||||
func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
t.Setenv("OPENCLAW_HOME", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "bind",
|
||||
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
|
||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||
})
|
||||
}
|
||||
|
||||
// Missing config.json → typed error with a hint pointing at bridge setup.
|
||||
func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
|
||||
fakeHome := t.TempDir() // empty — no .lark-channel/config.json
|
||||
t.Setenv("HOME", fakeHome)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "lark-channel",
|
||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||
Hint: "verify lark-channel-bridge is installed and configured",
|
||||
})
|
||||
}
|
||||
|
||||
// Empty accounts.app.id → typed error pointing at bridge setup. Distinct
|
||||
// from "missing file" so users know whether to install or to re-run setup.
|
||||
func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
configPath := writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"","secret":"","tenant":"feishu"}}}`)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "lark-channel",
|
||||
Message: "accounts.app.id missing in " + configPath,
|
||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||
})
|
||||
}
|
||||
|
||||
// app.id present but app.secret missing → typed error at the Build step.
|
||||
func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
configPath := writeLarkChannelFixture(t, fakeHome, `{"accounts":{"app":{"id":"cli_no_secret","secret":"","tenant":"feishu"}}}`)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "lark-channel",
|
||||
Message: "accounts.app.secret is empty in " + configPath,
|
||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigShowRun_WorkspaceField(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
configDir := t.TempDir()
|
||||
@@ -377,16 +590,28 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unbound workspace")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
// Should be a structured ConfigError suggesting config bind, not config init.
|
||||
var cfgErr *core.ConfigError
|
||||
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)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
|
||||
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
|
||||
}
|
||||
// Hint must point at config bind --help (NOT a ready-to-run bind command):
|
||||
// AI must read the help and confirm identity preset with the user first.
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hint must point at `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config init") {
|
||||
t.Errorf("agent hint must not mention config init; got %q", cfgErr.Hint)
|
||||
}
|
||||
// Should suggest config bind, not config init
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "openclaw context detected but lark-cli not bound to openclaw workspace",
|
||||
Hint: "run: lark-cli config bind --source openclaw",
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helper function tests (dotenv, brand, path resolution) ──
|
||||
|
||||
62
cmd/config/bind_warning_test.go
Normal file
62
cmd/config/bind_warning_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// runHermesBindWithIdentity boots a Hermes-shaped fake env, runs `config bind`
|
||||
// with the given identity preset in flag (non-TUI) mode, and returns captured
|
||||
// stderr. Hermes is the simplest source to fake (single .env file).
|
||||
func runHermesBindWithIdentity(t *testing.T, identity string) string {
|
||||
t.Helper()
|
||||
saveWorkspace(t)
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
hermesHome := t.TempDir()
|
||||
t.Setenv("HERMES_HOME", hermesHome)
|
||||
envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"
|
||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte(envContent), 0600); err != nil {
|
||||
t.Fatalf("write .env: %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{
|
||||
Factory: f,
|
||||
Source: "hermes",
|
||||
Identity: identity,
|
||||
Lang: "zh",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bind failed: %v", err)
|
||||
}
|
||||
return stderr.String()
|
||||
}
|
||||
|
||||
// TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation covers the
|
||||
// gap that previously slipped through: a fresh flag-mode bind landing on
|
||||
// user-default. warnIdentityEscalation requires a previous bot lock to fire,
|
||||
// and IdentityUserDefaultDesc only renders in TUI selection — so without
|
||||
// noticeUserDefaultRisk the user/AI never see the impersonation risk on a
|
||||
// first-time user-default bind.
|
||||
func TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation(t *testing.T) {
|
||||
out := runHermesBindWithIdentity(t, "user-default")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("user-default bind must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigBindRun_BotOnlyIdentity_NoImpersonationWarning(t *testing.T) {
|
||||
out := runHermesBindWithIdentity(t, "bot-only")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot-only bind must NOT warn about impersonation; got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ func newBinder(source string, opts *BindOptions) (SourceBinder, error) {
|
||||
return &openclawBinder{opts: opts, path: resolveOpenClawConfigPath()}, nil
|
||||
case "hermes":
|
||||
return &hermesBinder{opts: opts, path: resolveHermesEnvPath()}, nil
|
||||
case "lark-channel":
|
||||
return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil
|
||||
default:
|
||||
return nil, output.ErrValidation("unsupported source: %s", source)
|
||||
}
|
||||
@@ -270,6 +272,74 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// larkChannelBinder
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
type larkChannelBinder struct {
|
||||
opts *BindOptions
|
||||
path string
|
||||
|
||||
// Cached between ListCandidates and Build so we don't re-read the file.
|
||||
cfg *binding.LarkChannelRoot
|
||||
}
|
||||
|
||||
func (b *larkChannelBinder) Name() string { return "lark-channel" }
|
||||
func (b *larkChannelBinder) ConfigPath() string { return b.path }
|
||||
|
||||
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
||||
cfg, err := binding.ReadLarkChannelConfig(b.path)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("cannot read %s: %v", b.path, err),
|
||||
"verify lark-channel-bridge is installed and configured")
|
||||
}
|
||||
if cfg.Accounts.App.ID == "" {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("accounts.app.id missing in %s", b.path),
|
||||
"run lark-channel-bridge's setup to populate the app credential")
|
||||
}
|
||||
b.cfg = cfg
|
||||
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
|
||||
}
|
||||
|
||||
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.cfg == nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||
"internal: Build called before ListCandidates")
|
||||
}
|
||||
if b.cfg.Accounts.App.ID != appID {
|
||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||
"internal: appID %q does not match config", appID)
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Source-specific helpers (path / dotenv / brand) — kept private to this package.
|
||||
// Moved here from bind.go so bind.go can focus on orchestration.
|
||||
@@ -283,6 +353,8 @@ func sourceDisplayName(source string) string {
|
||||
return "OpenClaw"
|
||||
case "hermes":
|
||||
return "Hermes"
|
||||
case "lark-channel":
|
||||
return "Lark Channel"
|
||||
default:
|
||||
return source
|
||||
}
|
||||
@@ -316,6 +388,18 @@ func resolveHermesEnvPath() string {
|
||||
return filepath.Join(hermesHome, ".env")
|
||||
}
|
||||
|
||||
// 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.
|
||||
func resolveLarkChannelConfigPath() string {
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
|
||||
}
|
||||
return filepath.Join(home, ".lark-channel", "config.json")
|
||||
}
|
||||
|
||||
// resolveOpenClawConfigPath resolves openclaw.json path using the same priority
|
||||
// chain as OpenClaw's src/config/paths.ts:
|
||||
// 1. OPENCLAW_CONFIG_PATH env → exact file path
|
||||
|
||||
@@ -31,6 +31,8 @@ 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))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ func (r *recordingConfigKeychain) Remove(service, account string) error {
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_FlagParsing(t *testing.T) {
|
||||
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret123\n")
|
||||
|
||||
@@ -90,15 +91,15 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "not configured" {
|
||||
t.Fatalf("detail = %#v, want config/not configured", exitErr.Detail)
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +137,7 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_LangFlag(t *testing.T) {
|
||||
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *ConfigInitOptions
|
||||
@@ -157,6 +159,7 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_LangDefault(t *testing.T) {
|
||||
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *ConfigInitOptions
|
||||
|
||||
@@ -20,14 +20,14 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
@@ -52,5 +52,6 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
@@ -33,6 +34,13 @@ type ConfigInitOptions struct {
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
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
|
||||
// at config bind — which is what AI agents almost always want. Manual
|
||||
// users with a legitimate need for a separate app can pass --force-init
|
||||
// to bypass.
|
||||
ForceInit bool
|
||||
}
|
||||
|
||||
// NewCmdConfigInit creates the config init subcommand.
|
||||
@@ -46,10 +54,18 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.`,
|
||||
verification URL from its output.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
|
||||
refuses by default — use 'lark-cli config bind' to bind to the Agent's
|
||||
existing app instead of creating a parallel one. Pass --force-init only
|
||||
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 := guardAgentWorkspace(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -63,10 +79,34 @@ verification URL from its output.`,
|
||||
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.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
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Running init here would create a parallel app under the agent's workspace
|
||||
// dir, breaking the binding the user actually wants. --force-init lets a
|
||||
// human user override when they really do want a separate app.
|
||||
func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
if opts.ForceInit {
|
||||
return nil
|
||||
}
|
||||
ws := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
if ws.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
return &core.ConfigError{
|
||||
Code: 2,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
|
||||
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
|
||||
return o.New || o.AppID != "" || o.AppSecretStdin
|
||||
@@ -269,7 +309,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
|
||||
// Mode 3: Create new app directly (--new)
|
||||
if opts.New {
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, core.BrandFeishu, msg)
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
67
cmd/config/init_guard_test.go
Normal file
67
cmd/config/init_guard_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
|
||||
clearAgentEnv(t)
|
||||
|
||||
if err := guardAgentWorkspace(&ConfigInitOptions{}); err != nil {
|
||||
t.Errorf("local workspace should allow init, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", t.TempDir())
|
||||
|
||||
err := guardAgentWorkspace(&ConfigInitOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in OpenClaw context, got nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "--force-init") {
|
||||
t.Errorf("hint must mention --force-init escape hatch; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
|
||||
t.Setenv("HERMES_HOME", t.TempDir())
|
||||
|
||||
err := guardAgentWorkspace(&ConfigInitOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in Hermes context, got nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "hermes" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_ForceInitOverride(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", t.TempDir())
|
||||
|
||||
// --force-init must let the user proceed even inside an Agent context.
|
||||
if err := guardAgentWorkspace(&ConfigInitOptions{ForceInit: true}); err != nil {
|
||||
t.Errorf("--force-init should bypass the guard, got: %v", err)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -44,12 +45,12 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return notConfiguredError()
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
@@ -75,18 +76,3 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
// notConfiguredError returns the "not configured" error with a hint that
|
||||
// points the user to the right next step: config init for the default local
|
||||
// workspace, config bind for an Agent workspace that has not been bound yet.
|
||||
func notConfiguredError() error {
|
||||
ws := core.CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return output.ErrWithHint(output.ExitValidation, "config",
|
||||
"not configured",
|
||||
"run: lark-cli config init")
|
||||
}
|
||||
return output.ErrWithHint(output.ExitValidation, ws.Display(),
|
||||
fmt.Sprintf("%s context detected but lark-cli not bound to %s workspace", ws.Display(), ws.Display()),
|
||||
fmt.Sprintf("run: lark-cli config bind --source %s", ws.Display()))
|
||||
}
|
||||
|
||||
@@ -21,44 +21,44 @@ func NewCmdConfigStrictMode(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "strict-mode [bot|user|off]",
|
||||
Short: "View or set strict mode (identity restriction policy)",
|
||||
Long: `View or set strict mode (identity restriction policy).
|
||||
Long: `View or set strict mode — the identity restriction policy.
|
||||
|
||||
Without arguments, shows the current strict mode status and its source.
|
||||
Pass "bot", "user", or "off" to set strict mode.
|
||||
Use --global to set at the global level.
|
||||
Use --reset to clear the profile-level setting (inherit global).
|
||||
bot only bot identity allowed (user commands hidden)
|
||||
user only user identity allowed (bot commands hidden)
|
||||
off no restriction (default)
|
||||
|
||||
Modes:
|
||||
bot — only bot identity is allowed, user commands are hidden
|
||||
user — only user identity is allowed, bot commands are hidden
|
||||
off — no restriction (default)
|
||||
No args: show current mode. Switching does NOT require re-bind.
|
||||
|
||||
WARNING: Strict mode is a security policy set by the administrator.
|
||||
AI agents are strictly prohibited from modifying this setting.`,
|
||||
For AI agents: this is a security policy. DO NOT switch without
|
||||
explicit user confirmation — never run on your own initiative.`,
|
||||
Example: ` lark-cli config strict-mode # show current
|
||||
lark-cli config strict-mode user # switch (after user confirms)
|
||||
lark-cli config strict-mode bot --global # set globally
|
||||
lark-cli config strict-mode --reset # clear profile override`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
if reset {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return resetStrictMode(f, multi, app, global, args)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return showStrictMode(cmd.Context(), f, multi, app)
|
||||
}
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if !global && app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return setStrictMode(f, multi, app, args[0], global)
|
||||
},
|
||||
@@ -66,6 +66,7 @@ AI agents are strictly prohibited from modifying this setting.`,
|
||||
|
||||
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
|
||||
}
|
||||
@@ -106,6 +107,24 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
|
||||
}
|
||||
|
||||
// Capture the old mode at the SAME scope being changed, so we can warn
|
||||
// only when the policy actually expands user-identity at that scope.
|
||||
// --global → compare raw multi.StrictMode (profiles with explicit
|
||||
// overrides are unaffected; their warning comes from the existing
|
||||
// "profile %q has strict-mode explicitly set" notice below).
|
||||
// profile → compare effective mode (override > global > default), so
|
||||
// a profile flipping from inherited bot to explicit off still warns.
|
||||
// The previous version always used the profile's effective mode, which
|
||||
// false-positived (--global change while current profile has an explicit
|
||||
// override) and false-negatived (--global broadening that doesn't affect
|
||||
// the current profile but does affect other inheriting profiles).
|
||||
var oldMode core.StrictMode
|
||||
if global {
|
||||
oldMode = multi.StrictMode
|
||||
} else {
|
||||
oldMode, _ = resolveStrictModeStatus(multi, app)
|
||||
}
|
||||
|
||||
if global {
|
||||
multi.StrictMode = mode
|
||||
for _, a := range multi.Apps {
|
||||
@@ -119,7 +138,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
}
|
||||
} else {
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
app.StrictMode = &mode
|
||||
}
|
||||
@@ -127,6 +146,11 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "⚠️ "+strictModeRelaxLang(app).IdentityEscalationMessage)
|
||||
}
|
||||
|
||||
scope := "profile"
|
||||
if global {
|
||||
scope = "global"
|
||||
@@ -135,6 +159,16 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictModeRelaxLang picks the bind-message bundle whose language matches the
|
||||
// active profile's Lang setting. Falls back to bindMsgZh when no profile is
|
||||
// available (global mutation with no current app).
|
||||
func strictModeRelaxLang(app *core.AppConfig) *bindMsg {
|
||||
if app != nil {
|
||||
return getBindMsg(app.Lang)
|
||||
}
|
||||
return getBindMsg("")
|
||||
}
|
||||
|
||||
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) {
|
||||
if app != nil && app.StrictMode != nil {
|
||||
return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName())
|
||||
|
||||
140
cmd/config/strict_mode_warning_test.go
Normal file
140
cmd/config/strict_mode_warning_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// runStrictMode is a small helper that runs `config strict-mode <args...>` and
|
||||
// returns the captured stderr — that's where success-path messages and the
|
||||
// new user-identity warning land.
|
||||
func runStrictMode(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("strict-mode %v failed: %v", args, err)
|
||||
}
|
||||
return stderr.String()
|
||||
}
|
||||
|
||||
// expandsUserIdentity covers the only two transitions where AI gains the
|
||||
// ability to act under the user's identity, and asserts the warning fires.
|
||||
// Reuses bind_messages.go's IdentityEscalationMessage as the canonical text
|
||||
// so all three call sites (bind upgrade, fresh user-default bind, strict-mode
|
||||
// relax) stay phrased identically.
|
||||
func TestStrictMode_BotToUser_WarnsAboutIdentityRisk(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot")
|
||||
|
||||
out := runStrictMode(t, "user")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot→user transition must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_BotToOff_WarnsAboutIdentityRisk(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot")
|
||||
|
||||
out := runStrictMode(t, "off")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot→off transition must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// narrowingDoesNotWarn covers the cases that revoke or keep user-identity
|
||||
// scope — those should stay quiet, otherwise AI will spam users with risk
|
||||
// text on every restrictive change.
|
||||
func TestStrictMode_UserToBot_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "user")
|
||||
|
||||
out := runStrictMode(t, "bot")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("user→bot is a narrowing change; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_OffToBot_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
// Default starts at off; explicitly set bot — narrowing.
|
||||
out := runStrictMode(t, "bot")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("off→bot is a narrowing change; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_OffToUser_NoWarning(t *testing.T) {
|
||||
// Off already permits user-identity, so off→user is not a NEW grant
|
||||
// even though it forces user identity. Don't warn.
|
||||
setupStrictModeTestConfig(t)
|
||||
out := runStrictMode(t, "user")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("off→user does not newly permit user identity; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- --global path: comparison must use multi.StrictMode, not profile's
|
||||
// effective mode. The previous (buggy) version used resolveStrictModeStatus
|
||||
// here too, leading to both false positives (current profile has explicit
|
||||
// override unaffected by --global → still warned) and false negatives
|
||||
// (current profile has explicit override that masks an actual bot → off
|
||||
// global broadening for OTHER inheriting profiles → didn't warn).
|
||||
|
||||
func TestStrictMode_GlobalBotToUser_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global")
|
||||
|
||||
out := runStrictMode(t, "user", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→user must warn (broadens user-identity for inheriting profiles); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_GlobalBotToOff_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global")
|
||||
|
||||
out := runStrictMode(t, "off", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→off must warn (newly permits user identity in inheriting profiles); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// FalsePositive: current profile has explicit "bot" override, global goes
|
||||
// off → user. The current profile is unaffected (still bot via override),
|
||||
// and off→user at the global level is not a new grant either. Must not warn.
|
||||
func TestStrictMode_GlobalOffToUser_WithProfileBotOverride_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot") // profile-level explicit bot
|
||||
runStrictMode(t, "off", "--global") // global = off
|
||||
|
||||
out := runStrictMode(t, "user", "--global")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global off→user with profile-bot-override must not warn (profile unaffected, global wasn't bot); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// FalseNegative: global = bot, current profile has explicit "off" override.
|
||||
// Running --global off broadens OTHER inheriting profiles (bot → off). The
|
||||
// current profile doesn't change effective mode, but the policy still expanded
|
||||
// user-identity, so warning must fire. The pre-fix logic compared via the
|
||||
// current profile's effective mode and missed this case.
|
||||
func TestStrictMode_GlobalBotToOff_WithProfileOffOverride_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global") // global = bot
|
||||
runStrictMode(t, "off") // profile-level explicit off (already shows the warning at profile scope)
|
||||
|
||||
out := runStrictMode(t, "off", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→off must warn even when current profile has explicit off (other profiles inherit and newly permit user identity); got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -8,15 +8,16 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -42,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
|
||||
}
|
||||
@@ -49,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"`
|
||||
}
|
||||
@@ -83,7 +85,20 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
// ── 1. Config file ──
|
||||
_, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
checks = append(checks, fail("config_file", err.Error(), "run: lark-cli config init"))
|
||||
// For "config not present" cases, prefer the workspace-aware
|
||||
// NotConfiguredError message + hint (e.g. "openclaw context
|
||||
// detected but lark-cli is not bound to it" → bind --help) over
|
||||
// the OS-level "open ... no such file or directory".
|
||||
// For other errors (parse, perms), keep the raw error so the
|
||||
// underlying problem is still visible.
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
}
|
||||
checks = append(checks, fail("config_file", msg, hint))
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
checks = append(checks, pass("config_file", "config.json found"))
|
||||
@@ -103,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 {
|
||||
@@ -217,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).
|
||||
@@ -238,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)
|
||||
}
|
||||
|
||||
175
cmd/error_auth_hint.go
Normal file
175
cmd/error_auth_hint.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/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.
|
||||
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if exitErr == nil || exitErr.Detail == nil {
|
||||
return
|
||||
}
|
||||
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
|
||||
return
|
||||
}
|
||||
exitErr.Detail.Hint += "\n" + scopeHint
|
||||
}
|
||||
|
||||
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
|
||||
// current command for the resolved identity, checking shortcuts first and then
|
||||
// service methods from local registry metadata.
|
||||
func resolveDeclaredScopesForCurrentCommand(f *cmdutil.Factory) []string {
|
||||
if f == nil || f.CurrentCommand == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
identity := string(f.ResolvedIdentity)
|
||||
if identity == "" {
|
||||
identity = string(core.AsUser)
|
||||
}
|
||||
if identity != string(core.AsUser) && identity != string(core.AsBot) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if scopes := resolveDeclaredShortcutScopes(f.CurrentCommand, identity); len(scopes) > 0 {
|
||||
return scopes
|
||||
}
|
||||
return resolveDeclaredServiceMethodScopes(f.CurrentCommand, identity)
|
||||
}
|
||||
|
||||
// resolveDeclaredShortcutScopes returns the scopes declared by a mounted
|
||||
// shortcut command for the given identity.
|
||||
func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string {
|
||||
if cmd == nil || cmd.Parent() == nil || !strings.HasPrefix(cmd.Name(), "+") {
|
||||
return nil
|
||||
}
|
||||
|
||||
service := cmd.Parent().Name()
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
continue
|
||||
}
|
||||
scopes := sc.DeclaredScopesForIdentity(identity)
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), scopes...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
|
||||
// service/resource/method command from the embedded from_meta registry.
|
||||
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
|
||||
// Service-method scope lookup only applies to commands mounted as
|
||||
// root -> service -> resource -> method. Non-resource/method commands
|
||||
// intentionally return no scopes here so auth-hint enrichment does not
|
||||
// change runtime semantics for other command shapes.
|
||||
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(cmd.Name(), "+") {
|
||||
return nil
|
||||
}
|
||||
|
||||
service := cmd.Parent().Parent().Name()
|
||||
resource := cmd.Parent().Name()
|
||||
method := cmd.Name()
|
||||
|
||||
spec := registry.LoadFromMeta(service)
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resMap, _ := resources[resource].(map[string]interface{})
|
||||
if resMap == nil {
|
||||
return nil
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methodMap, _ := methods[method].(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
return nil
|
||||
}
|
||||
return declaredScopesForMethod(methodMap, identity)
|
||||
}
|
||||
|
||||
// declaredScopesForMethod returns all requiredScopes when present; otherwise it
|
||||
// resolves the single recommended scope from the method's scopes list.
|
||||
func declaredScopesForMethod(method map[string]interface{}, identity string) []string {
|
||||
if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 {
|
||||
return interfaceStrings(requiredRaw)
|
||||
}
|
||||
|
||||
rawScopes, _ := method["scopes"].([]interface{})
|
||||
if len(rawScopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
recommended := registry.SelectRecommendedScope(rawScopes, identity)
|
||||
if recommended == "" {
|
||||
for _, raw := range rawScopes {
|
||||
if scope, ok := raw.(string); ok && scope != "" {
|
||||
recommended = scope
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if recommended == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{recommended}
|
||||
}
|
||||
|
||||
// interfaceStrings converts a []interface{} containing strings into a compact
|
||||
// []string, skipping empty or non-string values.
|
||||
func interfaceStrings(values []interface{}) []string {
|
||||
scopes := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
scope, ok := value.(string)
|
||||
if !ok || scope == "" {
|
||||
continue
|
||||
}
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||
// identity, applying the default user-only behavior when AuthTypes is empty.
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{string(core.AsUser)}
|
||||
}
|
||||
for _, authType := range authTypes {
|
||||
if authType == identity {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
25
cmd/event/appmeta_err.go
Normal file
25
cmd/event/appmeta_err.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
|
||||
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
|
||||
|
||||
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.
|
||||
func describeAppMetaErr(err error) string {
|
||||
msg := err.Error()
|
||||
if url := authURLPattern.FindString(msg); url != "" {
|
||||
return fmt.Sprintf("bot is missing scopes needed for app-version metadata; grant at: %s", url)
|
||||
}
|
||||
const maxErrLen = 200
|
||||
if len(msg) > maxErrLen {
|
||||
return msg[:maxErrLen] + "…"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
54
cmd/event/appmeta_err_test.go
Normal file
54
cmd/event/appmeta_err_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const realisticPermError = `API GET /open-apis/application/v6/applications/cli_XXXXXXXXXXXXXXXX/app_versions?lang=zh_cn&page_size=2 returned 400: {"code":99991672,"msg":"Access denied. One of the following scopes is required: [application:application:self_manage, application:application.app_version:readonly].应用尚未开通所需的应用身份权限:[application:application:self_manage, application:application.app_version:readonly],点击链接申请并开通任一权限即可:https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=application:application:self_manage,application:application.app_version:readonly&op_from=openapi&token_type=tenant","error":{"message":"Refer to the documentation...","log_id":"20260421101203E2A5F141245B6F43B3A6"}}`
|
||||
|
||||
func TestDescribeAppMetaErr_PermissionDeniedShort(t *testing.T) {
|
||||
got := describeAppMetaErr(errors.New(realisticPermError))
|
||||
if len(got) > 400 {
|
||||
t.Errorf("summary too long (%d chars): %q", len(got), got)
|
||||
}
|
||||
if !strings.Contains(got, "scope") {
|
||||
t.Errorf("summary should mention scope requirement, got: %q", got)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=application:application:self_manage,application:application.app_version:readonly&op_from=openapi&token_type=tenant"
|
||||
if !strings.Contains(got, wantURL) {
|
||||
t.Errorf("summary missing grant URL\ngot: %q\nwant: %q", got, wantURL)
|
||||
}
|
||||
for _, noise := range []string{"log_id", `"error":`, "Refer to the documentation"} {
|
||||
if strings.Contains(got, noise) {
|
||||
t.Errorf("summary leaked noise %q: %q", noise, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAppMetaErr_UnknownErrorTruncated(t *testing.T) {
|
||||
long := strings.Repeat("x", 500)
|
||||
got := describeAppMetaErr(errors.New(long))
|
||||
if len(got) > 220 {
|
||||
t.Errorf("unknown error not truncated, len=%d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAppMetaErr_ShortErrorPassesThrough(t *testing.T) {
|
||||
got := describeAppMetaErr(errors.New("network unreachable"))
|
||||
if got != "network unreachable" {
|
||||
t.Errorf("short err should pass through unchanged, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeAppMetaErr_LarkOfficeDomain(t *testing.T) {
|
||||
msg := `... grant link: https://open.larksuite.com/app/cli_xyz/auth?q=application:application:self_manage&op_from=openapi&token_type=tenant ...`
|
||||
got := describeAppMetaErr(errors.New(msg))
|
||||
if !strings.Contains(got, "open.larksuite.com") {
|
||||
t.Errorf("want larksuite URL extracted, got: %q", got)
|
||||
}
|
||||
}
|
||||
70
cmd/event/bus.go
Normal file
70
cmd/event/bus.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/bus"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go.
|
||||
func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
var domain string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "_bus",
|
||||
Short: "Internal event bus daemon (do not call directly)",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Sanitize AppID: an unsanitized value could escape events/ via ".." or separators.
|
||||
eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID))
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger)
|
||||
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||
defer signal.Stop(sigCh)
|
||||
go func() {
|
||||
select {
|
||||
case <-sigCh:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
return b.Run(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&domain, "domain", "", "API domain")
|
||||
_ = cmd.Flags().MarkHidden("domain")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
24
cmd/event/console_url.go
Normal file
24
cmd/event/console_url.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
|
||||
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
|
||||
host, appID, strings.Join(scopes, ","))
|
||||
}
|
||||
|
||||
// consoleEventSubscriptionURL points at the app's event subscription console page.
|
||||
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/event", host, appID)
|
||||
}
|
||||
36
cmd/event/console_url_test.go
Normal file
36
cmd/event/console_url_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
|
||||
"im:message:readonly",
|
||||
"im:message.group_at_msg",
|
||||
})
|
||||
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
|
||||
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
|
||||
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
|
||||
t.Errorf("unexpected url: %s", got)
|
||||
}
|
||||
}
|
||||
372
cmd/event/consume.go
Normal file
372
cmd/event/consume.go
Normal file
@@ -0,0 +1,372 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/consume"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type consumeCmdOpts struct {
|
||||
params []string
|
||||
jqExpr string
|
||||
quiet bool
|
||||
outputDir string
|
||||
|
||||
maxEvents int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewCmdConsume(f *cmdutil.Factory) *cobra.Command {
|
||||
var o consumeCmdOpts
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "consume <EventKey>",
|
||||
Short: "Start consuming events for an EventKey",
|
||||
Long: `Start consuming real-time events for the given EventKey.
|
||||
|
||||
The consume command connects to the event bus daemon (starting it if needed),
|
||||
subscribes to the specified EventKey, and streams processed events to stdout.
|
||||
|
||||
Output is one JSON object per line (NDJSON). Pipe through 'jq .' if you need
|
||||
pretty-printed formatting.
|
||||
|
||||
Use 'event list' to see all available EventKeys.
|
||||
Use 'event schema <EventKey>' for parameter details.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConsume(cmd, f, args[0], o)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringArrayVarP(&o.params, "param", "p", nil, "Key=value parameter (repeatable)")
|
||||
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
||||
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
||||
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
|
||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consumeCmdOpts) error {
|
||||
// Pipe-close (e.g. `... | head -n 1`) must reach the EPIPE error path in the loop, not SIGPIPE-kill.
|
||||
ignoreBrokenPipe()
|
||||
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
paramMap, err := parseParams(o.params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keyDef, ok := eventlib.Lookup(eventKey)
|
||||
if !ok {
|
||||
return unknownEventKeyErr(eventKey)
|
||||
}
|
||||
|
||||
identity, err := resolveIdentity(cmd, f, keyDef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
err.Error(),
|
||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
outputDir := o.outputDir
|
||||
if outputDir != "" {
|
||||
safePath, err := sanitizeOutputDir(outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir = safePath
|
||||
}
|
||||
|
||||
domain := core.ResolveEndpoints(cfg.Brand).Open
|
||||
|
||||
// Surface auth errors before forking the bus daemon.
|
||||
if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiClient, err := f.NewAPIClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime := &consumeRuntime{client: apiClient, accessIdentity: identity}
|
||||
// botRuntime pins AsBot: /app_versions rejects UAT (99991668) and /connection is app-level.
|
||||
botRuntime := &consumeRuntime{client: apiClient, accessIdentity: core.AsBot}
|
||||
|
||||
// Weak-dependency fetch: failures leave appVer==nil and downgrade preflight to a no-op.
|
||||
preflightErrOut := f.IOStreams.ErrOut
|
||||
if o.quiet {
|
||||
preflightErrOut = io.Discard
|
||||
}
|
||||
appVer, appVerErr := appmeta.FetchCurrentPublished(cmd.Context(), botRuntime, cfg.AppID)
|
||||
switch {
|
||||
case appVerErr != nil:
|
||||
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(appVerErr))
|
||||
case appVer == nil:
|
||||
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
|
||||
}
|
||||
|
||||
pf := &preflightCtx{
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := preflightScopes(cmd.Context(), pf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer signal.Stop(sigCh)
|
||||
go func() {
|
||||
select {
|
||||
case <-sigCh:
|
||||
if !o.quiet && f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "\nShutting down...")
|
||||
}
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
errOut := f.IOStreams.ErrOut
|
||||
if o.quiet {
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||
}
|
||||
|
||||
if err := consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{
|
||||
EventKey: eventKey,
|
||||
Params: paramMap,
|
||||
JQExpr: o.jqExpr,
|
||||
Quiet: o.quiet,
|
||||
OutputDir: outputDir,
|
||||
Runtime: runtime,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: errOut,
|
||||
RemoteAPIClient: botRuntime,
|
||||
MaxEvents: o.maxEvents,
|
||||
Timeout: o.timeout,
|
||||
IsTTY: f.IOStreams.IsTerminal,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist.
|
||||
func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (core.Identity, error) {
|
||||
flagAs := core.Identity(cmd.Flag("as").Value.String())
|
||||
identity := f.ResolveAs(cmd.Context(), cmd, flagAs)
|
||||
if len(keyDef.AuthTypes) > 0 {
|
||||
if err := f.CheckIdentity(identity, keyDef.AuthTypes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
type preflightCtx struct {
|
||||
factory *cmdutil.Factory
|
||||
appID string
|
||||
brand core.LarkBrand
|
||||
eventKey string
|
||||
identity core.Identity
|
||||
keyDef *eventlib.KeyDefinition
|
||||
appVer *appmeta.AppVersion
|
||||
}
|
||||
|
||||
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
|
||||
func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(pf.keyDef.Scopes) == 0 || pf.identity == "" {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
var storedScopes string
|
||||
switch {
|
||||
case pf.identity.IsBot():
|
||||
if pf.appVer == nil {
|
||||
return nil
|
||||
}
|
||||
storedScopes = strings.Join(pf.appVer.TenantScopes, " ")
|
||||
case pf.identity == core.AsUser:
|
||||
result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID))
|
||||
if err != nil || result == nil || result.Scopes == "" {
|
||||
return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error
|
||||
}
|
||||
storedScopes = result.Scopes
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
missing := auth.MissingScopes(storedScopes, pf.keyDef.Scopes)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
||||
)
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
|
||||
if identity.IsBot() {
|
||||
return fmt.Sprintf(
|
||||
"grant these scopes and publish a new app version at: %s",
|
||||
consoleScopeGrantURL(brand, appID, missing),
|
||||
)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
|
||||
strings.Join(missing, " "),
|
||||
)
|
||||
}
|
||||
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
|
||||
func preflightEventTypes(pf *preflightCtx) error {
|
||||
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
return nil
|
||||
}
|
||||
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
|
||||
for _, t := range pf.appVer.EventTypes {
|
||||
subscribed[t] = true
|
||||
}
|
||||
var missing []string
|
||||
for _, t := range pf.keyDef.RequiredConsoleEvents {
|
||||
if !subscribed[t] {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
||||
)
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
func sanitizeOutputDir(dir string) (string, error) {
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
|
||||
// resolveTenantToken fetches the app's tenant access token.
|
||||
func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
||||
)
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
errOutputDirUnsafe = errors.New("unsafe --output-dir")
|
||||
)
|
||||
|
||||
func parseParams(raw []string) (map[string]string, error) {
|
||||
m := make(map[string]string)
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// watchStdinEOF drains r until EOF, writes a diagnostic, then cancels; only safe in non-TTY mode.
|
||||
func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
||||
go func() {
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
fmt.Fprintln(errOut, "[event] stdin closed — shutting down. "+
|
||||
"consume treats stdin EOF as exit signal (wired for AI subprocess callers). "+
|
||||
"To keep running: pass --max-events/--timeout for bounded run, "+
|
||||
"or keep stdin open (e.g. `< /dev/tty` interactive, `< <(tail -f /dev/null)` script), "+
|
||||
"or stop via SIGTERM instead of closing stdin.")
|
||||
cancel()
|
||||
}()
|
||||
}
|
||||
63
cmd/event/consume_stdin_test.go
Normal file
63
cmd/event/consume_stdin_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatchStdinEOF_CancelsOnEOF(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
watchStdinEOF(strings.NewReader(""), cancel, io.Discard)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchStdinEOF_StaysAliveWhileReaderBlocks(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
pr, _ := io.Pipe()
|
||||
defer pr.Close()
|
||||
|
||||
watchStdinEOF(pr, cancel, io.Discard)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("watchStdinEOF cancelled without EOF")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// On EOF the watcher must emit a diagnostic naming stdin close + workarounds (daemon-style callers depend on it).
|
||||
func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
watchStdinEOF(strings.NewReader(""), cancel, &buf)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
got := buf.String()
|
||||
for _, want := range []string{"stdin closed", "--max-events", "--timeout", "SIGTERM"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("diagnostic missing %q; got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
143
cmd/event/consume_test.go
Normal file
143
cmd/event/consume_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseParams(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want map[string]string
|
||||
wantSentry error
|
||||
wantEcho string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
in: nil,
|
||||
want: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "single key=value",
|
||||
in: []string{"mailbox=user@example.com"},
|
||||
want: map[string]string{"mailbox": "user@example.com"},
|
||||
},
|
||||
{
|
||||
name: "multiple pairs",
|
||||
in: []string{"a=1", "b=2", "c=3"},
|
||||
want: map[string]string{"a": "1", "b": "2", "c": "3"},
|
||||
},
|
||||
{
|
||||
name: "value containing = is kept intact",
|
||||
in: []string{"filter=foo=bar"},
|
||||
want: map[string]string{"filter": "foo=bar"},
|
||||
},
|
||||
{
|
||||
name: "empty value allowed",
|
||||
in: []string{"key="},
|
||||
want: map[string]string{"key": ""},
|
||||
},
|
||||
{
|
||||
name: "duplicate key — last wins",
|
||||
in: []string{"k=1", "k=2"},
|
||||
want: map[string]string{"k": "2"},
|
||||
},
|
||||
{
|
||||
name: "missing = separator",
|
||||
in: []string{"mailbox"},
|
||||
wantSentry: errInvalidParamFormat,
|
||||
wantEcho: `"mailbox"`,
|
||||
},
|
||||
{
|
||||
name: "leading = (empty key)",
|
||||
in: []string{"=value"},
|
||||
wantSentry: errInvalidParamFormat,
|
||||
wantEcho: `"=value"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseParams(tc.in)
|
||||
if tc.wantSentry != nil {
|
||||
if err == nil {
|
||||
t.Fatalf("want error wrapping %v, got nil", tc.wantSentry)
|
||||
}
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
||||
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len = %d, want %d; got=%v", len(got), len(tc.want), got)
|
||||
}
|
||||
for k, v := range tc.want {
|
||||
if got[k] != v {
|
||||
t.Errorf("key %q: got %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutputDir(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantSentry error
|
||||
}{
|
||||
{
|
||||
name: "relative path accepted",
|
||||
in: "./output",
|
||||
},
|
||||
{
|
||||
name: "nested relative path accepted",
|
||||
in: "events/today",
|
||||
},
|
||||
{
|
||||
name: "tilde rejected explicitly",
|
||||
in: "~/events",
|
||||
wantSentry: errOutputDirTilde,
|
||||
},
|
||||
{
|
||||
name: "parent escape rejected",
|
||||
in: "../outside",
|
||||
wantSentry: errOutputDirUnsafe,
|
||||
},
|
||||
{
|
||||
name: "absolute path rejected",
|
||||
in: "/tmp/events",
|
||||
wantSentry: errOutputDirUnsafe,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := sanitizeOutputDir(tc.in)
|
||||
if tc.wantSentry != nil {
|
||||
if err == nil {
|
||||
t.Fatalf("want error wrapping %v, got nil (path=%q)", tc.wantSentry, got)
|
||||
}
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == "" {
|
||||
t.Errorf("expected non-empty safe path, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
29
cmd/event/event.go
Normal file
29
cmd/event/event.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "event",
|
||||
Short: "Consume and manage real-time events",
|
||||
Long: `Unified event consumption system. Use 'event consume <EventKey>' to start consuming events.`,
|
||||
// Without SilenceUsage, RunE errors print the full flag help banner.
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
cmd.AddCommand(NewCmdConsume(f))
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdSchema(f))
|
||||
cmd.AddCommand(NewCmdStatus(f))
|
||||
cmd.AddCommand(NewCmdStop(f))
|
||||
cmd.AddCommand(NewCmdBus(f))
|
||||
|
||||
return cmd
|
||||
}
|
||||
265
cmd/event/format_helpers_test.go
Normal file
265
cmd/event/format_helpers_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWriteStopJSON_ShapeAndEmpty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStopJSON(&buf, []stopResult{
|
||||
{AppID: "cli_XXXXXXXXXXXXXXXX", Status: stopStopped, PID: 42},
|
||||
{AppID: "cli_YYYYYYYYYYYYYYYY", Status: stopRefused, PID: 43, Reason: "2 active consumer(s)"},
|
||||
}); err != nil {
|
||||
t.Fatalf("writeStopJSON: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if len(got.Results) != 2 {
|
||||
t.Fatalf("results len = %d, want 2", len(got.Results))
|
||||
}
|
||||
if got.Results[0]["status"] != "stopped" {
|
||||
t.Errorf("results[0].status = %v, want stopped", got.Results[0]["status"])
|
||||
}
|
||||
if got.Results[1]["status"] != "refused" {
|
||||
t.Errorf("results[1].status = %v, want refused", got.Results[1]["status"])
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := writeStopJSON(&buf, nil); err != nil {
|
||||
t.Fatalf("writeStopJSON(nil): %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("nil output is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if got.Results == nil || len(got.Results) != 0 {
|
||||
t.Errorf("results = %v, want []", got.Results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStopText_RoutesToStdoutOrStderr(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
writeStopText(&out, &errOut, []stopResult{
|
||||
{AppID: "cli_XXXXXXXXXXXXXXXX", Status: stopStopped, PID: 1},
|
||||
{AppID: "cli_YYYYYYYYYYYYYYYY", Status: stopNoBus},
|
||||
{AppID: "cli_ZZZZZZZZZZZZZZZZ", Status: stopRefused, Reason: "busy"},
|
||||
{AppID: "cli_WWWWWWWWWWWWWWWW", Status: stopErrored, Reason: "kill failed"},
|
||||
})
|
||||
if !strings.Contains(out.String(), "Bus stopped for cli_XXXXXXXXXXXXXXXX") {
|
||||
t.Errorf("stopped line missing from stdout: %q", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "No bus running for cli_YYYYYYYYYYYYYYYY") {
|
||||
t.Errorf("no-bus line missing from stdout: %q", out.String())
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Refused stopping cli_ZZZZZZZZZZZZZZZZ: busy") {
|
||||
t.Errorf("refused line missing from stderr: %q", errOut.String())
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Error stopping cli_WWWWWWWWWWWWWWWW: kill failed") {
|
||||
t.Errorf("error line missing from stderr: %q", errOut.String())
|
||||
}
|
||||
if strings.Contains(out.String(), "Refused") || strings.Contains(out.String(), "Error") {
|
||||
t.Errorf("failure lines leaked to stdout: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusState_String(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
s busState
|
||||
want string
|
||||
}{
|
||||
{stateNotRunning, "not_running"},
|
||||
{stateRunning, "running"},
|
||||
{stateOrphan, "orphan"},
|
||||
} {
|
||||
if got := tc.s.String(); got != tc.want {
|
||||
t.Errorf("busState(%d).String() = %q, want %q", tc.s, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanizeDuration_AllBuckets(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{30 * time.Second, "30s ago"},
|
||||
{90 * time.Second, "1m ago"},
|
||||
{2 * time.Hour, "2h ago"},
|
||||
{50 * time.Hour, "2d ago"},
|
||||
} {
|
||||
if got := humanizeDuration(tc.d); got != tc.want {
|
||||
t.Errorf("humanizeDuration(%v) = %q, want %q", tc.d, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_CoversAllStates(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{AppID: "cli_NOTRUNNINGXXXXXX", State: stateNotRunning},
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 3661,
|
||||
Active: 2,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 10, EventKey: "im.message.receive_v1", Received: 5, Dropped: 0},
|
||||
{PID: 11, EventKey: "im.message.receive_v1", Received: 3, Dropped: 1},
|
||||
},
|
||||
},
|
||||
{AppID: "cli_ORPHANXXXXXXXXXX", State: stateOrphan, PID: 5678, UptimeSec: 3600},
|
||||
})
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"── cli_NOTRUNNINGXXXXXX ──",
|
||||
"Bus: not running",
|
||||
"── cli_RUNNINGXXXXXXXXX ──",
|
||||
"running (PID 1234",
|
||||
"Active consumers: 2",
|
||||
"im.message.receive_v1",
|
||||
"── cli_ORPHANXXXXXXXXXX ──",
|
||||
"orphan (PID 5678",
|
||||
"Action: kill 5678",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("writeStatusText missing %q; full:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStatusJSON(&buf, []appStatus{
|
||||
{AppID: "cli_ORPHANXXXXXXXXXX", State: stateOrphan, PID: 99, UptimeSec: 60},
|
||||
{AppID: "cli_RUNNINGXXXXXXXXX", State: stateRunning, PID: 1, UptimeSec: 10, Active: 0},
|
||||
}); err != nil {
|
||||
t.Fatalf("writeStatusJSON: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Apps []map[string]interface{} `json:"apps"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("output is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if len(got.Apps) != 2 {
|
||||
t.Fatalf("apps len = %d", len(got.Apps))
|
||||
}
|
||||
orphan := got.Apps[0]
|
||||
if orphan["status"] != "orphan" {
|
||||
t.Errorf("orphan status = %v", orphan["status"])
|
||||
}
|
||||
if orphan["suggested_action"] != "kill 99" {
|
||||
t.Errorf("orphan suggested_action = %v, want 'kill 99'", orphan["suggested_action"])
|
||||
}
|
||||
if orphan["issue"] == nil {
|
||||
t.Error("orphan issue missing")
|
||||
}
|
||||
run := got.Apps[1]
|
||||
if run["issue"] != nil {
|
||||
t.Errorf("running entry leaked issue: %v", run["issue"])
|
||||
}
|
||||
if run["suggested_action"] != nil {
|
||||
t.Errorf("running entry leaked suggested_action: %v", run["suggested_action"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitForOrphan(t *testing.T) {
|
||||
orphan := []appStatus{{State: stateOrphan}}
|
||||
running := []appStatus{{State: stateRunning}}
|
||||
|
||||
if err := exitForOrphan(orphan, false); err != nil {
|
||||
t.Errorf("flag off + orphan → nil expected, got %v", err)
|
||||
}
|
||||
if err := exitForOrphan(running, false); err != nil {
|
||||
t.Errorf("flag off + running → nil expected, got %v", err)
|
||||
}
|
||||
|
||||
if err := exitForOrphan(running, true); err != nil {
|
||||
t.Errorf("flag on + no orphan → nil expected, got %v", err)
|
||||
}
|
||||
err := exitForOrphan(orphan, true)
|
||||
if err == nil {
|
||||
t.Fatal("flag on + orphan → expected error, got nil")
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %v, want ExitValidation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func errorAs(err error, target interface{}) bool {
|
||||
if e, ok := err.(*output.ExitError); ok {
|
||||
if t, ok := target.(**output.ExitError); ok {
|
||||
*t = e
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestNewCmdFactories_WireFlags(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
|
||||
|
||||
t.Run("consume", func(t *testing.T) {
|
||||
cmd := NewCmdConsume(f)
|
||||
for _, flag := range []string{"param", "jq", "quiet", "output-dir", "max-events", "timeout", "as"} {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("consume missing --%s flag", flag)
|
||||
}
|
||||
}
|
||||
if cmd.RunE == nil {
|
||||
t.Error("consume RunE is nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status", func(t *testing.T) {
|
||||
cmd := NewCmdStatus(f)
|
||||
for _, flag := range []string{"json", "current", "fail-on-orphan"} {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("status missing --%s flag", flag)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stop", func(t *testing.T) {
|
||||
cmd := NewCmdStop(f)
|
||||
for _, flag := range []string{"app-id", "all", "force", "json"} {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("stop missing --%s flag", flag)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
cmd := NewCmdList(f)
|
||||
if cmd.Flags().Lookup("json") == nil {
|
||||
t.Error("list missing --json flag")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bus", func(t *testing.T) {
|
||||
cmd := NewCmdBus(f)
|
||||
if !cmd.Hidden {
|
||||
t.Error("bus should be hidden (internal daemon entrypoint)")
|
||||
}
|
||||
if cmd.Flags().Lookup("domain") == nil {
|
||||
t.Error("bus missing --domain flag")
|
||||
}
|
||||
})
|
||||
}
|
||||
122
cmd/event/list.go
Normal file
122
cmd/event/list.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all available EventKeys",
|
||||
Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --json for machine-readable output.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runList(f, asJSON)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runList(f *cmdutil.Factory, asJSON bool) error {
|
||||
all := eventlib.ListAll()
|
||||
|
||||
if asJSON {
|
||||
return writeListJSON(f, all)
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
// stderr so `event list | jq` doesn't ingest it as a row.
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No EventKeys registered.")
|
||||
return nil
|
||||
}
|
||||
|
||||
type group struct {
|
||||
domain string
|
||||
keys []*eventlib.KeyDefinition
|
||||
}
|
||||
order := []string{}
|
||||
groups := map[string]*group{}
|
||||
|
||||
for _, def := range all {
|
||||
domain := def.Key
|
||||
if idx := strings.Index(def.Key, "."); idx > 0 {
|
||||
domain = def.Key[:idx]
|
||||
}
|
||||
g, ok := groups[domain]
|
||||
if !ok {
|
||||
g = &group{domain: domain}
|
||||
groups[domain] = g
|
||||
order = append(order, domain)
|
||||
}
|
||||
g.keys = append(g.keys, def)
|
||||
}
|
||||
|
||||
// Global widths (not per-section) keep "── domain ──" dividers aligned across groups.
|
||||
headers := []string{"KEY", "AUTH", "PARAMS", "DESCRIPTION"}
|
||||
rowsByDomain := make(map[string][][]string, len(order))
|
||||
var allRows [][]string
|
||||
for _, domain := range order {
|
||||
for _, def := range groups[domain].keys {
|
||||
auth := "-"
|
||||
if len(def.AuthTypes) > 0 {
|
||||
auth = strings.Join(def.AuthTypes, "|")
|
||||
}
|
||||
desc := def.Description
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
row := []string{
|
||||
def.Key,
|
||||
auth,
|
||||
fmt.Sprintf("%d", len(def.Params)),
|
||||
desc,
|
||||
}
|
||||
rowsByDomain[domain] = append(rowsByDomain[domain], row)
|
||||
allRows = append(allRows, row)
|
||||
}
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
const colGap = " "
|
||||
widths := tableWidths(headers, allRows)
|
||||
printTableRow(out, widths, headers, colGap)
|
||||
for _, domain := range order {
|
||||
fmt.Fprintf(out, "\n── %s ──\n", domain)
|
||||
for _, row := range rowsByDomain[domain] {
|
||||
printTableRow(out, widths, row, colGap)
|
||||
}
|
||||
}
|
||||
// stderr keeps stdout pipe-clean for `event list | jq`.
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "\nUse 'event schema <key>' for details.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeListJSON(f *cmdutil.Factory, all []*eventlib.KeyDefinition) error {
|
||||
type row struct {
|
||||
*eventlib.KeyDefinition
|
||||
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
|
||||
}
|
||||
rows := make([]row, len(all))
|
||||
for i, def := range all {
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows[i] = row{KeyDefinition: def, ResolvedSchema: resolved}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, rows)
|
||||
return nil
|
||||
}
|
||||
58
cmd/event/list_test.go
Normal file
58
cmd/event/list_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestRunList_TextOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runList(f, false); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunList_JSONOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runList(f, true); err != nil {
|
||||
t.Fatalf("runList json: %v", err)
|
||||
}
|
||||
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("expected at least one EventKey in JSON output")
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
for _, field := range []string{"key", "event_type", "schema"} {
|
||||
if row[field] == nil {
|
||||
t.Errorf("row missing %q: %+v", field, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
176
cmd/event/preflight_test.go
Normal file
176
cmd/event/preflight_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
key := ""
|
||||
if keyDef != nil {
|
||||
key = keyDef.Key
|
||||
}
|
||||
return &preflightCtx{
|
||||
appID: appID,
|
||||
brand: brand,
|
||||
eventKey: key,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_NilAppVer_SkipsCheck(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
EventType: "im.message.receive_v1",
|
||||
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
||||
}
|
||||
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, nil)); err != nil {
|
||||
t.Fatalf("nil appVer must be a weak-dependency skip, got err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_EmptyRequired_SkipsEvenIfEventTypeSet(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.message_read_v1",
|
||||
EventType: "im.message.message_read_v1",
|
||||
}
|
||||
appVer := &appmeta.AppVersion{EventTypes: []string{"im.message.receive_v1"}}
|
||||
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil {
|
||||
t.Fatalf("empty RequiredConsoleEvents must skip, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_AllSubscribed_Passes(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.reaction",
|
||||
EventType: "im.message.reaction.created_v1",
|
||||
RequiredConsoleEvents: []string{
|
||||
"im.message.reaction.created_v1",
|
||||
"im.message.reaction.deleted_v1",
|
||||
},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{EventTypes: []string{
|
||||
"im.message.reaction.created_v1",
|
||||
"im.message.reaction.deleted_v1",
|
||||
"im.message.receive_v1",
|
||||
}}
|
||||
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "mail.receive",
|
||||
EventType: "mail.user_mailbox.event.message_received_v1",
|
||||
RequiredConsoleEvents: []string{
|
||||
"mail.user_mailbox.event.message_received_v1",
|
||||
"mail.user_mailbox.event.message_read_v1",
|
||||
},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{EventTypes: []string{
|
||||
"mail.user_mailbox.event.message_received_v1",
|
||||
}}
|
||||
err := preflightEventTypes(newPreflightCtx("cli_XXXXXXXXXXXXXXXX", "feishu", "", def, appVer))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing subscription")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||
t.Errorf("error should name the missing event type, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitValidation {
|
||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint")
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("bot + nil appVer should skip, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{TenantScopes: []string{
|
||||
"im:message",
|
||||
"im:message.group_at_msg",
|
||||
"contact:user:readonly",
|
||||
}}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
|
||||
if err != nil {
|
||||
t.Fatalf("all scopes granted, unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "im.message.text",
|
||||
Scopes: []string{"im:message", "im:message.group_at_msg"},
|
||||
}
|
||||
appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}}
|
||||
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing scope")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||
t.Errorf("error should name missing scope, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
}
|
||||
hint := exit.Detail.Hint
|
||||
wantSubstrings := []string{
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
"token_type=tenant",
|
||||
}
|
||||
for _, want := range wantSubstrings {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("hint missing %q\ngot: %s", want, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{Key: "x"}
|
||||
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil {
|
||||
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
|
||||
}
|
||||
}
|
||||
49
cmd/event/runtime.go
Normal file
49
cmd/event/runtime.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// consumeRuntime routes event.APIClient calls through the shared client.APIClient with a pinned identity.
|
||||
type consumeRuntime struct {
|
||||
client *client.APIClient
|
||||
accessIdentity core.Identity
|
||||
}
|
||||
|
||||
func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
resp, err := r.client.DoAPI(ctx, client.RawApiRequest{
|
||||
Method: method,
|
||||
URL: path,
|
||||
Data: body,
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if resp.StatusCode >= 400 && !client.IsJSONContentType(ct) && ct != "" {
|
||||
const maxBodyEcho = 256
|
||||
body := string(resp.RawBody)
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
}
|
||||
return json.RawMessage(resp.RawBody), nil
|
||||
}
|
||||
224
cmd/event/schema.go
Normal file
224
cmd/event/schema.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// resolveSchemaJSON returns the final JSON Schema for an EventKey (reflected base, V2-wrapped for Native, overlay applied); orphans lists unresolved FieldOverrides pointers.
|
||||
func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string, error) {
|
||||
spec, isNative := pickSpec(def.Schema)
|
||||
if spec == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
base, err := renderSpec(spec)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if base == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
if isNative {
|
||||
base = schemas.WrapV2Envelope(base)
|
||||
}
|
||||
|
||||
if len(def.Schema.FieldOverrides) > 0 {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
|
||||
return base, nil, nil
|
||||
}
|
||||
|
||||
// pickSpec returns the non-nil spec and whether it is Native (requires V2 envelope wrap).
|
||||
func pickSpec(s eventlib.SchemaDef) (*eventlib.SchemaSpec, bool) {
|
||||
if s.Native != nil {
|
||||
return s.Native, true
|
||||
}
|
||||
if s.Custom != nil {
|
||||
return s.Custom, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// renderSpec produces a JSON Schema from Type (reflected) or Raw (copied).
|
||||
func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
if s.Type != nil {
|
||||
return schemas.FromType(s.Type), nil
|
||||
}
|
||||
if len(s.Raw) > 0 {
|
||||
buf := make(json.RawMessage, len(s.Raw))
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema <EventKey>",
|
||||
Short: "Show details for an EventKey",
|
||||
Long: "Display detailed information about an EventKey including type, events, parameters, and response schema. Use --json for machine-readable output.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runSchema(f, args[0], asJSON)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
def, ok := eventlib.Lookup(key)
|
||||
if !ok {
|
||||
return unknownEventKeyErr(key)
|
||||
}
|
||||
|
||||
if asJSON {
|
||||
return writeSchemaJSON(f, def)
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
|
||||
fmt.Fprintf(out, "Key: %s\n", def.Key)
|
||||
if def.Description != "" {
|
||||
fmt.Fprintf(out, "Description: %s\n", def.Description)
|
||||
}
|
||||
fmt.Fprintf(out, "Event: %s\n", def.EventType)
|
||||
|
||||
if def.PreConsume != nil {
|
||||
fmt.Fprintf(out, "Pre-consume: yes\n")
|
||||
}
|
||||
|
||||
if len(def.Scopes) > 0 {
|
||||
fmt.Fprintf(out, "\nRequired Scopes:\n")
|
||||
for _, s := range def.Scopes {
|
||||
fmt.Fprintf(out, " - %s\n", s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(def.RequiredConsoleEvents) > 0 {
|
||||
fmt.Fprintf(out, "\nRequired Console Events (must be enabled in developer console):\n")
|
||||
for _, e := range def.RequiredConsoleEvents {
|
||||
fmt.Fprintf(out, " - %s\n", e)
|
||||
}
|
||||
}
|
||||
|
||||
if len(def.Params) > 0 {
|
||||
fmt.Fprintf(out, "\nParameters:\n")
|
||||
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
|
||||
for _, p := range def.Params {
|
||||
required := "no"
|
||||
if p.Required {
|
||||
required = "yes"
|
||||
}
|
||||
defaultVal := p.Default
|
||||
if defaultVal == "" {
|
||||
defaultVal = "-"
|
||||
}
|
||||
desc := p.Description
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
// Inline Values below the table so AI consumers see allowed enum/multi values without --json.
|
||||
for _, p := range def.Params {
|
||||
if len(p.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, "\n %s values:\n", p.Name)
|
||||
vw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
for _, v := range p.Values {
|
||||
fmt.Fprintf(vw, " %s\t%s\n", v.Value, v.Desc)
|
||||
}
|
||||
vw.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
printIndentedJSON(out, resolved)
|
||||
} else {
|
||||
fmt.Fprintf(out, "\nOutput Schema: (schema not declared)\n")
|
||||
if def.Schema.Native != nil {
|
||||
fmt.Fprintf(out, " Consumers receive the V2 envelope: {schema, header, event}.\n")
|
||||
fmt.Fprintf(out, " Inspect real payloads via `lark-cli event consume %s`.\n", def.Key)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printIndentedJSON pretty-prints raw JSON with a 2-space leading indent.
|
||||
func printIndentedJSON(out io.Writer, raw json.RawMessage) {
|
||||
var parsed json.RawMessage
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
fmt.Fprintln(out, " <invalid JSON>")
|
||||
return
|
||||
}
|
||||
formatted, err := json.MarshalIndent(parsed, " ", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, " %s\n", string(formatted))
|
||||
}
|
||||
|
||||
// writeSchemaJSON emits the EventKey definition plus resolved schema; jq_root_path tells callers whether fields live at `.` or `.event`.
|
||||
func writeSchemaJSON(f *cmdutil.Factory, def *eventlib.KeyDefinition) error {
|
||||
type payload struct {
|
||||
*eventlib.KeyDefinition
|
||||
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
|
||||
JQRootPath string `json:"jq_root_path,omitempty"`
|
||||
}
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var jqRootPath string
|
||||
if resolved != nil {
|
||||
// Native → V2 envelope ⇒ `.event.xxx`; Custom → flat ⇒ `.`.
|
||||
_, isNative := pickSpec(def.Schema)
|
||||
jqRootPath = "."
|
||||
if isNative {
|
||||
jqRootPath = ".event"
|
||||
}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, payload{
|
||||
KeyDefinition: def,
|
||||
ResolvedSchema: resolved,
|
||||
JQRootPath: jqRootPath,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
131
cmd/event/schema_test.go
Normal file
131
cmd/event/schema_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Key:", "im.message.receive_v1",
|
||||
"Event:", "im.message.receive_v1",
|
||||
"Output Schema:",
|
||||
`"message_id"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("schema output missing %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.message_read_v1", false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Output Schema:",
|
||||
`"schema"`,
|
||||
`"header"`,
|
||||
`"event"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("native schema output missing %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
err := runSchema(f, "im.message.recieve_v1", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown key")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "unknown EventKey") {
|
||||
t.Errorf("error should mention unknown EventKey: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "im.message.receive_v1") {
|
||||
t.Errorf("error should suggest the real key name (typo correction): %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
for _, field := range []string{"key", "event_type", "schema", "resolved_output_schema"} {
|
||||
if _, ok := payload[field]; !ok {
|
||||
t.Errorf("JSON output missing field %q: %+v", field, payload)
|
||||
}
|
||||
}
|
||||
if payload["key"] != "im.message.receive_v1" {
|
||||
t.Errorf("key = %v, want im.message.receive_v1", payload["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
const syntheticKey = "t.custom.overlay"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
type out struct {
|
||||
SenderID string `json:"sender_id"`
|
||||
}
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Schema: eventlib.SchemaDef{
|
||||
Custom: &eventlib.SchemaSpec{Type: reflect.TypeOf(out{})},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{
|
||||
"/sender_id": {Kind: "open_id"},
|
||||
},
|
||||
},
|
||||
Process: func(context.Context, eventlib.APIClient, *eventlib.RawEvent, map[string]string) (json.RawMessage, error) {
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
def, _ := eventlib.Lookup(syntheticKey)
|
||||
resolved, orphans, err := resolveSchemaJSON(def)
|
||||
if err != nil || len(orphans) != 0 {
|
||||
t.Fatalf("resolve: err=%v orphans=%v", err, orphans)
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(resolved, &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := parsed["properties"].(map[string]interface{})["sender_id"].(map[string]interface{})["format"]
|
||||
if got != "open_id" {
|
||||
t.Errorf("overlay format = %v, want open_id", got)
|
||||
}
|
||||
}
|
||||
17
cmd/event/sigpipe_unix.go
Normal file
17
cmd/event/sigpipe_unix.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build unix
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// ignoreBrokenPipe stops Go's default SIGPIPE-on-stdout terminate behavior.
|
||||
// Subsequent stdout writes return syscall.EPIPE so consume can shut down cleanly.
|
||||
func ignoreBrokenPipe() {
|
||||
signal.Ignore(syscall.SIGPIPE)
|
||||
}
|
||||
9
cmd/event/sigpipe_windows.go
Normal file
9
cmd/event/sigpipe_windows.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package event
|
||||
|
||||
// ignoreBrokenPipe is a no-op on Windows (no SIGPIPE; closed-pipe writes return ERROR_BROKEN_PIPE directly).
|
||||
func ignoreBrokenPipe() {}
|
||||
329
cmd/event/status.go
Normal file
329
cmd/event/status.go
Normal file
@@ -0,0 +1,329 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/event/busctl"
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
asJSON bool
|
||||
current bool
|
||||
failOnOrphan bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show event bus daemon status for all discovered apps",
|
||||
Long: "Connect to each bus daemon under the config-dir/events/ tree and show PID, uptime, and active consumers. Use --current for only the current profile's app. Use --json for machine-readable output. Use --fail-on-orphan to exit 2 when any orphan bus is detected (for health checks).",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runStatus(f, current, asJSON, failOnOrphan)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit status as JSON (for AI / scripts)")
|
||||
cmd.Flags().BoolVar(¤t, "current", false, "Only show status for the current profile's app")
|
||||
cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "Exit 2 when any orphan bus is detected (default: always exit 0)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type busState int
|
||||
|
||||
const (
|
||||
stateNotRunning busState = iota
|
||||
stateRunning
|
||||
stateOrphan
|
||||
)
|
||||
|
||||
func (s busState) String() string {
|
||||
switch s {
|
||||
case stateRunning:
|
||||
return "running"
|
||||
case stateOrphan:
|
||||
return "orphan"
|
||||
default:
|
||||
return "not_running"
|
||||
}
|
||||
}
|
||||
|
||||
// appStatus bundles one AppID's derived status; State picks which fields are meaningful.
|
||||
type appStatus struct {
|
||||
AppID string
|
||||
State busState
|
||||
PID int
|
||||
UptimeSec int
|
||||
Active int
|
||||
Consumers []protocol.ConsumerInfo
|
||||
}
|
||||
|
||||
type busQuerier interface {
|
||||
QueryBusStatus(appID string) (*protocol.StatusResponse, error)
|
||||
}
|
||||
|
||||
// singleAppScanner wraps a Scanner and filters to one AppID for --current queries.
|
||||
type singleAppScanner struct {
|
||||
appID string
|
||||
inner busdiscover.Scanner
|
||||
}
|
||||
|
||||
func (s singleAppScanner) ScanBusProcesses() ([]busdiscover.Process, error) {
|
||||
if s.inner == nil {
|
||||
return nil, nil
|
||||
}
|
||||
all, err := s.inner.ScanBusProcesses()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := all[:0]
|
||||
for _, p := range all {
|
||||
if p.AppID == s.appID {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type transportQuerier struct {
|
||||
tr transport.IPC
|
||||
}
|
||||
|
||||
func (q *transportQuerier) QueryBusStatus(appID string) (*protocol.StatusResponse, error) {
|
||||
return busctl.QueryStatus(q.tr, appID)
|
||||
}
|
||||
|
||||
func runStatus(f *cmdutil.Factory, current, asJSON, failOnOrphan bool) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seeds := map[string]struct{}{}
|
||||
if current {
|
||||
seeds[cfg.AppID] = struct{}{}
|
||||
} else {
|
||||
for _, id := range discoverAppIDs() {
|
||||
seeds[id] = struct{}{}
|
||||
}
|
||||
// Always include the current profile so a first-time user sees it as not_running.
|
||||
seeds[cfg.AppID] = struct{}{}
|
||||
}
|
||||
seedList := make([]string, 0, len(seeds))
|
||||
for id := range seeds {
|
||||
seedList = append(seedList, id)
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
// --current: scope the scanner to this AppID so unrelated orphans don't surface.
|
||||
var scanner busdiscover.Scanner
|
||||
if current {
|
||||
scanner = singleAppScanner{appID: cfg.AppID, inner: busdiscover.Default()}
|
||||
} else {
|
||||
scanner = busdiscover.Default()
|
||||
}
|
||||
statuses := deriveStatuses(
|
||||
seedList,
|
||||
scanner,
|
||||
&transportQuerier{tr: tr},
|
||||
time.Now(),
|
||||
)
|
||||
|
||||
if asJSON {
|
||||
if err := writeStatusJSON(f.IOStreams.Out, statuses); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
writeStatusText(f.IOStreams.Out, statuses)
|
||||
}
|
||||
return exitForOrphan(statuses, failOnOrphan)
|
||||
}
|
||||
|
||||
// deriveStatuses classifies each AppID as running/orphan/not_running from socket + process-scan inputs; scanner errors are non-fatal.
|
||||
func deriveStatuses(seedAppIDs []string, sc busdiscover.Scanner, q busQuerier, now time.Time) []appStatus {
|
||||
procByAppID := map[string]busdiscover.Process{}
|
||||
if sc != nil {
|
||||
if procs, err := sc.ScanBusProcesses(); err == nil {
|
||||
for _, p := range procs {
|
||||
procByAppID[p.AppID] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ids := map[string]struct{}{}
|
||||
for _, id := range seedAppIDs {
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
for id := range procByAppID {
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
sorted := make([]string, 0, len(ids))
|
||||
for id := range ids {
|
||||
sorted = append(sorted, id)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
|
||||
// Query in parallel so one wedged peer can't compound the per-op deadline across many apps.
|
||||
type probe struct {
|
||||
resp *protocol.StatusResponse
|
||||
err error
|
||||
}
|
||||
probes := make([]probe, len(sorted))
|
||||
var wg sync.WaitGroup
|
||||
for i, appID := range sorted {
|
||||
wg.Add(1)
|
||||
go func(i int, appID string) {
|
||||
defer wg.Done()
|
||||
probes[i].resp, probes[i].err = q.QueryBusStatus(appID)
|
||||
}(i, appID)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
result := make([]appStatus, 0, len(sorted))
|
||||
for i, appID := range sorted {
|
||||
s := appStatus{AppID: appID, State: stateNotRunning}
|
||||
if probes[i].err == nil {
|
||||
resp := probes[i].resp
|
||||
s.State = stateRunning
|
||||
s.PID = resp.PID
|
||||
s.UptimeSec = resp.UptimeSec
|
||||
s.Active = resp.ActiveConns
|
||||
s.Consumers = resp.Consumers
|
||||
} else if p, ok := procByAppID[appID]; ok {
|
||||
s.State = stateOrphan
|
||||
s.PID = p.PID
|
||||
s.UptimeSec = int(now.Sub(p.StartTime).Seconds())
|
||||
}
|
||||
result = append(result, s)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// humanizeDuration formats d as a coarse "N unit ago" string.
|
||||
func humanizeDuration(d time.Duration) string {
|
||||
s := int(d.Seconds())
|
||||
if s < 60 {
|
||||
return fmt.Sprintf("%ds ago", s)
|
||||
}
|
||||
m := s / 60
|
||||
if m < 60 {
|
||||
return fmt.Sprintf("%dm ago", m)
|
||||
}
|
||||
h := m / 60
|
||||
if h < 24 {
|
||||
return fmt.Sprintf("%dh ago", h)
|
||||
}
|
||||
return fmt.Sprintf("%dd ago", h/24)
|
||||
}
|
||||
|
||||
func writeStatusText(out io.Writer, statuses []appStatus) {
|
||||
for i, s := range statuses {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
fmt.Fprintf(out, "── %s ──\n", s.AppID)
|
||||
switch s.State {
|
||||
case stateNotRunning:
|
||||
fmt.Fprintln(out, " Bus: not running")
|
||||
case stateRunning:
|
||||
fmt.Fprintf(out, " Bus: running (PID %d, uptime %s)\n",
|
||||
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
||||
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
||||
if len(s.Consumers) > 0 {
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
|
||||
rows := make([][]string, 0, len(s.Consumers))
|
||||
for _, c := range s.Consumers {
|
||||
rows = append(rows, []string{
|
||||
fmt.Sprintf("pid=%d", c.PID),
|
||||
c.EventKey,
|
||||
fmt.Sprintf("%d", c.Received),
|
||||
fmt.Sprintf("%d", c.Dropped),
|
||||
})
|
||||
}
|
||||
widths := tableWidths(headers, rows)
|
||||
const colGap = " "
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprint(out, " ")
|
||||
printTableRow(out, widths, headers, colGap)
|
||||
for _, row := range rows {
|
||||
fmt.Fprint(out, " ")
|
||||
printTableRow(out, widths, row, colGap)
|
||||
}
|
||||
}
|
||||
case stateOrphan:
|
||||
if s.PID == 0 {
|
||||
fmt.Fprintln(out, " Bus: orphan (PID unknown — bus.pid file unreadable)")
|
||||
fmt.Fprintln(out, " Issue: live bus detected but pid file is missing or corrupt")
|
||||
fmt.Fprintln(out, " Action: inspect ~/.lark-cli/events/<app>/bus.pid and kill manually")
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(out, " Bus: orphan (PID %d, started %s)\n",
|
||||
s.PID, humanizeDuration(time.Duration(s.UptimeSec)*time.Second))
|
||||
fmt.Fprintln(out, " Issue: socket file missing — consumers cannot connect")
|
||||
fmt.Fprintf(out, " Action: kill %d\n", s.PID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeStatusJSON(w io.Writer, statuses []appStatus) error {
|
||||
type jsonStatus struct {
|
||||
AppID string `json:"app_id"`
|
||||
Status string `json:"status"`
|
||||
Running bool `json:"running"` // backward compat
|
||||
PID int `json:"pid,omitempty"`
|
||||
UptimeSec int `json:"uptime_sec,omitempty"`
|
||||
Active int `json:"active_consumers,omitempty"`
|
||||
Consumers []protocol.ConsumerInfo `json:"consumers,omitempty"`
|
||||
Issue string `json:"issue,omitempty"`
|
||||
SuggestedAction string `json:"suggested_action,omitempty"`
|
||||
}
|
||||
payload := make([]jsonStatus, 0, len(statuses))
|
||||
for _, s := range statuses {
|
||||
js := jsonStatus{
|
||||
AppID: s.AppID,
|
||||
Status: s.State.String(),
|
||||
Running: s.State == stateRunning,
|
||||
PID: s.PID,
|
||||
UptimeSec: s.UptimeSec,
|
||||
Active: s.Active,
|
||||
Consumers: s.Consumers,
|
||||
}
|
||||
if s.State == stateOrphan {
|
||||
if s.PID == 0 {
|
||||
js.Issue = "live bus detected but pid file is missing or corrupt"
|
||||
js.SuggestedAction = "inspect events dir and kill manually"
|
||||
} else {
|
||||
js.Issue = "socket file missing"
|
||||
js.SuggestedAction = fmt.Sprintf("kill %d", s.PID)
|
||||
}
|
||||
}
|
||||
payload = append(payload, js)
|
||||
}
|
||||
output.PrintJson(w, map[string]interface{}{"apps": payload})
|
||||
return nil
|
||||
}
|
||||
|
||||
// exitForOrphan returns ExitValidation iff failOnOrphan and any status is orphan; default exit 0 preserves observe-only semantics.
|
||||
func exitForOrphan(statuses []appStatus, failOnOrphan bool) error {
|
||||
if !failOnOrphan {
|
||||
return nil
|
||||
}
|
||||
for _, s := range statuses {
|
||||
if s.State == stateOrphan {
|
||||
return output.ErrBare(output.ExitValidation)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
cmd/event/status_fail_on_orphan_test.go
Normal file
48
cmd/event/status_fail_on_orphan_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestExitForOrphan_Orphan(t *testing.T) {
|
||||
statuses := []appStatus{
|
||||
{AppID: "cli_a", State: stateRunning},
|
||||
{AppID: "cli_b", State: stateOrphan, PID: 70926},
|
||||
}
|
||||
err := exitForOrphan(statuses, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when failOnOrphan=true and orphan present")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitForOrphan_NoOrphan(t *testing.T) {
|
||||
statuses := []appStatus{
|
||||
{AppID: "cli_a", State: stateRunning},
|
||||
{AppID: "cli_b", State: stateNotRunning},
|
||||
}
|
||||
if err := exitForOrphan(statuses, true); err != nil {
|
||||
t.Errorf("expected nil error when no orphan; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitForOrphan_FlagDisabled(t *testing.T) {
|
||||
statuses := []appStatus{
|
||||
{AppID: "cli_b", State: stateOrphan, PID: 70926},
|
||||
}
|
||||
if err := exitForOrphan(statuses, false); err != nil {
|
||||
t.Errorf("flag off should never return error; got %v", err)
|
||||
}
|
||||
}
|
||||
242
cmd/event/status_orphan_test.go
Normal file
242
cmd/event/status_orphan_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
type fakeScanner struct {
|
||||
procs []busdiscover.Process
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeScanner) ScanBusProcesses() ([]busdiscover.Process, error) {
|
||||
return f.procs, f.err
|
||||
}
|
||||
|
||||
type fakeBusQuerier struct {
|
||||
respByAppID map[string]*protocol.StatusResponse
|
||||
}
|
||||
|
||||
func (f *fakeBusQuerier) QueryBusStatus(appID string) (*protocol.StatusResponse, error) {
|
||||
if r, ok := f.respByAppID[appID]; ok {
|
||||
return r, nil
|
||||
}
|
||||
return nil, errors.New("dial failed")
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_RunningBus(t *testing.T) {
|
||||
q := &fakeBusQuerier{
|
||||
respByAppID: map[string]*protocol.StatusResponse{
|
||||
"cli_a": protocol.NewStatusResponse(12345, 150, 1, nil),
|
||||
},
|
||||
}
|
||||
sc := &fakeScanner{procs: nil}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
s := statuses[0]
|
||||
if s.State != stateRunning {
|
||||
t.Errorf("State = %v, want stateRunning", s.State)
|
||||
}
|
||||
if s.PID != 12345 {
|
||||
t.Errorf("PID = %d, want 12345", s.PID)
|
||||
}
|
||||
if s.UptimeSec != 150 {
|
||||
t.Errorf("UptimeSec = %d, want 150", s.UptimeSec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_OrphanBus(t *testing.T) {
|
||||
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
|
||||
sc := &fakeScanner{procs: []busdiscover.Process{
|
||||
{PID: 70926, AppID: "cli_a", StartTime: time.Now().Add(-19 * time.Hour)},
|
||||
}}
|
||||
|
||||
now := time.Now()
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, now)
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
s := statuses[0]
|
||||
if s.State != stateOrphan {
|
||||
t.Errorf("State = %v, want stateOrphan", s.State)
|
||||
}
|
||||
if s.PID != 70926 {
|
||||
t.Errorf("PID = %d, want 70926", s.PID)
|
||||
}
|
||||
wantUptime := int((19 * time.Hour).Seconds())
|
||||
if s.UptimeSec < wantUptime-60 || s.UptimeSec > wantUptime+60 {
|
||||
t.Errorf("UptimeSec = %d, want ~%d", s.UptimeSec, wantUptime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_NotRunning(t *testing.T) {
|
||||
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
|
||||
sc := &fakeScanner{procs: nil}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
s := statuses[0]
|
||||
if s.State != stateNotRunning {
|
||||
t.Errorf("State = %v, want stateNotRunning", s.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_DiscoversOrphanAppIDsFromProcessScan(t *testing.T) {
|
||||
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
|
||||
sc := &fakeScanner{procs: []busdiscover.Process{
|
||||
{PID: 70926, AppID: "cli_orphan", StartTime: time.Now().Add(-1 * time.Hour)},
|
||||
}}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_known"}, sc, q, time.Now())
|
||||
if len(statuses) != 2 {
|
||||
t.Fatalf("expected 2 statuses, got %d: %+v", len(statuses), statuses)
|
||||
}
|
||||
byID := map[string]appStatus{}
|
||||
for _, s := range statuses {
|
||||
byID[s.AppID] = s
|
||||
}
|
||||
if byID["cli_known"].State != stateNotRunning {
|
||||
t.Errorf("cli_known state = %v, want stateNotRunning", byID["cli_known"].State)
|
||||
}
|
||||
if byID["cli_orphan"].State != stateOrphan {
|
||||
t.Errorf("cli_orphan state = %v, want stateOrphan", byID["cli_orphan"].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveStatuses_ScannerErrorIsNotFatal(t *testing.T) {
|
||||
q := &fakeBusQuerier{
|
||||
respByAppID: map[string]*protocol.StatusResponse{
|
||||
"cli_a": protocol.NewStatusResponse(12345, 150, 1, nil),
|
||||
},
|
||||
}
|
||||
sc := &fakeScanner{err: errors.New("ps failed")}
|
||||
|
||||
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
|
||||
if len(statuses) != 1 {
|
||||
t.Fatalf("expected 1 status, got %d", len(statuses))
|
||||
}
|
||||
if statuses[0].State != stateRunning {
|
||||
t.Errorf("State = %v, want stateRunning (scanner error must not break running detection)", statuses[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_OrphanBlock(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
statuses := []appStatus{{
|
||||
AppID: "cli_XXXXXXXXXXXXXXXX",
|
||||
State: stateOrphan,
|
||||
PID: 70926,
|
||||
UptimeSec: 68400,
|
||||
}}
|
||||
writeStatusText(&buf, statuses)
|
||||
out := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"── cli_XXXXXXXXXXXXXXXX ──",
|
||||
"Bus: orphan (PID 70926, started 19h ago)",
|
||||
"Issue: socket file missing — consumers cannot connect",
|
||||
"Action: kill 70926",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\nfull output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "running (PID") {
|
||||
t.Errorf("orphan block must not contain 'running' text; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
statuses := []appStatus{{
|
||||
AppID: "cli_XXXXXXXXXXXXXXXX",
|
||||
State: stateOrphan,
|
||||
PID: 70926,
|
||||
UptimeSec: 68400,
|
||||
}}
|
||||
if err := writeStatusJSON(&buf, statuses); err != nil {
|
||||
t.Fatalf("writeStatusJSON: %v", err)
|
||||
}
|
||||
var payload struct {
|
||||
Apps []map[string]interface{} `json:"apps"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(payload.Apps) != 1 {
|
||||
t.Fatalf("apps len = %d, want 1", len(payload.Apps))
|
||||
}
|
||||
a := payload.Apps[0]
|
||||
if a["status"] != "orphan" {
|
||||
t.Errorf("status = %v, want \"orphan\"", a["status"])
|
||||
}
|
||||
if a["running"] != false {
|
||||
t.Errorf("running = %v, want false", a["running"])
|
||||
}
|
||||
if a["issue"] != "socket file missing" {
|
||||
t.Errorf("issue = %v, want \"socket file missing\"", a["issue"])
|
||||
}
|
||||
if a["suggested_action"] != "kill 70926" {
|
||||
t.Errorf("suggested_action = %v, want \"kill 70926\"", a["suggested_action"])
|
||||
}
|
||||
if pid, ok := a["pid"].(float64); !ok || int(pid) != 70926 {
|
||||
t.Errorf("pid = %v, want 70926", a["pid"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_RunningOmitsOrphanFields(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
statuses := []appStatus{{
|
||||
AppID: "cli_running",
|
||||
State: stateRunning,
|
||||
PID: 11111,
|
||||
UptimeSec: 60,
|
||||
Active: 0,
|
||||
}}
|
||||
if err := writeStatusJSON(&buf, statuses); err != nil {
|
||||
t.Fatalf("writeStatusJSON: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Contains(out, `"issue"`) {
|
||||
t.Errorf("running status must not include 'issue' field; got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `"suggested_action"`) {
|
||||
t.Errorf("running status must not include 'suggested_action' field; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanizeDuration(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{30 * time.Second, "30s ago"},
|
||||
{90 * time.Second, "1m ago"},
|
||||
{45 * time.Minute, "45m ago"},
|
||||
{90 * time.Minute, "1h ago"},
|
||||
{5 * time.Hour, "5h ago"},
|
||||
{30 * time.Hour, "1d ago"},
|
||||
{80 * time.Hour, "3d ago"},
|
||||
} {
|
||||
got := humanizeDuration(tt.d)
|
||||
if got != tt.want {
|
||||
t.Errorf("humanizeDuration(%v) = %q, want %q", tt.d, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
242
cmd/event/stop.go
Normal file
242
cmd/event/stop.go
Normal file
@@ -0,0 +1,242 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/event/busctl"
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// stopStatus is the outcome tag; JSON wire format is the string form — keep values stable.
|
||||
type stopStatus string
|
||||
|
||||
const (
|
||||
stopStopped stopStatus = "stopped"
|
||||
stopNoBus stopStatus = "no_bus"
|
||||
stopRefused stopStatus = "refused"
|
||||
stopErrored stopStatus = "error"
|
||||
)
|
||||
|
||||
type stopResult struct {
|
||||
AppID string `json:"app_id"`
|
||||
Status stopStatus `json:"status"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type stopCmdOpts struct {
|
||||
appID string
|
||||
all bool
|
||||
force bool
|
||||
asJSON bool
|
||||
}
|
||||
|
||||
func NewCmdStop(f *cmdutil.Factory) *cobra.Command {
|
||||
var o stopCmdOpts
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop the event bus daemon",
|
||||
Long: `Stop the event bus daemon. Target is one of:
|
||||
• the current profile's AppID (default)
|
||||
• an explicit AppID via --app-id
|
||||
• every running bus on this machine via --all
|
||||
|
||||
Exit code: 2 if any target was refused or errored, 0 otherwise.
|
||||
|
||||
--force widens two gates:
|
||||
1. Allows stopping a bus that still has active consumers.
|
||||
2. On shutdown-timeout (bus didn't exit within 5s), SIGKILLs the
|
||||
process and cleans up the stale socket instead of returning an
|
||||
error.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runStop(f, o)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&o.appID, "app-id", "", "App ID of the bus to stop (default: current profile)")
|
||||
cmd.Flags().BoolVar(&o.all, "all", false, "Stop all running bus daemons")
|
||||
cmd.Flags().BoolVar(&o.force, "force", false, "Stop even with active consumers; on shutdown-timeout also SIGKILL the bus")
|
||||
cmd.Flags().BoolVar(&o.asJSON, "json", false, "Emit results as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runStop(f *cmdutil.Factory, o stopCmdOpts) error {
|
||||
tr := transport.New()
|
||||
|
||||
var targets []string
|
||||
if o.all {
|
||||
targets = discoverAppIDs()
|
||||
} else {
|
||||
targetAppID := o.appID
|
||||
if targetAppID == "" {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetAppID = cfg.AppID
|
||||
}
|
||||
targets = []string{targetAppID}
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
if o.asJSON {
|
||||
return writeStopJSON(f.IOStreams.Out, nil)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.Out, "No event bus instances found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
results := make([]stopResult, 0, len(targets))
|
||||
for _, id := range targets {
|
||||
results = append(results, stopBusOne(tr, id, o.force))
|
||||
}
|
||||
|
||||
if o.asJSON {
|
||||
return writeStopJSON(f.IOStreams.Out, results)
|
||||
}
|
||||
writeStopText(f.IOStreams.Out, f.IOStreams.ErrOut, results)
|
||||
|
||||
// Non-zero exit for refused/errored so non-JSON callers still get a signal.
|
||||
for _, r := range results {
|
||||
if r.Status == stopRefused || r.Status == stopErrored {
|
||||
return output.ErrBare(output.ExitValidation)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopBusOne attempts to stop appID's bus; polls tr.Dial post-Shutdown until listener is gone or budget elapses.
|
||||
func stopBusOne(tr transport.IPC, appID string, force bool) stopResult {
|
||||
resp, err := busctl.QueryStatus(tr, appID)
|
||||
if err != nil {
|
||||
return stopResult{AppID: appID, Status: stopNoBus}
|
||||
}
|
||||
|
||||
if resp.ActiveConns > 0 && !force {
|
||||
pids := make([]int, len(resp.Consumers))
|
||||
for i, c := range resp.Consumers {
|
||||
pids[i] = c.PID
|
||||
}
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopRefused,
|
||||
PID: resp.PID,
|
||||
Reason: fmt.Sprintf("%d active consumer(s) (pids: %v); use --force to override", resp.ActiveConns, pids),
|
||||
}
|
||||
}
|
||||
|
||||
if err := busctl.SendShutdown(tr, appID); err != nil {
|
||||
return stopResult{AppID: appID, Status: stopErrored, PID: resp.PID, Reason: err.Error()}
|
||||
}
|
||||
|
||||
const pollInterval = 100 * time.Millisecond
|
||||
deadline := time.Now().Add(shutdownBudget)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(pollInterval)
|
||||
probe, dialErr := tr.Dial(tr.Address(appID))
|
||||
if dialErr != nil {
|
||||
return stopResult{AppID: appID, Status: stopStopped, PID: resp.PID}
|
||||
}
|
||||
probe.Close()
|
||||
}
|
||||
|
||||
if !force {
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopErrored,
|
||||
PID: resp.PID,
|
||||
Reason: fmt.Sprintf("Bus did not exit within %v (pid=%d still listening); use --force to kill", shutdownBudget, resp.PID),
|
||||
}
|
||||
}
|
||||
|
||||
// --force: SIGKILL and clean up the stale socket.
|
||||
if err := killProcess(resp.PID); err != nil {
|
||||
if errors.Is(err, os.ErrProcessDone) {
|
||||
// Bus exited between timeout and kill — treat as success.
|
||||
tr.Cleanup(tr.Address(appID))
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopStopped,
|
||||
PID: resp.PID,
|
||||
Reason: "bus exited during kill attempt",
|
||||
}
|
||||
}
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopErrored,
|
||||
PID: resp.PID,
|
||||
Reason: fmt.Sprintf("failed to kill bus process: %v", err),
|
||||
}
|
||||
}
|
||||
tr.Cleanup(tr.Address(appID))
|
||||
return stopResult{
|
||||
AppID: appID,
|
||||
Status: stopStopped,
|
||||
PID: resp.PID,
|
||||
Reason: "killed (ungraceful) after shutdown timeout",
|
||||
}
|
||||
}
|
||||
|
||||
// killProcess is a var so tests can swap it without spawning sub-processes.
|
||||
var killProcess = func(pid int) error {
|
||||
p, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Kill()
|
||||
}
|
||||
|
||||
// shutdownBudget (var so tests can shrink it) bounds the post-Shutdown exit wait.
|
||||
var shutdownBudget = 5 * time.Second
|
||||
|
||||
func writeStopJSON(w io.Writer, results []stopResult) error {
|
||||
if results == nil {
|
||||
results = []stopResult{}
|
||||
}
|
||||
output.PrintJson(w, map[string]interface{}{"results": results})
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeStopText(out, errOut io.Writer, results []stopResult) {
|
||||
for _, r := range results {
|
||||
switch r.Status {
|
||||
case stopStopped:
|
||||
fmt.Fprintf(out, "Bus stopped for %s (pid=%d)\n", r.AppID, r.PID)
|
||||
case stopNoBus:
|
||||
fmt.Fprintf(out, "No bus running for %s\n", r.AppID)
|
||||
case stopRefused:
|
||||
fmt.Fprintf(errOut, "Refused stopping %s: %s\n", r.AppID, r.Reason)
|
||||
case stopErrored:
|
||||
fmt.Fprintf(errOut, "Error stopping %s: %s\n", r.AppID, r.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// discoverAppIDs returns appIDs whose bus.alive.lock is held by a live process.
|
||||
// Cross-platform via lockfile (flock on Unix, LockFileEx on Windows); ignores stale socket files.
|
||||
func discoverAppIDs() []string {
|
||||
procs, err := busdiscover.Default().ScanBusProcesses()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(procs))
|
||||
for _, p := range procs {
|
||||
ids = append(ids, p.AppID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
73
cmd/event/stop_discover_test.go
Normal file
73
cmd/event/stop_discover_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/busdiscover"
|
||||
)
|
||||
|
||||
func TestDiscoverAppIDs_OnlyLiveLockHolders(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
|
||||
eventsDir := filepath.Join(tmp, "events")
|
||||
|
||||
// Two live buses (lock held until t.Cleanup releases it).
|
||||
for _, app := range []string{"cli_XXXXXXXXXXXXXXXX", "cli_YYYYYYYYYYYYYYYY"} {
|
||||
appDir := filepath.Join(eventsDir, app)
|
||||
h, err := busdiscover.WritePIDFile(appDir, 1234)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePIDFile %s: %v", app, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = h.Release() })
|
||||
}
|
||||
|
||||
// Dead bus: lock acquired then released → looks like a stale dir on disk.
|
||||
deadDir := filepath.Join(eventsDir, "cli_ZZZZZZZZZZZZZZZZ")
|
||||
hDead, err := busdiscover.WritePIDFile(deadDir, 9999)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePIDFile dead: %v", err)
|
||||
}
|
||||
if err := hDead.Release(); err != nil {
|
||||
t.Fatalf("Release dead: %v", err)
|
||||
}
|
||||
|
||||
// Stale bus.sock without alive.lock — old behavior would surface it; new must not.
|
||||
staleSockDir := filepath.Join(eventsDir, "cli_SSSSSSSSSSSSSSSS")
|
||||
if err := os.MkdirAll(staleSockDir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staleSockDir, "bus.sock"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Stray non-dir file under events/.
|
||||
if err := os.WriteFile(filepath.Join(eventsDir, "stray.txt"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := discoverAppIDs()
|
||||
sort.Strings(got)
|
||||
want := []string{"cli_XXXXXXXXXXXXXXXX", "cli_YYYYYYYYYYYYYYYY"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("discoverAppIDs() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("discoverAppIDs()[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverAppIDs_MissingEventsDir(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if got := discoverAppIDs(); len(got) != 0 {
|
||||
t.Errorf("discoverAppIDs() on missing events/ = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
340
cmd/event/stop_integration_test.go
Normal file
340
cmd/event/stop_integration_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
type mockTransport struct {
|
||||
mu sync.Mutex
|
||||
addr string
|
||||
cleaned bool
|
||||
}
|
||||
|
||||
func (t *mockTransport) Listen(addr string) (net.Listener, error) {
|
||||
return net.Listen("tcp", addr)
|
||||
}
|
||||
|
||||
func (t *mockTransport) Dial(addr string) (net.Conn, error) {
|
||||
return net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||
}
|
||||
|
||||
func (t *mockTransport) Address(appID string) string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.addr
|
||||
}
|
||||
|
||||
func (t *mockTransport) Cleanup(addr string) {
|
||||
t.mu.Lock()
|
||||
t.cleaned = true
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *mockTransport) didCleanup() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.cleaned
|
||||
}
|
||||
|
||||
type fakeBus struct {
|
||||
listener net.Listener
|
||||
pid int
|
||||
exitDelay time.Duration
|
||||
unresponsive bool
|
||||
|
||||
shutdownCount int32
|
||||
wg sync.WaitGroup
|
||||
|
||||
stopOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newFakeBus(t *testing.T, pid int, exitDelay time.Duration, unresponsive bool) *fakeBus {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
b := &fakeBus{
|
||||
listener: ln,
|
||||
pid: pid,
|
||||
exitDelay: exitDelay,
|
||||
unresponsive: unresponsive,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
b.wg.Add(1)
|
||||
go b.serve()
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *fakeBus) addr() string { return b.listener.Addr().String() }
|
||||
|
||||
func (b *fakeBus) serve() {
|
||||
defer b.wg.Done()
|
||||
for {
|
||||
conn, err := b.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b.wg.Add(1)
|
||||
go b.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBus) handle(conn net.Conn) {
|
||||
defer b.wg.Done()
|
||||
defer conn.Close()
|
||||
|
||||
r := bufio.NewReader(conn)
|
||||
line, err := r.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
msg, err := protocol.Decode(line)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.(type) {
|
||||
case *protocol.StatusQuery:
|
||||
_ = protocol.Encode(conn, &protocol.StatusResponse{
|
||||
Type: protocol.MsgTypeStatusResponse,
|
||||
PID: b.pid,
|
||||
UptimeSec: 1,
|
||||
ActiveConns: 0,
|
||||
Consumers: nil,
|
||||
})
|
||||
case *protocol.Shutdown:
|
||||
atomic.AddInt32(&b.shutdownCount, 1)
|
||||
if b.unresponsive {
|
||||
return
|
||||
}
|
||||
if b.exitDelay > 0 {
|
||||
go func() {
|
||||
time.Sleep(b.exitDelay)
|
||||
b.stop()
|
||||
}()
|
||||
} else {
|
||||
go b.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBus) stop() {
|
||||
b.stopOnce.Do(func() {
|
||||
_ = b.listener.Close()
|
||||
close(b.done)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *fakeBus) wait(t *testing.T, budget time.Duration) {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(budget):
|
||||
t.Fatalf("fakeBus did not shut down within %v", budget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopReturnsStoppedOnlyAfterBusExits(t *testing.T) {
|
||||
const pid = 44441
|
||||
const exitDelay = 500 * time.Millisecond
|
||||
|
||||
bus := newFakeBus(t, pid, exitDelay, false)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Fatalf("status = %q (reason=%q); want stopped", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Fatalf("pid = %d; want %d", res.PID, pid)
|
||||
}
|
||||
if elapsed < 400*time.Millisecond {
|
||||
t.Fatalf("stopBusOne returned in %v; expected >= %v (waited for bus to exit)", elapsed, exitDelay)
|
||||
}
|
||||
if elapsed > 3*time.Second {
|
||||
t.Fatalf("stopBusOne took %v; expected well under 3s", elapsed)
|
||||
}
|
||||
|
||||
bus.wait(t, 2*time.Second)
|
||||
if got := atomic.LoadInt32(&bus.shutdownCount); got != 1 {
|
||||
t.Errorf("fakeBus received %d Shutdown messages; want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopTimesOutOnUnresponsiveBusWithoutForce(t *testing.T) {
|
||||
const pid = 44442
|
||||
|
||||
origKill := killProcess
|
||||
t.Cleanup(func() { killProcess = origKill })
|
||||
var killCalls []int
|
||||
var killMu sync.Mutex
|
||||
killProcess = func(p int) error {
|
||||
killMu.Lock()
|
||||
killCalls = append(killCalls, p)
|
||||
killMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
bus := newFakeBus(t, pid, 0, true)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
origBudget := shutdownBudget
|
||||
t.Cleanup(func() { shutdownBudget = origBudget })
|
||||
shutdownBudget = 500 * time.Millisecond
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "error" {
|
||||
t.Fatalf("status = %q (reason=%q); want error", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Errorf("pid = %d; want %d", res.PID, pid)
|
||||
}
|
||||
if elapsed < shutdownBudget || elapsed > shutdownBudget+2*time.Second {
|
||||
t.Fatalf("elapsed = %v; want >= %v and < %v", elapsed, shutdownBudget, shutdownBudget+2*time.Second)
|
||||
}
|
||||
if !strings.Contains(res.Reason, "did not exit within") {
|
||||
t.Errorf("reason %q should mention 'did not exit within'", res.Reason)
|
||||
}
|
||||
killMu.Lock()
|
||||
defer killMu.Unlock()
|
||||
if len(killCalls) != 0 {
|
||||
t.Errorf("killProcess called %v; want 0 calls without --force", killCalls)
|
||||
}
|
||||
if tr.didCleanup() {
|
||||
t.Errorf("Cleanup should not be called when --force is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopForceKillsUnresponsiveBus(t *testing.T) {
|
||||
const pid = 44443
|
||||
|
||||
origKill := killProcess
|
||||
t.Cleanup(func() { killProcess = origKill })
|
||||
var killCalls []int
|
||||
var killMu sync.Mutex
|
||||
killProcess = func(p int) error {
|
||||
killMu.Lock()
|
||||
killCalls = append(killCalls, p)
|
||||
killMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
bus := newFakeBus(t, pid, 0, true)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
origBudget := shutdownBudget
|
||||
t.Cleanup(func() { shutdownBudget = origBudget })
|
||||
shutdownBudget = 500 * time.Millisecond
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", true)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Fatalf("status = %q (reason=%q); want stopped", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Errorf("pid = %d; want %d", res.PID, pid)
|
||||
}
|
||||
if elapsed < shutdownBudget || elapsed > shutdownBudget+2*time.Second {
|
||||
t.Fatalf("elapsed = %v; want >= %v and < %v", elapsed, shutdownBudget, shutdownBudget+2*time.Second)
|
||||
}
|
||||
if !strings.Contains(res.Reason, "killed") {
|
||||
t.Errorf("reason %q should mention 'killed'", res.Reason)
|
||||
}
|
||||
|
||||
killMu.Lock()
|
||||
defer killMu.Unlock()
|
||||
if len(killCalls) != 1 || killCalls[0] != pid {
|
||||
t.Errorf("killProcess calls = %v; want [%d]", killCalls, pid)
|
||||
}
|
||||
if !tr.didCleanup() {
|
||||
t.Errorf("Cleanup was not invoked after force-kill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopReturnsStoppedFastWhenBusExitsImmediately(t *testing.T) {
|
||||
const pid = 12345
|
||||
|
||||
bus := newFakeBus(t, pid, 0, false)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
start := time.Now()
|
||||
res := stopBusOne(tr, "test-app", false)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Fatalf("expected stopped, got %q (reason: %s)", res.Status, res.Reason)
|
||||
}
|
||||
if res.PID != pid {
|
||||
t.Errorf("expected PID=%d, got %d", pid, res.PID)
|
||||
}
|
||||
if elapsed > 500*time.Millisecond {
|
||||
t.Errorf("expected fast return (<500ms), got %v — possibly waiting the full budget", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopForceHandlesProcessAlreadyDeadRace(t *testing.T) {
|
||||
const pid = 99999
|
||||
|
||||
origKill := killProcess
|
||||
t.Cleanup(func() { killProcess = origKill })
|
||||
var killCalls []int
|
||||
var killMu sync.Mutex
|
||||
killProcess = func(p int) error {
|
||||
killMu.Lock()
|
||||
killCalls = append(killCalls, p)
|
||||
killMu.Unlock()
|
||||
return os.ErrProcessDone
|
||||
}
|
||||
|
||||
bus := newFakeBus(t, pid, 0, true)
|
||||
defer bus.stop()
|
||||
tr := &mockTransport{addr: bus.addr()}
|
||||
|
||||
res := stopBusOne(tr, "test-app", true)
|
||||
|
||||
if res.Status != "stopped" {
|
||||
t.Errorf("expected stopped (race treated as success), got %q (reason: %s)", res.Status, res.Reason)
|
||||
}
|
||||
killMu.Lock()
|
||||
if len(killCalls) != 1 || killCalls[0] != pid {
|
||||
t.Errorf("expected killProcess called once with pid=%d, got %v", pid, killCalls)
|
||||
}
|
||||
killMu.Unlock()
|
||||
if !tr.didCleanup() {
|
||||
t.Error("expected Cleanup to be called even when kill reported already-dead")
|
||||
}
|
||||
if !strings.Contains(res.Reason, "exited during kill attempt") {
|
||||
t.Errorf("expected reason about race, got %q", res.Reason)
|
||||
}
|
||||
}
|
||||
102
cmd/event/suggestions.go
Normal file
102
cmd/event/suggestions.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const maxSuggestions = 3
|
||||
|
||||
// suggestEventKeys returns up to maxSuggestions keys resembling input (substring match beats edit distance).
|
||||
func suggestEventKeys(input string) []string {
|
||||
type match struct {
|
||||
key string
|
||||
dist int
|
||||
}
|
||||
var hits []match
|
||||
threshold := max(2, len(input)/5)
|
||||
|
||||
for _, def := range eventlib.ListAll() {
|
||||
if strings.Contains(def.Key, input) {
|
||||
hits = append(hits, match{def.Key, 0})
|
||||
continue
|
||||
}
|
||||
if d := levenshtein(input, def.Key); d <= threshold {
|
||||
hits = append(hits, match{def.Key, d})
|
||||
}
|
||||
}
|
||||
sort.Slice(hits, func(i, j int) bool { return hits[i].dist < hits[j].dist })
|
||||
|
||||
n := min(maxSuggestions, len(hits))
|
||||
out := make([]string, n)
|
||||
for i := range out {
|
||||
out[i] = hits[i].key
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatSuggestions renders keys as a human-readable quoted tail.
|
||||
func formatSuggestions(keys []string) string {
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
quoted := make([]string, len(keys))
|
||||
for i, k := range keys {
|
||||
quoted[i] = fmt.Sprintf("%q", k)
|
||||
}
|
||||
if len(quoted) == 1 {
|
||||
return quoted[0]
|
||||
}
|
||||
return "one of: " + strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
// unknownEventKeyErr builds the shared "unknown EventKey" error with a suggestion tail when available.
|
||||
func unknownEventKeyErr(key string) error {
|
||||
msg := fmt.Sprintf("unknown EventKey: %s", key)
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
}
|
||||
|
||||
// levenshtein computes classic edit distance (two-row DP).
|
||||
func levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 {
|
||||
return len(rb)
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return len(ra)
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
150
cmd/event/suggestions_test.go
Normal file
150
cmd/event/suggestions_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"a", "", 1},
|
||||
{"", "abc", 3},
|
||||
{"kitten", "kitten", 0},
|
||||
{"kitten", "sitten", 1},
|
||||
{"kitten", "sitting", 3},
|
||||
{"飞书", "飞书", 0},
|
||||
{"飞书", "飞s", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := levenshtein(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestEventKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantEmpty bool
|
||||
wantAllHavePrefix string
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "typo via Levenshtein (recieve → receive)",
|
||||
input: "im.message.recieve_v1",
|
||||
wantContains: "im.message.receive_v1",
|
||||
},
|
||||
{
|
||||
name: "substring match returns im.message.* keys",
|
||||
input: "im.message",
|
||||
wantAllHavePrefix: "im.message.",
|
||||
},
|
||||
{
|
||||
name: "completely unrelated input returns empty",
|
||||
input: "xyzzy_no_such_event_key_at_all",
|
||||
wantEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "exact key is a substring of itself",
|
||||
input: "im.message.receive_v1",
|
||||
wantContains: "im.message.receive_v1",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := suggestEventKeys(tc.input)
|
||||
if tc.wantEmpty {
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty slice, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected non-empty suggestions, got nothing")
|
||||
}
|
||||
if len(got) > maxSuggestions {
|
||||
t.Errorf("got %d suggestions, want at most %d: %v", len(got), maxSuggestions, got)
|
||||
}
|
||||
if tc.wantAllHavePrefix != "" {
|
||||
for _, k := range got {
|
||||
if !strings.HasPrefix(k, tc.wantAllHavePrefix) {
|
||||
t.Errorf("suggestion %q lacks prefix %q (full slice: %v)", k, tc.wantAllHavePrefix, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
if tc.wantContains != "" {
|
||||
found := false
|
||||
for _, k := range got {
|
||||
if k == tc.wantContains {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("want %q in suggestions, got %v", tc.wantContains, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSuggestions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{name: "empty → empty string", in: nil, want: ""},
|
||||
{name: "single key → just quoted", in: []string{"a"}, want: `"a"`},
|
||||
{name: "two keys → one of", in: []string{"a", "b"}, want: `one of: "a", "b"`},
|
||||
{name: "three keys → one of", in: []string{"a", "b", "c"}, want: `one of: "a", "b", "c"`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := formatSuggestions(tc.in); got != tc.want {
|
||||
t.Errorf("formatSuggestions(%v) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) {
|
||||
err := unknownEventKeyErr("im.message.recieve_v1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
"unknown EventKey: im.message.recieve_v1",
|
||||
"did you mean",
|
||||
"im.message.receive_v1",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("error %q missing %q", msg, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownEventKeyErr_NoSuggestion(t *testing.T) {
|
||||
err := unknownEventKeyErr("xyzzy_no_such_event_key_at_all")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "unknown EventKey") {
|
||||
t.Errorf("error should mention unknown EventKey: %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "did you mean") {
|
||||
t.Errorf("error should NOT suggest anything for nonsense input: %q", msg)
|
||||
}
|
||||
}
|
||||
39
cmd/event/table.go
Normal file
39
cmd/event/table.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// tableWidths returns the max cell width per column across headers + rows.
|
||||
func tableWidths(headers []string, rows [][]string) []int {
|
||||
widths := make([]int, len(headers))
|
||||
for i, h := range headers {
|
||||
widths[i] = len(h)
|
||||
}
|
||||
for _, row := range rows {
|
||||
for i, cell := range row {
|
||||
if i >= len(widths) {
|
||||
break
|
||||
}
|
||||
if l := len(cell); l > widths[i] {
|
||||
widths[i] = l
|
||||
}
|
||||
}
|
||||
}
|
||||
return widths
|
||||
}
|
||||
|
||||
// printTableRow renders one padded row; final cell is unpadded to avoid trailing whitespace.
|
||||
func printTableRow(out io.Writer, widths []int, cells []string, gap string) {
|
||||
for i, cell := range cells {
|
||||
if i == len(cells)-1 {
|
||||
fmt.Fprintln(out, cell)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, "%-*s%s", widths[i], cell, gap)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
247
cmd/platform_guards.go
Normal file
247
cmd/platform_guards.go
Normal file
@@ -0,0 +1,247 @@
|
||||
// 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).
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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: platform.FailOpen},
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
_, _, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg == nil {
|
||||
t.Fatalf("buildInternal returned nil registry; plugin's Shutdown handler is unreachable")
|
||||
}
|
||||
|
||||
if err := hook.Emit(context.Background(), reg, platform.Shutdown, nil); err != nil {
|
||||
t.Fatalf("Emit(Shutdown): %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt64(&plugin.shutdownCalled); got != 1 {
|
||||
t.Errorf("On(Shutdown) handler fired %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// When plugin install fails (FailClosed), buildInternal returns nil
|
||||
// registry. Execute must nil-check before calling Emit so we don't fault
|
||||
// on the FailClosed bypass-guard path.
|
||||
func TestBuildInternal_failClosedYieldsNilRegistry(t *testing.T) {
|
||||
tmpHome(t)
|
||||
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
// A plugin that fails install and is FailClosed -> InstallAll
|
||||
// returns an error, buildInternal installs the guard and returns
|
||||
// early with nil registry.
|
||||
plugin := &failingPlugin{
|
||||
name: "fail-closed",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailClosed},
|
||||
err: errors.New("install failure simulated"),
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
_, _, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("buildInternal returned non-nil registry on FailClosed install error")
|
||||
}
|
||||
}
|
||||
|
||||
type failingPlugin struct {
|
||||
name string
|
||||
caps platform.Capabilities
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *failingPlugin) Name() string { return p.name }
|
||||
func (p *failingPlugin) Version() string { return "0.0.1" }
|
||||
func (p *failingPlugin) Capabilities() platform.Capabilities { return p.caps }
|
||||
func (p *failingPlugin) Install(platform.Registrar) error { return p.err }
|
||||
|
||||
// === Plugin Restrict conflict guard ===
|
||||
//
|
||||
// Two plugins both calling r.Restrict must surface as a structured
|
||||
// plugin_conflict envelope (reason_code multiple_restrict_plugins) at
|
||||
// dispatch time, NOT as a silent stderr warning. Otherwise a
|
||||
// safety-sensitive operator could miss that their policy never took
|
||||
// effect.
|
||||
func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
rule := &platform.Rule{Name: "any", Allow: []string{"**"}}
|
||||
platform.Register(&fakeIntegrationPlugin{
|
||||
name: "plugin-a",
|
||||
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
|
||||
rule: rule,
|
||||
})
|
||||
platform.Register(&fakeIntegrationPlugin{
|
||||
name: "plugin-b",
|
||||
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
|
||||
rule: rule,
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("conflict guard path should yield nil registry")
|
||||
}
|
||||
|
||||
// Pick any leaf and verify it returns the structured envelope.
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatalf("no runnable leaf in command tree")
|
||||
}
|
||||
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 != "plugin_conflict" {
|
||||
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
|
||||
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
|
||||
}
|
||||
}
|
||||
|
||||
// Single plugin with an invalid Rule must surface as plugin_install /
|
||||
// invalid_rule envelope (distinct error.type from multi-Restrict).
|
||||
func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
// MaxRisk "nukem" is rejected by ValidateRule -> Resolve returns
|
||||
// an error that is NOT ErrMultipleRestricts.
|
||||
platform.Register(&fakeIntegrationPlugin{
|
||||
name: "bad",
|
||||
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
|
||||
rule: &platform.Rule{Name: "bad", MaxRisk: "nukem"},
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("conflict guard path should yield nil registry")
|
||||
}
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatalf("no runnable leaf in command tree")
|
||||
}
|
||||
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 != "plugin_install" {
|
||||
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
|
||||
t.Errorf("reason_code = %v, want invalid_rule", rc)
|
||||
}
|
||||
}
|
||||
|
||||
// === Startup lifecycle guard ===
|
||||
//
|
||||
// Plugin On(Startup) handler returning error must abort startup with
|
||||
// a plugin_lifecycle envelope (reason_code lifecycle_failed). Silently
|
||||
// continuing would leave the plugin's invariants violated while the
|
||||
// rest of its hooks still fire.
|
||||
func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
platform.Register(&startupFailingPlugin{
|
||||
name: "lc",
|
||||
failErr: errors.New("backend unreachable"),
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("lifecycle guard path should yield nil registry")
|
||||
}
|
||||
|
||||
leaf := findRunnableLeaf(root)
|
||||
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 != "plugin_lifecycle" {
|
||||
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "lifecycle_failed" {
|
||||
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
|
||||
}
|
||||
if d["hook_name"] != "lc.start" {
|
||||
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
|
||||
}
|
||||
}
|
||||
|
||||
// Same path but the handler panics -> reason_code lifecycle_panic.
|
||||
func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
platform.Register(&startupFailingPlugin{
|
||||
name: "lc",
|
||||
doPanic: true,
|
||||
panicMsg: "kaboom",
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("lifecycle guard path should yield nil registry")
|
||||
}
|
||||
leaf := findRunnableLeaf(root)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
|
||||
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
|
||||
}
|
||||
}
|
||||
|
||||
type startupFailingPlugin struct {
|
||||
name string
|
||||
failErr error // when set, handler returns this
|
||||
doPanic bool // when true, handler panics with panicMsg
|
||||
panicMsg string
|
||||
}
|
||||
|
||||
func (p *startupFailingPlugin) Name() string { return p.name }
|
||||
func (p *startupFailingPlugin) Version() string { return "0.0.1" }
|
||||
func (p *startupFailingPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (p *startupFailingPlugin) Install(r platform.Registrar) error {
|
||||
r.On(platform.Startup, "start", func(context.Context, *platform.LifecycleContext) error {
|
||||
if p.doPanic {
|
||||
panic(p.panicMsg)
|
||||
}
|
||||
return p.failErr
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// === Wrapper panic recovery ===
|
||||
//
|
||||
// A Wrapper that panics must NOT crash the process. The framework
|
||||
// recovers and converts to a structured envelope:
|
||||
//
|
||||
// type="hook", reason_code="panic", hook_name=<namespaced>
|
||||
func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
platform.Register(&panickingWrapPlugin{name: "p"})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Wrapper panic must be recovered, but it escaped: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
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)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "panic" {
|
||||
t.Errorf("reason_code = %v, want panic", d["reason_code"])
|
||||
}
|
||||
if d["hook_name"] != "p.boom" {
|
||||
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
|
||||
}
|
||||
}
|
||||
|
||||
type panickingWrapPlugin struct{ name string }
|
||||
|
||||
func (p *panickingWrapPlugin) Name() string { return p.name }
|
||||
func (p *panickingWrapPlugin) Version() string { return "0.0.1" }
|
||||
func (p *panickingWrapPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
|
||||
func (p *panickingWrapPlugin) Install(r platform.Registrar) error {
|
||||
r.Wrap("boom", platform.All(),
|
||||
func(_ platform.Handler) platform.Handler {
|
||||
return func(context.Context, platform.Invocation) error {
|
||||
panic("intentional panic for test")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// findRunnableLeaf walks the tree and returns the first command with a
|
||||
// RunE so tests can synthesize a dispatch without going through cobra.
|
||||
func findRunnableLeaf(c *cobra.Command) *cobra.Command {
|
||||
if c.RunE != nil && c.HasParent() {
|
||||
return c
|
||||
}
|
||||
for _, child := range c.Commands() {
|
||||
if l := findRunnableLeaf(child); l != nil {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// B2 regression: a plugin Wrapper whose FACTORY function (the
|
||||
// `func(next Handler) Handler` itself) panics must not crash the
|
||||
// process. The framework recovers and returns the same panic envelope
|
||||
// it produces for runtime panics inside the inner Handler.
|
||||
//
|
||||
// Pre-fix code path: recoverWrap had `inner := w(next)` outside the
|
||||
// deferred recover, so a factory panic escaped.
|
||||
func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
platform.Register(&factoryPanicWrapPlugin{name: "fac"})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("factory panic must be recovered, but it escaped: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
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)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "panic" {
|
||||
t.Errorf("reason_code = %v, want panic", d["reason_code"])
|
||||
}
|
||||
if d["hook_name"] != "fac.bad-factory" {
|
||||
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
|
||||
}
|
||||
}
|
||||
|
||||
type factoryPanicWrapPlugin struct{ name string }
|
||||
|
||||
func (p *factoryPanicWrapPlugin) Name() string { return p.name }
|
||||
func (p *factoryPanicWrapPlugin) Version() string { return "0.0.1" }
|
||||
func (p *factoryPanicWrapPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
|
||||
func (p *factoryPanicWrapPlugin) Install(r platform.Registrar) error {
|
||||
r.Wrap("bad-factory", platform.All(),
|
||||
// The factory itself panics; the returned Handler is never reached.
|
||||
func(_ platform.Handler) platform.Handler {
|
||||
panic("factory blew up")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -45,6 +45,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
_ = cmd.MarkFlagRequired("name")
|
||||
_ = cmd.MarkFlagRequired("app-id")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
||||
return profileListRun(f)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -28,13 +28,14 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT remove profiles unless the user explicitly asks. This is destructive and clears all associated credentials.",
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(name)
|
||||
|
||||
@@ -24,6 +24,7 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
|
||||
return profileRenameRun(f, args[0], args[1])
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -32,9 +33,9 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(oldName)
|
||||
|
||||
@@ -27,13 +27,14 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT switch profiles unless the user explicitly asks.",
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle "-" for toggle-back
|
||||
|
||||
75
cmd/prune.go
75
cmd/prune.go
@@ -4,12 +4,15 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
||||
@@ -42,16 +45,76 @@ func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
|
||||
}
|
||||
|
||||
func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command {
|
||||
// The denial annotations let the hook layer's populateInvocationDenial
|
||||
// recognise this command as denied, so the Wrap chain is physically
|
||||
// isolated (wrapRunE takes the DeniedByPolicy branch and calls the
|
||||
// stub RunE directly). Without these, a plugin Wrapper registered
|
||||
// against platform.All() could intercept and silently swallow the
|
||||
// strict-mode error -- breaking strict-mode's "hard boundary" contract.
|
||||
//
|
||||
// Args + PersistentPreRunE overrides mirror cmdpolicy/apply.go::installDenyStub:
|
||||
//
|
||||
// - Args=ArbitraryArgs: with DisableFlagParsing the user's flags
|
||||
// look like positional args; the original child's Args validator
|
||||
// (e.g. cobra.NoArgs) would fire BEFORE RunE and produce a
|
||||
// cobra usage error instead of our strict_mode envelope.
|
||||
//
|
||||
// - PersistentPreRunE no-op: cmd/auth/auth.go declares a parent
|
||||
// PersistentPreRunE that returns external_provider when env
|
||||
// credentials are set. Cobra's "first wins walking up" would
|
||||
// pick auth's instead of our denial. A leaf-level no-op makes
|
||||
// cobra stop here and proceed to the wrapped RunE.
|
||||
//
|
||||
// strict-mode keeps its short Message + independent Hint and
|
||||
// composes the shared detail.* / wrapped-CommandDeniedError shape
|
||||
// by hand; BuildDenialError would override Message with the
|
||||
// CommandDeniedError.Error() long form.
|
||||
stubMessage := fmt.Sprintf(
|
||||
"strict mode is %q, only %s-identity commands are available",
|
||||
mode, mode.ForcedIdentity())
|
||||
const stubHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
|
||||
denial := cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerStrictMode,
|
||||
PolicySource: "strict-mode",
|
||||
ReasonCode: "identity_not_supported",
|
||||
Reason: stubMessage,
|
||||
}
|
||||
// Preserve the original command's annotations (risk_level,
|
||||
// lark:supportedIdentities, cmdmeta.domain, ...) and help text so
|
||||
// audit / compliance observers can still see what was denied.
|
||||
// Stamp the denial annotations on top.
|
||||
annotations := make(map[string]string, len(child.Annotations)+2)
|
||||
for k, v := range child.Annotations {
|
||||
annotations[k] = v
|
||||
}
|
||||
annotations[cmdpolicy.AnnotationDenialLayer] = cmdpolicy.LayerStrictMode
|
||||
annotations[cmdpolicy.AnnotationDenialSource] = "strict-mode"
|
||||
|
||||
return &cobra.Command{
|
||||
Use: child.Use,
|
||||
Aliases: append([]string(nil), child.Aliases...),
|
||||
Short: child.Short,
|
||||
Long: child.Long,
|
||||
Hidden: true,
|
||||
DisableFlagParsing: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, only %s identity is allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode, mode.ForcedIdentity())
|
||||
Args: cobra.ArbitraryArgs,
|
||||
Annotations: annotations,
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Message: stubMessage,
|
||||
Hint: stubHint,
|
||||
Detail: cmdpolicy.DenialDetailMap(cd),
|
||||
},
|
||||
Err: cd,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -198,3 +202,176 @@ func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for codex C13: a strict-mode stub whose PARENT declares
|
||||
// a PersistentPreRunE (e.g. cmd/auth/auth.go's external_provider
|
||||
// check on env credentials) must surface the strict_mode envelope,
|
||||
// not the parent's error. Cobra's "first PersistentPreRunE wins
|
||||
// walking up from leaf" semantics will pick the parent's unless the
|
||||
// stub itself carries its own.
|
||||
//
|
||||
// Fix: strictModeStubFrom installs a no-op PersistentPreRunE so cobra
|
||||
// stops at the stub and proceeds to its RunE.
|
||||
func TestStrictModeStub_BypassesParentPersistentPreRunE(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
stub := findCmd(root, "auth", "login")
|
||||
if stub == nil {
|
||||
t.Fatal("auth/login stub should exist after StrictModeBot")
|
||||
}
|
||||
if stub.PersistentPreRunE == nil {
|
||||
t.Fatal("strict-mode stub must declare PersistentPreRunE on leaf")
|
||||
}
|
||||
if err := stub.PersistentPreRunE(stub, nil); err != nil {
|
||||
t.Errorf("strict-mode stub PersistentPreRunE should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for codex H13: strict-mode stub must accept arbitrary
|
||||
// positional args. With DisableFlagParsing=true, a user passing
|
||||
// `auth login --scope ...` looks like 4 positional args; the original
|
||||
// cobra.Args validator would surface a usage error BEFORE strict-mode
|
||||
// stub's RunE.
|
||||
func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
stub := findCmd(root, "auth", "login")
|
||||
if stub == nil {
|
||||
t.Fatal("auth/login stub should exist after StrictModeBot")
|
||||
}
|
||||
if stub.Args == nil {
|
||||
t.Fatal("strict-mode stub must declare Args validator")
|
||||
}
|
||||
if err := stub.Args(stub, []string{"--scope", "im.message", "--profile", "default"}); err != nil {
|
||||
t.Errorf("strict-mode stub Args should accept flag-like args, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Pins the strict-mode envelope shape: structured detail.* / wrapped
|
||||
// CommandDeniedError for external agents, AND the historical short
|
||||
// Message + independent Hint for existing consumers.
|
||||
func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
stub := findCmd(root, "im", "+search")
|
||||
if stub == nil {
|
||||
t.Fatalf("expected im/+search stub")
|
||||
}
|
||||
err := stub.RunE(stub, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("strict-mode stub RunE should return error")
|
||||
}
|
||||
|
||||
var ee *output.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
t.Fatalf("err is not *output.ExitError: %T", err)
|
||||
}
|
||||
if ee.Detail == nil {
|
||||
t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON")
|
||||
}
|
||||
if ee.Detail.Type != "command_denied" {
|
||||
t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type)
|
||||
}
|
||||
dm, ok := ee.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail)
|
||||
}
|
||||
if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode)
|
||||
}
|
||||
if got, _ := dm["reason_code"].(string); got != "identity_not_supported" {
|
||||
t.Errorf("Detail.Detail[reason_code] = %q, want identity_not_supported", got)
|
||||
}
|
||||
if got, _ := dm["policy_source"].(string); got != "strict-mode" {
|
||||
t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got)
|
||||
}
|
||||
|
||||
var cd *platform.CommandDeniedError
|
||||
if !errors.As(err, &cd) {
|
||||
t.Fatalf("err does not unwrap to *platform.CommandDeniedError")
|
||||
}
|
||||
if cd.Layer != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("CommandDeniedError.Layer = %q, want %q", cd.Layer, cmdpolicy.LayerStrictMode)
|
||||
}
|
||||
if cd.ReasonCode != "identity_not_supported" {
|
||||
t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode)
|
||||
}
|
||||
if !strings.Contains(cd.Reason, `strict mode is "bot"`) {
|
||||
t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason)
|
||||
}
|
||||
if ee.Detail.Message != `strict mode is "bot", only bot-identity commands are available` {
|
||||
t.Errorf("Detail.Message = %q, want short historical form", ee.Detail.Message)
|
||||
}
|
||||
if !strings.HasPrefix(ee.Detail.Hint, "if the user explicitly wants to switch policy") {
|
||||
t.Errorf("Detail.Hint = %q, want historical hint", ee.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// strictModeStubFrom must write the denial annotations so the hook
|
||||
// layer's populateInvocationDenial recognises the command as denied
|
||||
// and physically isolates the Wrap chain. Without this, a plugin
|
||||
// Wrapper registered against platform.All() could intercept the stub
|
||||
// and silently return nil, swallowing the strict-mode error.
|
||||
func TestStrictModeStub_HasDenialAnnotation(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
|
||||
// im/+search is user-only -> replaced by a stub in StrictModeBot.
|
||||
stub := findCmd(root, "im", "+search")
|
||||
if stub == nil {
|
||||
t.Fatalf("expected im/+search stub to exist")
|
||||
}
|
||||
got := stub.Annotations[cmdpolicy.AnnotationDenialLayer]
|
||||
if got != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("stub annotation %q = %q, want %q",
|
||||
cmdpolicy.AnnotationDenialLayer, got, cmdpolicy.LayerStrictMode)
|
||||
}
|
||||
if src := stub.Annotations[cmdpolicy.AnnotationDenialSource]; src != "strict-mode" {
|
||||
t.Errorf("stub annotation %q = %q, want %q",
|
||||
cmdpolicy.AnnotationDenialSource, src, "strict-mode")
|
||||
}
|
||||
}
|
||||
|
||||
// Audit / compliance observers fire even for strict-mode-denied commands
|
||||
// and rely on CommandView.Risk() / Identities() / etc. The stub must
|
||||
// carry the original command's annotations so those accessors keep
|
||||
// returning meaningful values; the Short/Long are preserved so `--help`
|
||||
// on a denied command still describes the original intent (parity with
|
||||
// cmdpolicy/apply.go::installDenyStub).
|
||||
func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
svc := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(svc)
|
||||
userOnly := &cobra.Command{
|
||||
Use: "+search",
|
||||
Short: "search messages",
|
||||
Long: "Search across IM history.",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(userOnly, []string{"user"})
|
||||
cmdutil.SetRisk(userOnly, "read")
|
||||
svc.AddCommand(userOnly)
|
||||
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
|
||||
stub := findCmd(root, "im", "+search")
|
||||
if stub == nil {
|
||||
t.Fatalf("expected im/+search stub")
|
||||
}
|
||||
if got := stub.Annotations["risk_level"]; got != "read" {
|
||||
t.Errorf("stub risk_level = %q, want %q (lost in replacement)", got, "read")
|
||||
}
|
||||
if got := stub.Annotations["lark:supportedIdentities"]; got != "user" {
|
||||
t.Errorf("stub supportedIdentities = %q, want %q", got, "user")
|
||||
}
|
||||
if stub.Short != "search messages" {
|
||||
t.Errorf("stub Short = %q, want preserved Short", stub.Short)
|
||||
}
|
||||
if stub.Long != "Search across IM history." {
|
||||
t.Errorf("stub Long = %q, want preserved Long", stub.Long)
|
||||
}
|
||||
// Denial stamps must still be present.
|
||||
if stub.Annotations[cmdpolicy.AnnotationDenialLayer] != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("denial annotation overwritten or missing")
|
||||
}
|
||||
}
|
||||
|
||||
162
cmd/root.go
162
cmd/root.go
@@ -12,14 +12,20 @@ import (
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -47,7 +53,7 @@ EXAMPLES:
|
||||
FLAGS:
|
||||
--params <json> URL/query parameters JSON
|
||||
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
|
||||
--as <type> identity type: user | bot | auto (default: auto)
|
||||
--as <type> identity type: user | bot
|
||||
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
|
||||
--page-all automatically paginate through all pages
|
||||
--page-size <N> page size (0 = use API default)
|
||||
@@ -87,68 +93,99 @@ func Execute() int {
|
||||
}
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
f, rootCmd := buildInternal(
|
||||
context.Background(), inv,
|
||||
ctx := context.Background()
|
||||
f, rootCmd, reg := buildInternal(
|
||||
ctx, inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
)
|
||||
|
||||
// --- Update check (non-blocking) ---
|
||||
// --- Notices (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
setupUpdateNotice()
|
||||
setupNotices()
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err)
|
||||
runErr := rootCmd.Execute()
|
||||
|
||||
// Fire Shutdown lifecycle hooks regardless of run outcome.
|
||||
// emitShutdown imposes a 2s total deadline and never propagates handler
|
||||
// errors (Emit's documented Shutdown contract), so it cannot block exit
|
||||
// or alter the user-visible exit code.
|
||||
if reg != nil && !isCompletionCommand(os.Args) {
|
||||
_ = hook.Emit(ctx, reg, platform.Shutdown, runErr)
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
return handleRootError(f, runErr)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// setupUpdateNotice starts an async update check and wires the output decorator.
|
||||
func setupUpdateNotice() {
|
||||
// Sync: check cache immediately (no network, fast).
|
||||
// setupNotices wires both the binary update notice and the skills
|
||||
// staleness notice into output.PendingNotice as a composed function.
|
||||
// Each provider populates an independent key under _notice; either
|
||||
// or both may be present in any given envelope.
|
||||
func setupNotices() {
|
||||
// Binary update — synchronous cache check + async refresh
|
||||
if info := update.CheckCached(build.Version); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
|
||||
// Async: refresh cache for this run (and future runs).
|
||||
ver := build.Version
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
|
||||
}
|
||||
}()
|
||||
update.RefreshCache(build.Version)
|
||||
// If cache was just populated for the first time, set pending now.
|
||||
update.RefreshCache(ver)
|
||||
if update.GetPending() == nil {
|
||||
if info := update.CheckCached(build.Version); info != nil {
|
||||
if info := update.CheckCached(ver); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wire the output decorator so JSON envelopes include "_notice".
|
||||
// Skills check — synchronous, local-only (no network, no goroutine).
|
||||
skillscheck.Init(build.Version)
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
info := update.GetPending()
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"update": map[string]interface{}{
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
},
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
}
|
||||
|
||||
// isCompletionCommand returns true if args indicate a shell completion request.
|
||||
// Update notifications must be suppressed for these to avoid corrupting
|
||||
// machine-parseable completion output.
|
||||
// Update notifications and Shutdown lifecycle emits must be suppressed for
|
||||
// these to avoid corrupting machine-parseable completion output and to avoid
|
||||
// firing plugin Shutdown handlers on every Tab keystroke.
|
||||
//
|
||||
// Cobra dispatches BOTH "__complete" and its alias "__completeNoDesc" through
|
||||
// the same hidden subcommand (see cobra/completions.go ShellCompRequestCmd /
|
||||
// ShellCompNoDescRequestCmd). Check both, otherwise bash/zsh completion
|
||||
// (which often uses NoDesc) silently bypasses the gate.
|
||||
func isCompletionCommand(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "completion" || arg == "__complete" {
|
||||
if arg == "completion" || arg == "__complete" || arg == "__completeNoDesc" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -158,7 +195,7 @@ func isCompletionCommand(args []string) bool {
|
||||
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
|
||||
// the invocation will actually serve a __complete request.
|
||||
func configureFlagCompletions(args []string) {
|
||||
cmdutil.SetFlagCompletionsDisabled(!isCompletionCommand(args))
|
||||
cmdutil.SetFlagCompletionsEnabled(isCompletionCommand(args))
|
||||
}
|
||||
|
||||
// handleRootError dispatches a command error to the appropriate handler
|
||||
@@ -179,6 +216,7 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
if !exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command) preserve the original API
|
||||
// error detail; skip enrichment which would clear it.
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
enrichPermissionError(f, exitErr)
|
||||
}
|
||||
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
|
||||
@@ -247,6 +285,70 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
|
||||
fmt.Fprint(w, buffer.String())
|
||||
}
|
||||
|
||||
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
||||
// group commands (no Run/RunE) with an unknown_subcommand error.
|
||||
//
|
||||
// IMPORTANT: every command modified here is also tagged with
|
||||
// cmdpolicy.AnnotationPureGroup so the user-layer policy engine
|
||||
// continues to treat the command as a pure parent group. Without the
|
||||
// tag, the RunE injection here would flip Runnable()=true and a user
|
||||
// rule like `max_risk: read` would deny every `<group> --help` call
|
||||
// with reason_code=risk_not_annotated.
|
||||
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
||||
cmd.RunE = unknownSubcommandRunE
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[cmdpolicy.AnnotationPureGroup] = "true"
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
installUnknownSubcommandGuard(c)
|
||||
}
|
||||
}
|
||||
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
unknown := args[0]
|
||||
available := availableSubcommandNames(cmd)
|
||||
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
||||
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
||||
if len(available) > 0 {
|
||||
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_subcommand",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"available": available,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
subs := make([]string, 0, len(cmd.Commands()))
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || !c.IsAvailableCommand() {
|
||||
continue
|
||||
}
|
||||
name := c.Name()
|
||||
if name == "help" || name == "completion" {
|
||||
continue
|
||||
}
|
||||
subs = append(subs, name)
|
||||
}
|
||||
sort.Strings(subs)
|
||||
return subs
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
||||
// flags that are normally hidden in single-app mode (currently --profile)
|
||||
@@ -262,11 +364,15 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
defaultHelp(cmd, args)
|
||||
out := cmd.OutOrStdout()
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Risk:", level)
|
||||
}
|
||||
tips := cmdutil.GetTips(cmd)
|
||||
if len(tips) == 0 {
|
||||
return
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Tips:")
|
||||
for _, tip := range tips {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,15 +15,26 @@ import (
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Canonical strict-mode envelope strings shared across fixtures
|
||||
// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom).
|
||||
const (
|
||||
strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available`
|
||||
strictModeUserMessage = `strict mode is "user", only user-identity commands are available`
|
||||
strictModeHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
|
||||
)
|
||||
|
||||
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
|
||||
// subcommands wired to a test factory, simulating the real CLI command tree.
|
||||
func buildIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -149,20 +161,6 @@ func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
|
||||
stderr.Reset()
|
||||
}
|
||||
|
||||
func parseDryRunJSON(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
out := stdout.String()
|
||||
const prefix = "=== Dry Run ===\n"
|
||||
if !strings.HasPrefix(out, prefix) {
|
||||
t.Fatalf("expected dry-run prefix, got:\n%s", out)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(out, prefix)), &payload); err != nil {
|
||||
t.Fatalf("failed to parse dry-run payload: %v\nstdout: %s", err, out)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// --- api command ---
|
||||
|
||||
func TestIntegration_Api_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
@@ -357,11 +355,23 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
|
||||
"auth", "login", "--json", "--scope", "im:message.send_as_user",
|
||||
})
|
||||
|
||||
// auth login is user-only, so it gets pruned in strict-mode-bot and the
|
||||
// stub error fires (not login.go's inline check, which is shadowed by
|
||||
// pruning).
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Type: "command_denied",
|
||||
Message: strictModeBotMessage,
|
||||
Hint: strictModeHint,
|
||||
Detail: map[string]any{
|
||||
"path": "auth/login",
|
||||
"layer": "strict_mode",
|
||||
"policy_source": "strict-mode",
|
||||
"rule_name": "",
|
||||
"reason_code": "identity_not_supported",
|
||||
"reason": strictModeBotMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -377,8 +387,17 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Type: "command_denied",
|
||||
Message: strictModeBotMessage,
|
||||
Hint: strictModeHint,
|
||||
Detail: map[string]any{
|
||||
"path": "im/+messages-search",
|
||||
"layer": "strict_mode",
|
||||
"policy_source": "strict-mode",
|
||||
"rule_name": "",
|
||||
"reason_code": "identity_not_supported",
|
||||
"reason": strictModeBotMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -402,7 +421,26 @@ func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentity(t *testing.T) {
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
|
||||
})
|
||||
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Message: `strict mode is "user", only user-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
@@ -410,16 +448,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentit
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
payload := parseDryRunJSON(t, stdout)
|
||||
if got := payload["as"]; got != "bot" {
|
||||
t.Fatalf("dry-run as = %v, want bot", got)
|
||||
}
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
@@ -433,13 +470,22 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Type: "command_denied",
|
||||
Message: strictModeUserMessage,
|
||||
Hint: strictModeHint,
|
||||
Detail: map[string]any{
|
||||
"path": "im/images/create",
|
||||
"layer": "strict_mode",
|
||||
"policy_source": "strict-mode",
|
||||
"rule_name": "",
|
||||
"reason_code": "identity_not_supported",
|
||||
"reason": strictModeUserMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t *testing.T) {
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
@@ -447,16 +493,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t
|
||||
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
||||
}
|
||||
payload := parseDryRunJSON(t, stdout)
|
||||
if got := payload["as"]; got != "bot" {
|
||||
t.Fatalf("dry-run as = %v, want bot", got)
|
||||
}
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- shortcut command ---
|
||||
@@ -490,3 +535,193 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
|
||||
// produces no skills key in the composed notice. Users who installed
|
||||
// skills via `npx skills add` (no stamp) must not see the misleading
|
||||
// "not installed" notice — only `lark-cli update` users opt into the
|
||||
// drift tracker.
|
||||
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
origVersion := build.Version
|
||||
build.Version = "1.0.21"
|
||||
t.Cleanup(func() { build.Version = origVersion })
|
||||
|
||||
// Reset pending state to ensure a clean test.
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() {
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
return // expected — no pending notices at all
|
||||
}
|
||||
if _, ok := notice["skills"]; ok {
|
||||
t.Errorf("notice.skills present in cold-start state, want absent: %+v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_InSync verifies that a matching stamp produces no
|
||||
// skills key in the composed notice.
|
||||
func TestSetupNotices_InSync(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
origVersion := build.Version
|
||||
build.Version = "1.0.21"
|
||||
t.Cleanup(func() { build.Version = origVersion })
|
||||
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() {
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice != nil {
|
||||
if _, ok := notice["skills"]; ok {
|
||||
t.Errorf("notice.skills present in in-sync state: %+v", notice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_Drift verifies a mismatching stamp produces the
|
||||
// drift message with both current and target populated.
|
||||
func TestSetupNotices_Drift(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
origVersion := build.Version
|
||||
build.Version = "1.0.21"
|
||||
t.Cleanup(func() { build.Version = origVersion })
|
||||
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() {
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
t.Fatal("GetNotice() = nil, want non-nil for drift")
|
||||
}
|
||||
skills, ok := notice["skills"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("notice.skills missing, got %+v", notice)
|
||||
}
|
||||
if skills["current"] != "1.0.20" || skills["target"] != "1.0.21" {
|
||||
t.Errorf("notice.skills = %+v, want {current:\"1.0.20\", target:\"1.0.21\"}", skills)
|
||||
}
|
||||
want := "lark-cli skills 1.0.20 out of sync with binary 1.0.21, run: lark-cli update"
|
||||
if msg, _ := skills["message"].(string); msg != want {
|
||||
t.Errorf("notice.skills.message = %q, want %q", msg, want)
|
||||
}
|
||||
if cmd, _ := skills["command"].(string); cmd != "lark-cli update" {
|
||||
t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_BothUpdateAndSkills verifies the composed envelope
|
||||
// emits BOTH "_notice.update" and "_notice.skills" keys when each
|
||||
// pending value is set. Drives the skills key via setupNotices() (drift
|
||||
// state) and manually populates the update pending afterwards, since
|
||||
// clearNoticeEnv suppresses the update goroutine to avoid network
|
||||
// flakiness.
|
||||
func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
origVersion := build.Version
|
||||
build.Version = "1.0.21"
|
||||
t.Cleanup(func() { build.Version = origVersion })
|
||||
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() {
|
||||
skillscheck.SetPending(nil)
|
||||
update.SetPending(nil)
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
|
||||
// After setupNotices, skills pending is set (drift). Manually populate
|
||||
// the update side so the composed envelope has both keys — the update
|
||||
// goroutine is suppressed by clearNoticeEnv.
|
||||
update.SetPending(&update.UpdateInfo{Current: "1.0.21", Latest: "1.0.22"})
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
t.Fatal("GetNotice() = nil, want both keys")
|
||||
}
|
||||
if _, ok := notice["update"].(map[string]interface{}); !ok {
|
||||
t.Errorf("missing 'update' key: %+v", notice)
|
||||
}
|
||||
if _, ok := notice["skills"].(map[string]interface{}); !ok {
|
||||
t.Errorf("missing 'skills' key: %+v", notice)
|
||||
}
|
||||
upd, ok := notice["update"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("notice.update missing or wrong type: %+v", notice)
|
||||
}
|
||||
if cmd, _ := upd["command"].(string); cmd != "lark-cli update" {
|
||||
t.Errorf("notice.update.command = %q, want %q", cmd, "lark-cli update")
|
||||
}
|
||||
sk, ok := notice["skills"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("notice.skills missing or wrong type: %+v", notice)
|
||||
}
|
||||
if cmd, _ := sk["command"].(string); cmd != "lark-cli update" {
|
||||
t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update")
|
||||
}
|
||||
}
|
||||
|
||||
// clearNoticeEnv unsets the env vars that affect either notice. We
|
||||
// proactively SUPPRESS the update notifier (LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1)
|
||||
// because setupNotices spawns a goroutine that hits the npm registry —
|
||||
// tests focused on the skills check should not depend on network state.
|
||||
func clearNoticeEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, key := range []string{
|
||||
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER",
|
||||
"CI", "BUILD_NUMBER", "RUN_ID",
|
||||
} {
|
||||
t.Setenv(key, "")
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
// Suppress the update goroutine's network call deterministically.
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
}
|
||||
|
||||
70
cmd/root_risk_help_test.go
Normal file
70
cmd/root_risk_help_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// rendersHelp runs the wrapped help func and returns stdout.
|
||||
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
cmd.HelpFunc()(cmd, nil)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
root.AddCommand(child)
|
||||
|
||||
out := rendersHelp(t, child)
|
||||
if !strings.Contains(out, "Risk: high-risk-write") {
|
||||
t.Errorf("expected Risk line in help output, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "list", Short: "list items"}
|
||||
root.AddCommand(child)
|
||||
|
||||
out := rendersHelp(t, child)
|
||||
if strings.Contains(out, "Risk:") {
|
||||
t.Errorf("expected no Risk line when annotation is absent, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
cmdutil.SetTips(child, []string{"use --yes to confirm"})
|
||||
root.AddCommand(child)
|
||||
|
||||
out := rendersHelp(t, child)
|
||||
riskIdx := strings.Index(out, "Risk:")
|
||||
tipsIdx := strings.Index(out, "Tips:")
|
||||
if riskIdx == -1 || tipsIdx == -1 {
|
||||
t.Fatalf("expected both Risk and Tips sections, got:\n%s", out)
|
||||
}
|
||||
if riskIdx >= tipsIdx {
|
||||
t.Errorf("expected Risk to precede Tips; got Risk@%d, Tips@%d", riskIdx, tipsIdx)
|
||||
}
|
||||
}
|
||||
183
cmd/root_test.go
183
cmd/root_test.go
@@ -11,9 +11,12 @@ import (
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
|
||||
@@ -188,6 +191,150 @@ func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
var target registry.CommandEntry
|
||||
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
||||
if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" {
|
||||
target = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if target.Command == "" {
|
||||
t.Fatal("failed to locate a calendar create command in local registry metadata")
|
||||
}
|
||||
parts := strings.Split(target.Command, " ")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("expected resource/method command, got %q", target.Command)
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "calendar"}
|
||||
resourceCmd := &cobra.Command{Use: parts[0]}
|
||||
methodCmd := &cobra.Command{Use: parts[1]}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(resourceCmd)
|
||||
resourceCmd.AddCommand(methodCmd)
|
||||
f.CurrentCommand = methodCmd
|
||||
|
||||
exitErr := output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected exit code %d, got %d", output.ExitAPI, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
||||
t.Fatalf("expected api_error detail, got %+v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
|
||||
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): calendar:calendar.event:create") {
|
||||
t.Fatalf("expected scope guidance in hint, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
|
||||
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Detail != nil {
|
||||
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("expected exit code %d, got %d", output.ExitNetwork, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||
t.Fatalf("expected network detail, got %+v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
|
||||
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): docx:document:create") {
|
||||
t.Fatalf("expected shortcut scope hint, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
|
||||
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Detail != nil {
|
||||
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ShortcutIncludesConditionalScopes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "drive"}
|
||||
shortcutCmd := &cobra.Command{Use: "+status"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected error detail")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
|
||||
t.Fatalf("expected conditional scope hint for drive +status, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
exitErr.Detail.Hint = "existing hint"
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
|
||||
if exitErr.Detail.Hint != want {
|
||||
t.Fatalf("expected appended hint %q, got %q", want, exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
||||
@@ -198,7 +345,7 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigureFlagCompletions(t *testing.T) {
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
|
||||
t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -209,14 +356,42 @@ func TestConfigureFlagCompletions(t *testing.T) {
|
||||
{"help flag", []string{"im", "--help"}, true},
|
||||
{"no args", []string{}, true},
|
||||
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
|
||||
{"__completeNoDesc request", []string{"__completeNoDesc", "im", "+send", ""}, false},
|
||||
{"completion subcommand", []string{"completion", "bash"}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmdutil.SetFlagCompletionsDisabled(!tc.wantDisabled)
|
||||
cmdutil.SetFlagCompletionsEnabled(tc.wantDisabled)
|
||||
configureFlagCompletions(tc.args)
|
||||
if got := cmdutil.FlagCompletionsDisabled(); got != tc.wantDisabled {
|
||||
t.Fatalf("FlagCompletionsDisabled() = %v, want %v", got, tc.wantDisabled)
|
||||
if got := !cmdutil.FlagCompletionsEnabled(); got != tc.wantDisabled {
|
||||
t.Fatalf("FlagCompletionsEnabled() = %v, want disabled=%v", !got, tc.wantDisabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isCompletionCommand must classify BOTH cobra completion aliases as
|
||||
// completion requests so the Shutdown emit and update-notice paths skip
|
||||
// shell-completion invocations. __completeNoDesc is an Alias of
|
||||
// __complete (cobra/completions.go ShellCompNoDescRequestCmd) and
|
||||
// dispatches the same RunE; bash/zsh completion typically calls the
|
||||
// NoDesc variant.
|
||||
func TestIsCompletionCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{"plain command", []string{"im", "+send"}, false},
|
||||
{"__complete", []string{"__complete", "im"}, true},
|
||||
{"__completeNoDesc", []string{"__completeNoDesc", "im"}, true},
|
||||
{"completion subcommand", []string{"completion", "bash"}, true},
|
||||
{"completion in tail", []string{"foo", "bar", "completion"}, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isCompletionCommand(tc.args); got != tc.want {
|
||||
t.Fatalf("isCompletionCommand(%v) = %v, want %v", tc.args, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -380,6 +380,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
251
cmd/sec/config_init.go
Normal file
251
cmd/sec/config_init.go
Normal file
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// NewCmdSecConfig is the parent for `lark-cli sec config <verb>`. Currently
|
||||
// it only carries `init`; future verbs (e.g. `show`, `reset`) plug in here.
|
||||
func NewCmdSecConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage lark-sec-cli daemon configuration",
|
||||
}
|
||||
cmd.AddCommand(NewCmdSecConfigInit(f, nil))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ConfigInitOptions holds inputs for `lark-cli sec config init`.
|
||||
type ConfigInitOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
AppID string
|
||||
AppSecret string
|
||||
Brand string
|
||||
Yes bool // skip the interactive form when all required values are provided
|
||||
}
|
||||
|
||||
// NewCmdSecConfigInit collects App ID / App Secret / Brand from the user and
|
||||
// registers them with the running lark-sec-cli daemon's admin endpoint. The
|
||||
// daemon stashes the secret in the OS keychain and switches into sidecar mode
|
||||
// for SEC_AUTH credential isolation.
|
||||
func NewCmdSecConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
|
||||
opts := &ConfigInitOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Register a Lark App with the running lark-sec-cli daemon",
|
||||
Long: `Register an App ID / App Secret with the lark-sec-cli daemon.
|
||||
|
||||
The daemon must already be running (start it with "lark-cli sec run"). The
|
||||
registration POSTs to /_sec/api/v1/register-app on the local proxy port,
|
||||
HMAC-signed with the daemon's proxy.key.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runConfigInit(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (skips the prompt when set)")
|
||||
cmd.Flags().StringVar(&opts.AppSecret, "app-secret", "", "App Secret (skips the prompt when set)")
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark")
|
||||
cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "skip the interactive form when all required values are provided")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// secBridge mirrors what the daemon writes to ~/.lark-cli/sec_config.json.
|
||||
// It's the single contract between lark-cli and lark-sec-cli at runtime —
|
||||
// we don't reach into lark-sec-cli internals, only what it chooses to publish.
|
||||
type secBridge struct {
|
||||
Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"`
|
||||
Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"`
|
||||
CA string `json:"LARKSUITE_CLI_SEC_CA"`
|
||||
Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"`
|
||||
}
|
||||
|
||||
func runConfigInit(cmd *cobra.Command, opts *ConfigInitOptions) error {
|
||||
errOut := opts.Factory.IOStreams.ErrOut
|
||||
trace := verboseOut(cmd, errOut)
|
||||
|
||||
tracef(trace, "sec config init", "loading daemon bridge from %s/sec_config.json", core.GetConfigDir())
|
||||
bridge, err := loadBridge()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "sec_bridge_missing",
|
||||
fmt.Sprintf("daemon bridge file unreadable: %v", err),
|
||||
"Start the daemon first: `lark-cli sec run`.")
|
||||
}
|
||||
tracef(trace, "sec config init", "bridge: enable=%t proxy=%s ca=%s auth=%t", bridge.Enable, bridge.Proxy, bridge.CA, bridge.Auth)
|
||||
if !bridge.Enable || bridge.Proxy == "" {
|
||||
return output.ErrWithHint(output.ExitValidation, "sec_not_running",
|
||||
"lark-sec-cli is not advertising an active proxy",
|
||||
"Run `lark-cli sec run` to start it.")
|
||||
}
|
||||
|
||||
// The HMAC key sits next to the CA in the daemon's config dir. Deriving
|
||||
// from the bridge's SEC_CA path keeps lark-cli decoupled from the daemon's
|
||||
// install location — if the daemon ever moves, the bridge follows and we
|
||||
// follow with it.
|
||||
tracef(trace, "sec config init", "reading daemon HMAC key beside %s", bridge.CA)
|
||||
hmacKey, err := readHMACKey(bridge.CA)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "sec_hmac_key", "read daemon HMAC key: %v", err)
|
||||
}
|
||||
|
||||
if err := promptForMissing(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracef(trace, "sec config init", "POST %s/_sec/api/v1/register-app app_id=%s brand=%s", bridge.Proxy, opts.AppID, opts.Brand)
|
||||
if err := registerApp(cmd.Context(), bridge.Proxy, hmacKey, opts.AppID, opts.AppSecret, opts.Brand); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "sec_register_app", "register-app: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(errOut,
|
||||
fmt.Sprintf("registered app %s with lark-sec-cli (%s)", opts.AppID, opts.Brand))
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadBridge reads the daemon-written sec_config.json from lark-cli's config dir.
|
||||
func loadBridge() (*secBridge, error) {
|
||||
path := filepath.Join(core.GetConfigDir(), "sec_config.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var b secBridge
|
||||
if err := json.Unmarshal(data, &b); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// readHMACKey returns the daemon's proxy.key bytes. The daemon writes the key
|
||||
// hex-encoded (64 ASCII chars); we hex-decode here. If the file is a raw
|
||||
// 32-byte blob (older daemon variants), we use it as-is.
|
||||
func readHMACKey(caPath string) ([]byte, error) {
|
||||
if caPath == "" {
|
||||
return nil, errors.New("sec_config.json has no LARKSUITE_CLI_SEC_CA — can't locate proxy.key")
|
||||
}
|
||||
keyPath := filepath.Join(filepath.Dir(caPath), "proxy.key")
|
||||
raw, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 64 {
|
||||
if decoded, err := hex.DecodeString(string(raw)); err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// promptForMissing fills in any of AppID / AppSecret / Brand the user didn't
|
||||
// provide via flags. --yes refuses to prompt; that's caller error if any are
|
||||
// still missing at that point.
|
||||
func promptForMissing(opts *ConfigInitOptions) error {
|
||||
if opts.AppID != "" && opts.AppSecret != "" && opts.Brand != "" {
|
||||
return nil
|
||||
}
|
||||
if opts.Yes {
|
||||
return output.ErrValidation("--yes set but missing one of --app-id / --app-secret / --brand")
|
||||
}
|
||||
|
||||
groups := []*huh.Group{}
|
||||
if opts.AppID == "" {
|
||||
groups = append(groups, huh.NewGroup(
|
||||
huh.NewInput().Title("App ID").Placeholder("cli_xxxx").Value(&opts.AppID),
|
||||
))
|
||||
}
|
||||
if opts.AppSecret == "" {
|
||||
groups = append(groups, huh.NewGroup(
|
||||
huh.NewInput().Title("App Secret").EchoMode(huh.EchoModePassword).Value(&opts.AppSecret),
|
||||
))
|
||||
}
|
||||
if opts.Brand == "" {
|
||||
opts.Brand = "feishu"
|
||||
groups = append(groups, huh.NewGroup(
|
||||
huh.NewSelect[string]().Title("Brand").Options(
|
||||
huh.NewOption("Feishu (cn)", "feishu"),
|
||||
huh.NewOption("Lark (intl)", "lark"),
|
||||
).Value(&opts.Brand),
|
||||
))
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
form := huh.NewForm(groups...).WithTheme(cmdutil.ThemeFeishu())
|
||||
if err := form.Run(); err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerApp POSTs to /_sec/api/v1/register-app with the daemon's HMAC scheme.
|
||||
// Canonical signing input is "method\npath\nsha256hex(body)\ntimestamp", per
|
||||
// lark-sec-cli/internal/proxy/admin_handler.go's verifyHMAC.
|
||||
func registerApp(ctx context.Context, proxyURL string, hmacKey []byte, appID, appSecret, brand string) error {
|
||||
const path = "/_sec/api/v1/register-app"
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": appID,
|
||||
"app_secret": appSecret,
|
||||
"brand": brand,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
bodyHash := sha256.Sum256(body)
|
||||
canonical := http.MethodPost + "\n" + path + "\n" + hex.EncodeToString(bodyHash[:]) + "\n" + ts
|
||||
mac := hmac.New(sha256.New, hmacKey)
|
||||
mac.Write([]byte(canonical))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, proxyURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Lark-Admin-Signature", sig)
|
||||
req.Header.Set("X-Lark-Admin-Timestamp", ts)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
33
cmd/sec/factory.go
Normal file
33
cmd/sec/factory.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
intsec "github.com/larksuite/cli/internal/sec"
|
||||
)
|
||||
|
||||
// installer wires up an internal/sec.Installer using the Factory's HTTP client,
|
||||
// the default platform paths, and a lazy OAPI-client provider used to fetch
|
||||
// the install manifest. APIClientFunc is a method value, not an eager call —
|
||||
// commands that short-circuit (or that never install, like sec status / sec
|
||||
// stop) avoid decrypting credentials from the keychain. Every cmd/sec
|
||||
// subcommand starts here.
|
||||
func installer(f *cmdutil.Factory) (*intsec.Installer, *intsec.Paths, error) {
|
||||
paths, err := intsec.DefaultPaths()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve sec paths: %w", err)
|
||||
}
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolve http client: %w", err)
|
||||
}
|
||||
return &intsec.Installer{
|
||||
Paths: paths,
|
||||
HTTPClient: httpClient,
|
||||
APIClientFunc: f.NewAPIClient,
|
||||
}, paths, nil
|
||||
}
|
||||
127
cmd/sec/run.go
Normal file
127
cmd/sec/run.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
intsec "github.com/larksuite/cli/internal/sec"
|
||||
)
|
||||
|
||||
// RunOptions holds inputs for `lark-cli sec run`.
|
||||
type RunOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
ProxyPort int
|
||||
// AutoInstall runs `sec install` first when no binary is recorded.
|
||||
AutoInstall bool
|
||||
}
|
||||
|
||||
// NewCmdSecRun starts lark-sec-cli as a user-level system service so it
|
||||
// persists across logins and gets restarted by the OS supervisor if it
|
||||
// crashes. Under the hood it shells out to `lark-sec-cli service enable`,
|
||||
// which is the recommended startup path per the lark-sec-cli manual:
|
||||
//
|
||||
// - macOS → user-level launchd plist with KeepAlive=true
|
||||
// - Linux → user systemd unit with Restart=always
|
||||
// - Windows → registry autostart + a VBS watchdog loop
|
||||
//
|
||||
// Switching to this from a detached `exec.Command(... Setsid:true)` spawn
|
||||
// fixes two latent issues at once: (1) daemon logs survive past lark-cli
|
||||
// exit because the service supervisor — not our terminated pipes — owns
|
||||
// the daemon's stdout, and (2) the daemon's own self-upgrade module can
|
||||
// now fire (it gates on running-under-supervisor).
|
||||
func NewCmdSecRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command {
|
||||
opts := &RunOptions{Factory: f, AutoInstall: true}
|
||||
cmd := &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Enable lark-sec-cli as a user system service (the daemon runs in the background)",
|
||||
Long: `Install lark-sec-cli as a user-level system service so the proxy
|
||||
daemon runs automatically, persists across logins, and is restarted by the
|
||||
OS if it exits. The daemon writes its own log file (default: under
|
||||
~/.lark-sec-cli/logs/daemon.log) so logs persist independently of this
|
||||
command.
|
||||
|
||||
After enabling, the daemon writes ~/.lark-cli/sec_config.json itself with
|
||||
the proxy port and CA path, so subsequent lark-cli runs route through the
|
||||
sidecar without any further action.
|
||||
|
||||
To stop and remove the service: lark-cli sec stop.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runRun(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "force lark-sec-cli to bind this port (default: dynamic)")
|
||||
cmd.Flags().BoolVar(&opts.AutoInstall, "auto-install", true, "bootstrap-install lark-sec-cli first when no binary is recorded")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runRun(cmd *cobra.Command, opts *RunOptions) error {
|
||||
ctx := cmd.Context()
|
||||
errOut := opts.Factory.IOStreams.ErrOut
|
||||
trace := verboseOut(cmd, errOut)
|
||||
|
||||
tracef(trace, "sec run", "constructing installer (lazy credentials)")
|
||||
inst, paths, err := installer(opts.Factory)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
|
||||
// Make sure we have a binary on disk before asking it to install itself
|
||||
// as a service.
|
||||
tracef(trace, "sec run", "loading state from %s", paths.StateFile())
|
||||
state, err := intsec.LoadState(paths.StateFile())
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
|
||||
}
|
||||
if state == nil {
|
||||
tracef(trace, "sec run", "no install on disk (auto-install=%t)", opts.AutoInstall)
|
||||
if !opts.AutoInstall {
|
||||
return output.ErrWithHint(output.ExitValidation, "sec_not_installed",
|
||||
"lark-sec-cli is not installed",
|
||||
"Re-run `lark-cli sec run` with --auto-install (default on), or remove --auto-install=false.")
|
||||
}
|
||||
state, err = inst.Install(ctx, intsec.InstallOptions{Verbose: trace})
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitNetwork, "sec_install", "auto-install lark-sec-cli: %v", err)
|
||||
}
|
||||
} else {
|
||||
tracef(trace, "sec run", "existing install: version=%s binary=%s", state.Version, state.BinaryPath)
|
||||
}
|
||||
|
||||
args := []string{"service", "enable"}
|
||||
if opts.ProxyPort > 0 {
|
||||
args = append(args, fmt.Sprintf("--proxy-port=%d", opts.ProxyPort))
|
||||
}
|
||||
|
||||
fmt.Fprintf(errOut, "Running: %s %v\n", state.BinaryPath, args)
|
||||
tracef(trace, "sec run", "shelling out to %s %v", state.BinaryPath, args)
|
||||
|
||||
c := exec.CommandContext(ctx, state.BinaryPath, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
if err := c.Run(); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "sec_service_enable",
|
||||
"`lark-sec-cli service enable` failed: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
tracef(trace, "sec run", "service enable returned ok (%d bytes stdout)", stdout.Len())
|
||||
|
||||
// Forward the installer's stdout to the user — it contains the launchd /
|
||||
// systemd unit name, the registered executable path, and a confirmation
|
||||
// that the supervisor will respawn the daemon on exit. Useful diagnostic
|
||||
// output that's better seen than swallowed.
|
||||
fmt.Fprint(errOut, stdout.String())
|
||||
output.PrintSuccess(errOut,
|
||||
"lark-sec-cli enabled as a user system service. Run `lark-cli sec status` to verify, `lark-cli sec stop` to disable.")
|
||||
return nil
|
||||
}
|
||||
49
cmd/sec/sec.go
Normal file
49
cmd/sec/sec.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package sec exposes the `lark-cli sec` command tree that bootstraps the
|
||||
// lark-sec-cli sidecar daemon: install, run, stop, status, and `config init`.
|
||||
// The internal/sec package owns the implementation; this package is a thin
|
||||
// Cobra wrapper that mirrors the conventions in cmd/auth.
|
||||
//
|
||||
// After bootstrap install, lark-sec-cli handles its own upgrade lifecycle —
|
||||
// lark-cli is not in the update path, which is why there's no `sec update`
|
||||
// subcommand here.
|
||||
package sec
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmdSec builds the parent `sec` command and registers all subcommands.
|
||||
//
|
||||
// The persistent --verbose / -v flag is inherited by every subcommand:
|
||||
// `sec run -v`, `sec status -v`, etc. all emit step-by-step trace output to
|
||||
// stderr.
|
||||
//
|
||||
// There is no `sec install` subcommand — `sec run` auto-installs lark-sec-cli
|
||||
// if no binary is on disk, so a separate install verb was redundant.
|
||||
func NewCmdSec(f *cmdutil.Factory) *cobra.Command {
|
||||
var verbose bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "sec",
|
||||
Short: "Manage the lark-sec-cli security sidecar (run, status, stop, config)",
|
||||
Long: `Manage the lark-sec-cli security sidecar.
|
||||
|
||||
lark-sec-cli is a local HTTPS proxy daemon that intercepts lark-cli's traffic,
|
||||
injects BDMS risk-control signatures, and manages credentials via the OS
|
||||
keychain. These subcommands handle the runtime lifecycle from lark-cli's side:
|
||||
start the daemon (auto-installing on first run), inspect its state, register
|
||||
an app with it, and stop it. Updates after the first install are managed by
|
||||
lark-sec-cli itself.`,
|
||||
}
|
||||
cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
|
||||
"print step-by-step pipeline output to stderr")
|
||||
cmd.AddCommand(NewCmdSecRun(f, nil))
|
||||
cmd.AddCommand(NewCmdSecStop(f, nil))
|
||||
cmd.AddCommand(NewCmdSecStatus(f, nil))
|
||||
cmd.AddCommand(NewCmdSecConfig(f))
|
||||
return cmd
|
||||
}
|
||||
38
cmd/sec/sec_test.go
Normal file
38
cmd/sec/sec_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// TestNewCmdSec_HasAllSubcommands locks in the public command surface so a
|
||||
// future refactor doesn't silently drop run/status/etc. The `update` verb
|
||||
// was intentionally removed when lark-sec-cli took over its own upgrade
|
||||
// lifecycle; if it ever needs to come back, add it here too. `install` was
|
||||
// removed because `sec run --auto-install` (default on) makes a standalone
|
||||
// install verb redundant.
|
||||
func TestNewCmdSec_HasAllSubcommands(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
cmd := NewCmdSec(f)
|
||||
|
||||
var got []string
|
||||
for _, c := range cmd.Commands() {
|
||||
got = append(got, c.Name())
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := []string{"config", "run", "status", "stop"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("subcommands = %v, want %v", got, want)
|
||||
}
|
||||
for i, name := range want {
|
||||
if got[i] != name {
|
||||
t.Errorf("subcommands[%d] = %q, want %q", i, got[i], name)
|
||||
}
|
||||
}
|
||||
}
|
||||
115
cmd/sec/status.go
Normal file
115
cmd/sec/status.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
intsec "github.com/larksuite/cli/internal/sec"
|
||||
)
|
||||
|
||||
// StatusOptions holds inputs for `lark-cli sec status`.
|
||||
type StatusOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
}
|
||||
|
||||
// NewCmdSecStatus shows install + runtime state. Implementation strategy:
|
||||
//
|
||||
// 1. Read lark-cli's local install record (state.json) — works even when the
|
||||
// daemon's not installed, and gives the user a version/buildId/path
|
||||
// fingerprint regardless of whether the service is up.
|
||||
// 2. If the install exists, shell out to `lark-sec-cli status` for the
|
||||
// live daemon view (service registration, pid liveness, proxy probe,
|
||||
// sec_config.json contents). The daemon's own status command does a
|
||||
// thorough check; we just pass it through.
|
||||
func NewCmdSecStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
|
||||
opts := &StatusOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show lark-sec-cli install and runtime state",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runStatus(cmd, opts)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runStatus(cmd *cobra.Command, opts *StatusOptions) error {
|
||||
errOut := opts.Factory.IOStreams.ErrOut
|
||||
trace := verboseOut(cmd, errOut)
|
||||
|
||||
tracef(trace, "sec status", "constructing installer (lazy credentials)")
|
||||
_, paths, err := installer(opts.Factory)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
out := opts.Factory.IOStreams.Out
|
||||
tracef(trace, "sec status", "loading state from %s", paths.StateFile())
|
||||
state, err := intsec.LoadState(paths.StateFile())
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
|
||||
}
|
||||
if state == nil {
|
||||
fmt.Fprintln(out, "lark-sec-cli: not installed")
|
||||
fmt.Fprintln(out, " run: lark-cli sec run")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(out, "lark-sec-cli %s\n", state.Version)
|
||||
fmt.Fprintf(out, " binary: %s\n", state.BinaryPath)
|
||||
|
||||
// Daemon-side detail via `lark-sec-cli status`. The daemon's status
|
||||
// command already covers service registration + pid + proxy reachability
|
||||
// + bridge file — better than re-implementing those here.
|
||||
tracef(trace, "sec status", "shelling out to %s status", state.BinaryPath)
|
||||
c := exec.CommandContext(cmd.Context(), state.BinaryPath, "status")
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
runErr := c.Run()
|
||||
tracef(trace, "sec status", "daemon status exit=%v stdout=%d bytes stderr=%d bytes", runErr, stdout.Len(), stderr.Len())
|
||||
fmt.Fprintln(out, " --- lark-sec-cli status ---")
|
||||
if stdout.Len() > 0 {
|
||||
fmt.Fprint(out, indent(stdout.String(), " "))
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
fmt.Fprint(out, indent(stderr.String(), " "))
|
||||
}
|
||||
// `lark-sec-cli status` exits 1 when not running — that's diagnostic
|
||||
// data, not a failure of OUR command. Surface it for the user but don't
|
||||
// propagate the non-zero exit upward.
|
||||
_ = runErr
|
||||
return nil
|
||||
}
|
||||
|
||||
// indent prefixes every line of s with prefix. Cheap pass-through formatter
|
||||
// used to make the embedded `lark-sec-cli status` output read as a sub-block
|
||||
// under our own header.
|
||||
func indent(s, prefix string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
buf.WriteString(prefix)
|
||||
buf.WriteString(s[start : i+1])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
buf.WriteString(prefix)
|
||||
buf.WriteString(s[start:])
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
82
cmd/sec/stop.go
Normal file
82
cmd/sec/stop.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
intsec "github.com/larksuite/cli/internal/sec"
|
||||
)
|
||||
|
||||
// StopOptions holds inputs for `lark-cli sec stop`.
|
||||
type StopOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
}
|
||||
|
||||
// NewCmdSecStop disables and removes the lark-sec-cli user system service.
|
||||
// Counterpart to `sec run` — internally invokes `lark-sec-cli service disable`,
|
||||
// which uninstalls the launchd / systemd / VBS-watchdog registration.
|
||||
//
|
||||
// The daemon itself wipes ~/.lark-cli/sec_config.json on shutdown (see its
|
||||
// --disable-on-exit flag, default true), so subsequent lark-cli runs route
|
||||
// directly to the upstream API instead of dangling through a dead local proxy.
|
||||
func NewCmdSecStop(f *cmdutil.Factory, runF func(*StopOptions) error) *cobra.Command {
|
||||
opts := &StopOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Disable and remove the lark-sec-cli user system service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runStop(cmd, opts)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runStop(cmd *cobra.Command, opts *StopOptions) error {
|
||||
out := opts.Factory.IOStreams.ErrOut
|
||||
trace := verboseOut(cmd, out)
|
||||
|
||||
tracef(trace, "sec stop", "constructing installer (lazy credentials)")
|
||||
_, paths, err := installer(opts.Factory)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
tracef(trace, "sec stop", "loading state from %s", paths.StateFile())
|
||||
state, err := intsec.LoadState(paths.StateFile())
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
|
||||
}
|
||||
if state == nil {
|
||||
// Nothing on disk to stop — no-op.
|
||||
tracef(trace, "sec stop", "no install on disk; nothing to stop")
|
||||
output.PrintSuccess(out, "lark-sec-cli not installed; nothing to stop")
|
||||
return nil
|
||||
}
|
||||
|
||||
args := []string{"service", "disable"}
|
||||
fmt.Fprintf(out, "Running: %s %v\n", state.BinaryPath, args)
|
||||
tracef(trace, "sec stop", "shelling out to %s %v", state.BinaryPath, args)
|
||||
|
||||
c := exec.CommandContext(cmd.Context(), state.BinaryPath, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
if err := c.Run(); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "sec_service_disable",
|
||||
"`lark-sec-cli service disable` failed: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
tracef(trace, "sec stop", "service disable returned ok (%d bytes stdout)", stdout.Len())
|
||||
fmt.Fprint(out, stdout.String())
|
||||
output.PrintSuccess(out, "lark-sec-cli service disabled")
|
||||
return nil
|
||||
}
|
||||
32
cmd/sec/verbose.go
Normal file
32
cmd/sec/verbose.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// verboseOut returns the trace destination for a sec subcommand: the given
|
||||
// stderr writer when the inherited --verbose / -v flag is set, otherwise nil.
|
||||
// Pair with tracef — a nil destination silently drops traces, so callers can
|
||||
// emit unconditionally.
|
||||
func verboseOut(cmd *cobra.Command, errOut io.Writer) io.Writer {
|
||||
if v, _ := cmd.Flags().GetBool("verbose"); v {
|
||||
return errOut
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tracef writes one trace line to w when w is non-nil. The prefix names the
|
||||
// emitting subcommand (e.g. "sec run") so layered output from the install
|
||||
// pipeline + the command itself stays distinguishable.
|
||||
func tracef(w io.Writer, prefix, format string, args ...any) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "[%s] "+format+"\n", append([]any{prefix}, args...)...)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user