mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
112 Commits
feat/sidec
...
docs/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09d5c5d99b | ||
|
|
e1af7e3018 | ||
|
|
693e299589 | ||
|
|
69f335be7c | ||
|
|
d1a0926dd6 | ||
|
|
008bdda861 | ||
|
|
f1da8c274b | ||
|
|
842be3fdc5 | ||
|
|
1cd7a88597 | ||
|
|
7c64e63b9d | ||
|
|
8e60f01474 | ||
|
|
465c789f7c | ||
|
|
2a7e9c7d0d | ||
|
|
76ba6fad4f | ||
|
|
510545f1e5 | ||
|
|
c11cf3b716 | ||
|
|
ee2c93efeb | ||
|
|
33e459a4de | ||
|
|
5aeae2db65 | ||
|
|
9b39d10203 | ||
|
|
8572a58fda | ||
|
|
9bc66cc445 | ||
|
|
e53f9d999e | ||
|
|
ae35b35693 | ||
|
|
c2e617fc96 | ||
|
|
3f77eded9d | ||
|
|
e64610f6d2 | ||
|
|
dfa26c38f6 | ||
|
|
154ecdb90f | ||
|
|
483043c88b | ||
|
|
6d8dc402ac | ||
|
|
9f2e049858 | ||
|
|
2c703f2fce | ||
|
|
501bf539af | ||
|
|
8e667db534 | ||
|
|
e751a53f76 | ||
|
|
e794fd5925 | ||
|
|
077b5e7180 | ||
|
|
0d20a02050 | ||
|
|
7cc0b49603 | ||
|
|
6b48a39d55 | ||
|
|
b07be60068 | ||
|
|
31bc87a2cc | ||
|
|
7fdf55821b | ||
|
|
201e3e016f | ||
|
|
eed711bb11 | ||
|
|
4f4c0b59c9 | ||
|
|
2b4c6349a1 | ||
|
|
944cd55fc7 | ||
|
|
7229baae40 | ||
|
|
170565c57e | ||
|
|
03ea6e78b8 | ||
|
|
ed3fe9337f | ||
|
|
cc416a4de5 | ||
|
|
00d45f8fa2 | ||
|
|
0d847511d2 | ||
|
|
8f5504c51c | ||
|
|
d0a896ce91 | ||
|
|
99ceb2279c | ||
|
|
ec2ffebf47 | ||
|
|
ee5113f9d0 | ||
|
|
7cce7468d6 | ||
|
|
281cdbd37c | ||
|
|
add079ea1c | ||
|
|
076f4d579f | ||
|
|
0c2fd08d5a | ||
|
|
9d845442ce | ||
|
|
c07a14aa2b | ||
|
|
8b39f7243c | ||
|
|
e40ef66912 | ||
|
|
e1bb9db552 | ||
|
|
7c50b3d9e3 | ||
|
|
5788a6c384 | ||
|
|
bd07859c90 | ||
|
|
8c3cba17b2 | ||
|
|
6367aaa0f5 | ||
|
|
37b17f3d37 | ||
|
|
be5527ca4e | ||
|
|
a75420f72c | ||
|
|
f3949f04c4 | ||
|
|
62364fc320 | ||
|
|
2f4e2c3019 | ||
|
|
3990151122 | ||
|
|
fa929f02d6 | ||
|
|
a4a4bd6ee0 | ||
|
|
ac116e7ca3 | ||
|
|
5e6a3eb857 | ||
|
|
493b3cce95 | ||
|
|
abc0553f21 | ||
|
|
a82a486508 | ||
|
|
c000dc3a44 | ||
|
|
256df8c0fb | ||
|
|
7a0dbe057b | ||
|
|
8ce38793a7 | ||
|
|
54e646edc9 | ||
|
|
b07a6003f9 | ||
|
|
03a589978f | ||
|
|
b3fcf55611 | ||
|
|
2f35ce3724 | ||
|
|
7e7f716a82 | ||
|
|
1670a794f6 | ||
|
|
33de28fd1a | ||
|
|
85c7280d8b | ||
|
|
24ce3ec151 | ||
|
|
2bbab4d851 | ||
|
|
98173ae5a9 | ||
|
|
c8e205eed2 | ||
|
|
04932c2421 | ||
|
|
531d7265b5 | ||
|
|
6d7f8ba442 | ||
|
|
b216363e63 | ||
|
|
b0b163d0ef |
30
.github/CODEOWNERS
vendored
Normal file
30
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/internal/ @liangshuo-1
|
||||||
|
|
||||||
|
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||||
|
/skills/ @liangshuo-1
|
||||||
|
/skills/lark-approval/
|
||||||
|
/skills/lark-apps/
|
||||||
|
/skills/lark-attendance/
|
||||||
|
/skills/lark-base/
|
||||||
|
/skills/lark-calendar/
|
||||||
|
/skills/lark-contact/
|
||||||
|
/skills/lark-doc/
|
||||||
|
/skills/lark-drive/
|
||||||
|
/skills/lark-event/
|
||||||
|
/skills/lark-im/
|
||||||
|
/skills/lark-mail/
|
||||||
|
/skills/lark-markdown/
|
||||||
|
/skills/lark-minutes/
|
||||||
|
/skills/lark-okr/
|
||||||
|
/skills/lark-openapi-explorer/
|
||||||
|
/skills/lark-shared/
|
||||||
|
/skills/lark-sheets/
|
||||||
|
/skills/lark-skill-maker/
|
||||||
|
/skills/lark-slides/
|
||||||
|
/skills/lark-task/
|
||||||
|
/skills/lark-vc/
|
||||||
|
/skills/lark-vc-agent/
|
||||||
|
/skills/lark-whiteboard/
|
||||||
|
/skills/lark-wiki/
|
||||||
|
/skills/lark-workflow-meeting-summary/
|
||||||
|
/skills/lark-workflow-standup-report/
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,6 +35,8 @@ tests/mail/reports/
|
|||||||
# Generated / test artifacts
|
# Generated / test artifacts
|
||||||
.hammer/
|
.hammer/
|
||||||
.lark-slides/
|
.lark-slides/
|
||||||
|
/notes/
|
||||||
|
/minutes/
|
||||||
internal/registry/meta_data.json
|
internal/registry/meta_data.json
|
||||||
cmd/api/download.bin
|
cmd/api/download.bin
|
||||||
app.log
|
app.log
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ linters:
|
|||||||
- path: internal/vfs/
|
- path: internal/vfs/
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
|
# internal/gen build-time generators (standalone `package main` run via
|
||||||
|
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
|
||||||
|
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
|
||||||
|
# impossible here: a structured error return needs os.Exit (also banned),
|
||||||
|
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
|
||||||
|
- path: shortcuts/.*/internal/gen/
|
||||||
|
linters:
|
||||||
|
- forbidigo
|
||||||
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
|
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
|
||||||
# for the client / credential layer.
|
# for the client / credential layer.
|
||||||
- path-except: shortcuts/
|
- path-except: shortcuts/
|
||||||
@@ -65,10 +73,23 @@ linters:
|
|||||||
- forbidigo
|
- forbidigo
|
||||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||||
# Add a path when its migration is complete.
|
# Add a path when its migration is complete.
|
||||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
|
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||||
text: errs-typed-only
|
text: errs-typed-only
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
|
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||||
|
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||||
|
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||||
|
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||||
|
text: errs-no-bare-wrap
|
||||||
|
linters:
|
||||||
|
- forbidigo
|
||||||
|
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||||
|
# helpers have migrated to typed final errors.
|
||||||
|
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
|
||||||
|
text: errs-no-legacy-helper
|
||||||
|
linters:
|
||||||
|
- forbidigo
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
depguard:
|
depguard:
|
||||||
@@ -94,6 +115,21 @@ linters:
|
|||||||
msg: >-
|
msg: >-
|
||||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||||
(see errs/types.go).
|
(see errs/types.go).
|
||||||
|
# ── legacy shared error helpers banned on migrated domains ──
|
||||||
|
# These helpers emit legacy output.Err* / bare error shapes or drop
|
||||||
|
# typed metadata such as Param/Cause. Migrated domains must use typed
|
||||||
|
# common replacements or local typed helpers instead.
|
||||||
|
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||||
|
msg: >-
|
||||||
|
[errs-no-legacy-helper] these shared helpers emit legacy or
|
||||||
|
metadata-poor error shapes. Use typed common replacements, typed
|
||||||
|
errs.NewXxxError builders, or domain-local typed helpers.
|
||||||
|
# ── bare error wraps banned on fully-typed paths ──
|
||||||
|
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||||
|
msg: >-
|
||||||
|
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
|
||||||
|
wrap a cause with .WithCause(err). Genuine intermediate wraps:
|
||||||
|
//nolint:forbidigo with a reason.
|
||||||
# ── http: shortcuts must not construct raw HTTP requests ──
|
# ── http: shortcuts must not construct raw HTTP requests ──
|
||||||
# Bans request / client construction; constants (http.MethodPost,
|
# Bans request / client construction; constants (http.MethodPost,
|
||||||
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ builds:
|
|||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
- riscv64
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||||
|
|||||||
28
AGENTS.md
28
AGENTS.md
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build # Build (runs fetch_meta first)
|
make build # Build (runs fetch_meta first)
|
||||||
make unit-test # Required before PR (runs with -race)
|
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||||
make test # Full: vet + unit + integration
|
make test # Full: vet + unit + integration
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -75,7 +75,31 @@ The one rule to internalize: **every error message you write will be parsed by a
|
|||||||
|
|
||||||
### Structured errors in commands
|
### Structured errors in commands
|
||||||
|
|
||||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
|
||||||
|
|
||||||
|
Picking a constructor:
|
||||||
|
|
||||||
|
| Failure | Constructor |
|
||||||
|
|---------|-------------|
|
||||||
|
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
|
||||||
|
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
|
||||||
|
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
|
||||||
|
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
|
||||||
|
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
|
||||||
|
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
|
||||||
|
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
|
||||||
|
|
||||||
|
Signatures that are easy to guess wrong:
|
||||||
|
|
||||||
|
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
|
||||||
|
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }` — `ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
|
||||||
|
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
|
||||||
|
|
||||||
|
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
|
||||||
|
|
||||||
|
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
|
||||||
|
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
|
||||||
|
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
|
||||||
|
|
||||||
### stdout is data, stderr is everything else
|
### stdout is data, stderr is everything else
|
||||||
|
|
||||||
|
|||||||
193
CHANGELOG.md
193
CHANGELOG.md
@@ -2,6 +2,191 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [v1.0.53] - 2026-06-12
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
|
||||||
|
- **auth**: Add `--json` flag support to auth subcommands (#1431)
|
||||||
|
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
|
||||||
|
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
|
||||||
|
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **skills**: Optimize whiteboard skill (#1371)
|
||||||
|
- **skills**: Optimize okr skill (#1368)
|
||||||
|
|
||||||
|
## [v1.0.52] - 2026-06-11
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **events**: Per-resource subscription identity + Match hook (#1185)
|
||||||
|
- **apps**: Emit typed error envelopes across the apps domain (#1288)
|
||||||
|
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
|
||||||
|
- **im**: Add `--chat-modes` filter to chat search (#1317)
|
||||||
|
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
|
||||||
|
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **apps**: Support git credential dry-run (#1390)
|
||||||
|
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
|
||||||
|
- **build**: Make `-race` flag arch-conditional to support riscv64
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
|
||||||
|
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
|
||||||
|
- **skills**: Optimize lark-drive skill routing (#1284)
|
||||||
|
- **skills**: Expand cite user guidance and fix typos (#1394)
|
||||||
|
|
||||||
|
## [v1.0.51] - 2026-06-10
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **apps**: Support multi dev modes (#1175)
|
||||||
|
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
|
||||||
|
- **base**: Configure initial base table schema (#1377)
|
||||||
|
- **vc**: Add recording event support (#1369)
|
||||||
|
- **minutes**: Replace words for transcript (#1372)
|
||||||
|
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
|
||||||
|
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
|
||||||
|
- **slides**: Emit typed error envelopes across the slides domain (#1349)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
|
||||||
|
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
|
||||||
|
|
||||||
|
## [v1.0.50] - 2026-06-09
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **doc**: Emit typed error envelopes across the doc domain (#1346)
|
||||||
|
- **event**: Emit typed error envelopes across the event domain (#1289)
|
||||||
|
- **contact**: Emit typed error envelopes across the contact domain (#1287)
|
||||||
|
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
|
||||||
|
- **cli**: Adjust agent timeout hint output conditions (#1328)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
|
||||||
|
- **slides**: Build create URL locally instead of drive metas call (#1329)
|
||||||
|
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
|
||||||
|
- **doc**: Document `<folder-manager>` resource block (#1168)
|
||||||
|
- **drive**: Add drive comment location guidance (#1258)
|
||||||
|
|
||||||
|
## [v1.0.49] - 2026-06-08
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **events**: Add whiteboard event domain with per-board subscription (#1265)
|
||||||
|
- **im**: Support feed group (#1102)
|
||||||
|
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
|
||||||
|
- **im**: Format feed group error handling (#1308)
|
||||||
|
- **im**: Return typed error envelopes across the im domain (#1230)
|
||||||
|
- **base**: Emit typed error envelopes across the base domain (#1248)
|
||||||
|
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
|
||||||
|
- **task**: Emit typed error envelopes across the task domain (#1231)
|
||||||
|
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
|
||||||
|
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
|
||||||
|
- **markdown**: Harden create upload failures (#1325)
|
||||||
|
- **drive**: Harden inspect shortcut failures (#1324)
|
||||||
|
- **slides**: Add IconPark lookup for Lark slides (#1123)
|
||||||
|
- **doc**: Remove docs v1 API (#1291)
|
||||||
|
- **cli**: Add `skills` command to read embedded skill content (#1318)
|
||||||
|
- **cli**: Fetch official skills index (#1301)
|
||||||
|
- **shared**: Document relative-path-only file arguments (#1319)
|
||||||
|
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
|
||||||
|
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
|
||||||
|
- **drive**: Use docs secure label read scope (#1281)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
|
||||||
|
- **skills**: Tighten drive and markdown guardrails (#1326)
|
||||||
|
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
|
||||||
|
- **markdown**: Add markdown domain template (#1293)
|
||||||
|
- **markdown**: Improve lark-markdown skill guidance (#1279)
|
||||||
|
- **doc**: Improve lark-doc skill guidance (#1283)
|
||||||
|
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
|
||||||
|
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
|
||||||
|
|
||||||
|
## [v1.0.48] - 2026-06-04
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
|
||||||
|
- **contact**: Add contact skill domain guidance (#1144)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **skills**: Use JSON skills list during update (#1251)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
|
||||||
|
- **vc-agent**: Require explicit leave request (#1260)
|
||||||
|
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
|
||||||
|
|
||||||
|
## [v1.0.47] - 2026-06-03
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
|
||||||
|
- **base**: Add base block shortcuts (#1044)
|
||||||
|
- **im**: Complete card message format (#1198)
|
||||||
|
- **im**: Improve markdown guidance for messages (#1237)
|
||||||
|
- **vc**: Forward invite call-id on meeting join (#1243)
|
||||||
|
- **drive**: Emit typed error envelopes across the drive domain (#1205)
|
||||||
|
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
|
||||||
|
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
|
||||||
|
- **wiki**: Support `appid` member type (#1235)
|
||||||
|
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
|
||||||
|
- **config**: Validate credentials after `config init` (#1151)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **skills**: Recover empty fallback for skills to update (#1233)
|
||||||
|
|
||||||
|
## [v1.0.46] - 2026-06-02
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **im**: Add card message format support (#1218)
|
||||||
|
- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216)
|
||||||
|
- **vc**: Inline transcript from artifacts API and add keywords (#1206)
|
||||||
|
- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181)
|
||||||
|
- **agent**: Increase agent trace max length to 1024 (#1211)
|
||||||
|
- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **cli**: Remove FLAGS section from root `--help` (#1226)
|
||||||
|
- **cli**: Stop root `--help` listing per-command flags as global (#1223)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **base**: Optimize base skill references (#1171)
|
||||||
|
- **drive**: Add Lark Drive knowledge organization workflow (#1028)
|
||||||
|
|
||||||
## [v1.0.45] - 2026-06-01
|
## [v1.0.45] - 2026-06-01
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -964,6 +1149,14 @@ Bundled AI agent skills for intelligent assistance:
|
|||||||
- Bilingual documentation (English & Chinese).
|
- Bilingual documentation (English & Chinese).
|
||||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||||
|
|
||||||
|
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||||
|
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||||
|
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||||
|
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
|
||||||
|
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
|
||||||
|
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||||
|
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||||
|
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||||
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
|
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
|
||||||
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
|
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
|
||||||
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
|
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
|
||||||
|
|||||||
9
Makefile
9
Makefile
@@ -8,6 +8,13 @@ DATE := $(shell date +%Y-%m-%d)
|
|||||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||||
PREFIX ?= /usr/local
|
PREFIX ?= /usr/local
|
||||||
|
|
||||||
|
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
|
||||||
|
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
|
||||||
|
# over `go env GOARCH`, because command-line make variables are not visible to
|
||||||
|
# $(shell ...).
|
||||||
|
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||||
|
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||||
|
|
||||||
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
||||||
|
|
||||||
all: test
|
all: test
|
||||||
@@ -34,7 +41,7 @@ fmt-check:
|
|||||||
|
|
||||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||||
unit-test: fetch_meta
|
unit-test: fetch_meta
|
||||||
go test -race -gcflags="all=-N -l" -count=1 \
|
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
|
||||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||||
|
|
||||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
|||||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
||||||
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
|
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
|
||||||
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
|
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
|
||||||
|
|
||||||
## Installation & Quick Start
|
## Installation & Quick Start
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
| 🔗 应用 | 创建妙搭(Spark/Miaoda)应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
|
||||||
|
|
||||||
## 安装与快速开始
|
## 安装与快速开始
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
|||||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||||
|
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
||||||
|
|||||||
@@ -718,3 +718,23 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
|||||||
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
|
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
var gotOpts *APIOptions
|
||||||
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("--json should be accepted without error, got: %v", err)
|
||||||
|
}
|
||||||
|
if gotOpts.Method != "GET" {
|
||||||
|
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,6 +91,29 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
var gotOpts *CheckOptions
|
||||||
|
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if gotOpts == nil {
|
||||||
|
t.Fatal("expected opts to be set")
|
||||||
|
}
|
||||||
|
if !gotOpts.JSON {
|
||||||
|
t.Error("expected JSON=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
|
||||||
@@ -109,6 +132,27 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
|
||||||
|
var gotOpts *LogoutOptions
|
||||||
|
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if gotOpts == nil {
|
||||||
|
t.Fatal("expected opts to be set")
|
||||||
|
}
|
||||||
|
if !gotOpts.JSON {
|
||||||
|
t.Error("expected JSON=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthListCmd_FlagParsing(t *testing.T) {
|
func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
|
||||||
@@ -126,6 +170,27 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
|
||||||
|
var gotOpts *ListOptions
|
||||||
|
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if gotOpts == nil {
|
||||||
|
t.Error("expected opts to be set")
|
||||||
|
}
|
||||||
|
if !gotOpts.JSON {
|
||||||
|
t.Error("expected JSON=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
@@ -145,6 +210,29 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
var gotOpts *StatusOptions
|
||||||
|
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if gotOpts == nil {
|
||||||
|
t.Error("expected opts to be set")
|
||||||
|
}
|
||||||
|
if !gotOpts.JSON {
|
||||||
|
t.Error("expected JSON=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
@@ -267,6 +355,32 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
var gotOpts *ScopesOptions
|
||||||
|
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"--format", "pretty", "--json"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if gotOpts == nil {
|
||||||
|
t.Fatal("expected opts to be set")
|
||||||
|
}
|
||||||
|
if !gotOpts.JSON {
|
||||||
|
t.Error("expected JSON=true")
|
||||||
|
}
|
||||||
|
if gotOpts.Format != "json" {
|
||||||
|
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
type CheckOptions struct {
|
type CheckOptions struct {
|
||||||
Factory *cmdutil.Factory
|
Factory *cmdutil.Factory
|
||||||
Scope string
|
Scope string
|
||||||
|
JSON bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCmdAuthCheck creates the auth check subcommand.
|
// NewCmdAuthCheck creates the auth check subcommand.
|
||||||
@@ -37,6 +38,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
||||||
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||||
cmd.MarkFlagRequired("scope")
|
cmd.MarkFlagRequired("scope")
|
||||||
cmdutil.SetRisk(cmd, "read")
|
cmdutil.SetRisk(cmd, "read")
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
// ListOptions holds all inputs for auth list.
|
// ListOptions holds all inputs for auth list.
|
||||||
type ListOptions struct {
|
type ListOptions struct {
|
||||||
Factory *cmdutil.Factory
|
Factory *cmdutil.Factory
|
||||||
|
JSON bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCmdAuthList creates the auth list subcommand.
|
// NewCmdAuthList creates the auth list subcommand.
|
||||||
@@ -34,6 +35,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
|||||||
return authListRun(opts)
|
return authListRun(opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||||
cmdutil.SetRisk(cmd, "read")
|
cmdutil.SetRisk(cmd, "read")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -44,6 +46,14 @@ func authListRun(opts *ListOptions) error {
|
|||||||
|
|
||||||
multi, _ := core.LoadMultiAppConfig()
|
multi, _ := core.LoadMultiAppConfig()
|
||||||
if multi == nil || len(multi.Apps) == 0 {
|
if multi == nil || len(multi.Apps) == 0 {
|
||||||
|
if opts.JSON {
|
||||||
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"users": []map[string]interface{}{},
|
||||||
|
"reason": "not_configured",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
// auth list is a read-only probe; the "configured but no users"
|
// auth list is a read-only probe; the "configured but no users"
|
||||||
// branch below already returns exit 0 with a stderr hint, so we
|
// branch below already returns exit 0 with a stderr hint, so we
|
||||||
// keep the same contract here. We still want the hint to be
|
// keep the same contract here. We still want the hint to be
|
||||||
@@ -61,6 +71,14 @@ func authListRun(opts *ListOptions) error {
|
|||||||
|
|
||||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||||
if app == nil || len(app.Users) == 0 {
|
if app == nil || len(app.Users) == 0 {
|
||||||
|
if opts.JSON {
|
||||||
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"users": []map[string]interface{}{},
|
||||||
|
"reason": "not_logged_in",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -34,6 +35,33 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||||
|
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["ok"] != true {
|
||||||
|
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||||
|
}
|
||||||
|
users, ok := payload["users"].([]any)
|
||||||
|
if !ok || len(users) != 0 {
|
||||||
|
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||||
|
}
|
||||||
|
if payload["reason"] != "not_configured" {
|
||||||
|
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
// 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
|
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||||
@@ -57,3 +85,48 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
|
|||||||
t.Errorf("agent hint must not mention config init: %s", out)
|
t.Errorf("agent hint must not mention config init: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
writeLogoutConfig(t, nil)
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||||
|
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["ok"] != true {
|
||||||
|
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||||
|
}
|
||||||
|
users, ok := payload["users"].([]any)
|
||||||
|
if !ok || len(users) != 0 {
|
||||||
|
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||||
|
}
|
||||||
|
if payload["reason"] != "not_logged_in" {
|
||||||
|
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
writeLogoutConfig(t, nil)
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||||
|
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "No logged-in users") {
|
||||||
|
t.Errorf("stderr = %q, want no-users hint", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -296,10 +296,11 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 AgentTimeoutHint as a structured field so agents that
|
||||||
// JSON mode embeds it as a structured field (so an agent that captures
|
// capture stdout into a JSON parser see it without stream-mixing surprises.
|
||||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
// Text mode prints the hint to stderr only when running under a non-TTY
|
||||||
// text mode prints to stderr (alongside the URL prompt).
|
// (i.e. piped / agent harness), since humans reading a terminal don't need
|
||||||
|
// the agent-oriented instructions.
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
"event": "device_authorization",
|
"event": "device_authorization",
|
||||||
@@ -317,7 +318,9 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||||
|
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Poll for token
|
// Step 3: Poll for token
|
||||||
@@ -404,10 +407,11 @@ 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)
|
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
|
// Skip the stderr hint in JSON mode (the --no-wait call that issued
|
||||||
// device_code already returned the hint as a JSON field, and writing
|
// the device_code already surfaced it as a JSON field), and also skip it
|
||||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
// when running on an interactive terminal — the agent-oriented
|
||||||
if !opts.JSON {
|
// instructions only matter for piped / harness environments.
|
||||||
|
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||||
}
|
}
|
||||||
log(msg.WaitingAuth)
|
log(msg.WaitingAuth)
|
||||||
|
|||||||
@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
|||||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||||
// service_descriptions.json.
|
// service_descriptions.json.
|
||||||
func getShortcutOnlyDomainNames() []string {
|
func getShortcutOnlyDomainNames() []string {
|
||||||
return []string{"base", "contact", "docs", "markdown", "apps"}
|
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -214,6 +215,12 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
|
||||||
|
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
|
||||||
|
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCollectScopesForDomains(t *testing.T) {
|
func TestCollectScopesForDomains(t *testing.T) {
|
||||||
projects := registry.ListFromMetaProjects()
|
projects := registry.ListFromMetaProjects()
|
||||||
if len(projects) == 0 {
|
if len(projects) == 0 {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
// LogoutOptions holds all inputs for auth logout.
|
// LogoutOptions holds all inputs for auth logout.
|
||||||
type LogoutOptions struct {
|
type LogoutOptions struct {
|
||||||
Factory *cmdutil.Factory
|
Factory *cmdutil.Factory
|
||||||
|
JSON bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCmdAuthLogout creates the auth logout subcommand.
|
// NewCmdAuthLogout creates the auth logout subcommand.
|
||||||
@@ -34,6 +35,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
|||||||
return authLogoutRun(opts)
|
return authLogoutRun(opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||||
cmdutil.SetRisk(cmd, "write")
|
cmdutil.SetRisk(cmd, "write")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -44,25 +46,65 @@ func authLogoutRun(opts *LogoutOptions) error {
|
|||||||
|
|
||||||
multi, _ := core.LoadMultiAppConfig()
|
multi, _ := core.LoadMultiAppConfig()
|
||||||
if multi == nil || len(multi.Apps) == 0 {
|
if multi == nil || len(multi.Apps) == 0 {
|
||||||
|
if opts.JSON {
|
||||||
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"loggedOut": false,
|
||||||
|
"reason": "not_configured",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
|
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||||
if app == nil || len(app.Users) == 0 {
|
if app == nil || len(app.Users) == 0 {
|
||||||
|
if opts.JSON {
|
||||||
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"loggedOut": false,
|
||||||
|
"reason": "not_logged_in",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
httpClient, httpErr := f.HttpClient()
|
||||||
|
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
|
||||||
|
|
||||||
for _, user := range app.Users {
|
for _, user := range app.Users {
|
||||||
|
if httpErr == nil && secretErr == nil {
|
||||||
|
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
|
||||||
|
revokeToken := token.RefreshToken
|
||||||
|
tokenTypeHint := "refresh_token"
|
||||||
|
if revokeToken == "" {
|
||||||
|
revokeToken = token.AccessToken
|
||||||
|
tokenTypeHint = "access_token"
|
||||||
|
}
|
||||||
|
if revokeToken != "" {
|
||||||
|
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
|
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
|
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Users = []core.AppUser{}
|
app.Users = []core.AppUser{}
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
if opts.JSON {
|
||||||
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"loggedOut": true,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
356
cmd/auth/logout_test.go
Normal file
356
cmd/auth/logout_test.go
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
|
"github.com/zalando/go-keyring"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
|
||||||
|
t.Helper()
|
||||||
|
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||||
|
CurrentApp: "test-app",
|
||||||
|
Apps: []core.AppConfig{
|
||||||
|
{
|
||||||
|
AppId: "test-app",
|
||||||
|
AppSecret: core.PlainSecret("test-secret"),
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
Users: users,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||||
|
t.Fatalf("authLogoutRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["ok"] != true {
|
||||||
|
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||||
|
}
|
||||||
|
if payload["loggedOut"] != false {
|
||||||
|
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||||
|
}
|
||||||
|
if payload["reason"] != "not_configured" {
|
||||||
|
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
writeLogoutConfig(t, nil)
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||||
|
t.Fatalf("authLogoutRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["ok"] != true {
|
||||||
|
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||||
|
}
|
||||||
|
if payload["loggedOut"] != false {
|
||||||
|
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||||
|
}
|
||||||
|
if payload["reason"] != "not_logged_in" {
|
||||||
|
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
|
||||||
|
keyring.MockInit()
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||||
|
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||||
|
AppId: "test-app",
|
||||||
|
UserOpenId: "ou_user",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetStoredToken() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||||
|
t.Fatalf("authLogoutRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["ok"] != true {
|
||||||
|
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||||
|
}
|
||||||
|
if payload["loggedOut"] != true {
|
||||||
|
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
|
||||||
|
}
|
||||||
|
if _, hasReason := payload["reason"]; hasReason {
|
||||||
|
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
|
||||||
|
keyring.MockInit()
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||||
|
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||||
|
AppId: "test-app",
|
||||||
|
UserOpenId: "ou_user",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetStoredToken() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||||
|
t.Fatalf("authLogoutRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "Logged out") {
|
||||||
|
t.Errorf("stderr = %q, want success text", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
|
||||||
|
keyring.MockInit()
|
||||||
|
setupLoginConfigDir(t)
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
|
||||||
|
multi := &core.MultiAppConfig{
|
||||||
|
CurrentApp: "default",
|
||||||
|
Apps: []core.AppConfig{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
AppId: "cli_test",
|
||||||
|
AppSecret: core.PlainSecret("secret"),
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||||
|
AppId: "cli_test",
|
||||||
|
UserOpenId: "ou_user",
|
||||||
|
AccessToken: "user-access-token",
|
||||||
|
RefreshToken: "user-refresh-token",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetStoredToken() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
ProfileName: "default",
|
||||||
|
AppID: "cli_test",
|
||||||
|
AppSecret: "secret",
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: larkauth.PathOAuthRevoke,
|
||||||
|
Body: map[string]interface{}{"code": 0},
|
||||||
|
BodyFilter: func(body []byte) bool {
|
||||||
|
values, err := url.ParseQuery(string(body))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return values.Get("client_id") == "cli_test" &&
|
||||||
|
values.Get("client_secret") == "secret" &&
|
||||||
|
values.Get("token") == "user-refresh-token" &&
|
||||||
|
values.Get("token_type_hint") == "refresh_token"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||||
|
t.Fatalf("authLogoutRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||||
|
t.Fatalf("stderr = %q, want Logged out", got)
|
||||||
|
}
|
||||||
|
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||||
|
t.Fatalf("expected stored token removed, got %#v", got)
|
||||||
|
}
|
||||||
|
saved, err := core.LoadMultiAppConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||||
|
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
|
||||||
|
keyring.MockInit()
|
||||||
|
setupLoginConfigDir(t)
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
|
||||||
|
multi := &core.MultiAppConfig{
|
||||||
|
CurrentApp: "default",
|
||||||
|
Apps: []core.AppConfig{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
AppId: "cli_test",
|
||||||
|
AppSecret: core.PlainSecret("secret"),
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||||
|
AppId: "cli_test",
|
||||||
|
UserOpenId: "ou_user",
|
||||||
|
AccessToken: "user-access-token",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetStoredToken() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
ProfileName: "default",
|
||||||
|
AppID: "cli_test",
|
||||||
|
AppSecret: "secret",
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: larkauth.PathOAuthRevoke,
|
||||||
|
Body: map[string]interface{}{"code": 0},
|
||||||
|
BodyFilter: func(body []byte) bool {
|
||||||
|
values, err := url.ParseQuery(string(body))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return values.Get("client_id") == "cli_test" &&
|
||||||
|
values.Get("client_secret") == "secret" &&
|
||||||
|
values.Get("token") == "user-access-token" &&
|
||||||
|
values.Get("token_type_hint") == "access_token"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||||
|
t.Fatalf("authLogoutRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||||
|
t.Fatalf("stderr = %q, want Logged out", got)
|
||||||
|
}
|
||||||
|
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||||
|
t.Fatalf("expected stored token removed, got %#v", got)
|
||||||
|
}
|
||||||
|
saved, err := core.LoadMultiAppConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||||
|
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
|
||||||
|
keyring.MockInit()
|
||||||
|
setupLoginConfigDir(t)
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
|
||||||
|
multi := &core.MultiAppConfig{
|
||||||
|
CurrentApp: "default",
|
||||||
|
Apps: []core.AppConfig{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
AppId: "cli_test",
|
||||||
|
AppSecret: core.PlainSecret("secret"),
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||||
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||||
|
AppId: "cli_test",
|
||||||
|
UserOpenId: "ou_user",
|
||||||
|
AccessToken: "user-access-token",
|
||||||
|
RefreshToken: "user-refresh-token",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetStoredToken() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
ProfileName: "default",
|
||||||
|
AppID: "cli_test",
|
||||||
|
AppSecret: "secret",
|
||||||
|
Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: larkauth.PathOAuthRevoke,
|
||||||
|
Status: 500,
|
||||||
|
Body: map[string]interface{}{"error": "server_error"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||||
|
t.Fatalf("authLogoutRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotErr := stderr.String()
|
||||||
|
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
|
||||||
|
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotErr, "Logged out") {
|
||||||
|
t.Fatalf("stderr = %q, want Logged out", gotErr)
|
||||||
|
}
|
||||||
|
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||||
|
t.Fatalf("expected stored token removed, got %#v", got)
|
||||||
|
}
|
||||||
|
saved, err := core.LoadMultiAppConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||||
|
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ type ScopesOptions struct {
|
|||||||
Factory *cmdutil.Factory
|
Factory *cmdutil.Factory
|
||||||
Ctx context.Context
|
Ctx context.Context
|
||||||
Format string
|
Format string
|
||||||
|
JSON bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCmdAuthScopes creates the auth scopes subcommand.
|
// NewCmdAuthScopes creates the auth scopes subcommand.
|
||||||
@@ -30,6 +31,9 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
|||||||
Short: "Query scopes enabled for the app",
|
Short: "Query scopes enabled for the app",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
opts.Ctx = cmd.Context()
|
opts.Ctx = cmd.Context()
|
||||||
|
if opts.JSON {
|
||||||
|
opts.Format = "json"
|
||||||
|
}
|
||||||
if runF != nil {
|
if runF != nil {
|
||||||
return runF(opts)
|
return runF(opts)
|
||||||
}
|
}
|
||||||
@@ -38,6 +42,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||||
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||||
cmdutil.SetRisk(cmd, "read")
|
cmdutil.SetRisk(cmd, "read")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
type StatusOptions struct {
|
type StatusOptions struct {
|
||||||
Factory *cmdutil.Factory
|
Factory *cmdutil.Factory
|
||||||
Verify bool
|
Verify bool
|
||||||
|
JSON bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCmdAuthStatus creates the auth status subcommand.
|
// NewCmdAuthStatus creates the auth status subcommand.
|
||||||
@@ -35,6 +36,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
||||||
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||||
cmdutil.SetRisk(cmd, "read")
|
cmdutil.SetRisk(cmd, "read")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
23
cmd/build.go
23
cmd/build.go
@@ -6,6 +6,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
"github.com/larksuite/cli/cmd/api"
|
"github.com/larksuite/cli/cmd/api"
|
||||||
"github.com/larksuite/cli/cmd/auth"
|
"github.com/larksuite/cli/cmd/auth"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"github.com/larksuite/cli/cmd/profile"
|
"github.com/larksuite/cli/cmd/profile"
|
||||||
"github.com/larksuite/cli/cmd/schema"
|
"github.com/larksuite/cli/cmd/schema"
|
||||||
"github.com/larksuite/cli/cmd/service"
|
"github.com/larksuite/cli/cmd/service"
|
||||||
|
"github.com/larksuite/cli/cmd/skill"
|
||||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||||
_ "github.com/larksuite/cli/events"
|
_ "github.com/larksuite/cli/events"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
@@ -51,6 +53,18 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
|
||||||
|
// at build time. It is registered by the repo-root package main's init via
|
||||||
|
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
|
||||||
|
// breaking the single-file preview build (see skills_embed.go). nil in builds
|
||||||
|
// that embed no skills; the `skills` commands then return a typed internal error.
|
||||||
|
var embeddedSkillContent fs.FS
|
||||||
|
|
||||||
|
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
|
||||||
|
// repo-root package main's init; a wrapper main can call it before Execute to
|
||||||
|
// supply its own skill content.
|
||||||
|
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||||
|
|
||||||
// HideProfile sets the visibility policy for the root-level --profile flag.
|
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||||
// When hide is true the flag stays registered (so existing invocations still
|
// When hide is true the flag stays registered (so existing invocations still
|
||||||
// parse) but is omitted from help and shell completion. Typically called as
|
// parse) but is omitted from help and shell completion. Typically called as
|
||||||
@@ -103,6 +117,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
if cfg.keychain != nil {
|
if cfg.keychain != nil {
|
||||||
f.Keychain = cfg.keychain
|
f.Keychain = cfg.keychain
|
||||||
}
|
}
|
||||||
|
f.SkillContent = embeddedSkillContent
|
||||||
rootCmd := &cobra.Command{
|
rootCmd := &cobra.Command{
|
||||||
Use: "lark-cli",
|
Use: "lark-cli",
|
||||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||||
@@ -117,6 +132,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
|
|
||||||
installTipsHelpFunc(rootCmd)
|
installTipsHelpFunc(rootCmd)
|
||||||
rootCmd.SilenceErrors = true
|
rootCmd.SilenceErrors = true
|
||||||
|
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||||
|
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
|
||||||
|
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
|
||||||
|
// inherited by every subcommand, turning unknown-flag errors into a
|
||||||
|
// structured "did you mean" envelope.
|
||||||
|
rootCmd.SilenceUsage = true
|
||||||
|
rootCmd.SetFlagErrorFunc(flagDidYouMean)
|
||||||
|
|
||||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
||||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||||
@@ -133,6 +155,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||||
|
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||||
|
|
||||||
|
|||||||
160
cmd/cmdexample_catalog_test.go
Normal file
160
cmd/cmdexample_catalog_test.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// universalFlags are accepted by every command (cobra auto-injects help; the
|
||||||
|
// root injects version). They are never reported as unknown.
|
||||||
|
var universalFlags = map[string]bool{"--help": true, "-h": true, "--version": true}
|
||||||
|
|
||||||
|
// catalog is the source-of-truth command catalog: command path -> accepted flag
|
||||||
|
// tokens. A path is the command words WITHOUT the "lark-cli" root prefix, e.g.
|
||||||
|
// "contact +search-user". The root command is the empty path "".
|
||||||
|
type catalog struct {
|
||||||
|
flagsByPath map[string]map[string]bool
|
||||||
|
group map[string]bool // paths that are parent groups (have subcommands)
|
||||||
|
sorted []string // cached sorted paths for suggestCommand; invalidated on addCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCatalog() *catalog {
|
||||||
|
return &catalog{
|
||||||
|
flagsByPath: map[string]map[string]bool{},
|
||||||
|
group: map[string]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setGroup records whether path is a parent group (has subcommands). Leftover
|
||||||
|
// words after a group node are unknown subcommands; after a leaf they are
|
||||||
|
// positionals (e.g. "api GET /path").
|
||||||
|
func (c *catalog) setGroup(path string, isGroup bool) {
|
||||||
|
if isGroup {
|
||||||
|
c.group[path] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *catalog) isGroup(path string) bool { return c.group[path] }
|
||||||
|
|
||||||
|
// addCommand registers a command path and the flags it accepts. Repeated calls
|
||||||
|
// for the same path union the flag sets. flags are full tokens ("--query", "-q").
|
||||||
|
func (c *catalog) addCommand(path string, flags []string) {
|
||||||
|
set := c.flagsByPath[path]
|
||||||
|
if set == nil {
|
||||||
|
set = map[string]bool{}
|
||||||
|
c.flagsByPath[path] = set
|
||||||
|
}
|
||||||
|
for _, f := range flags {
|
||||||
|
set[f] = true
|
||||||
|
}
|
||||||
|
c.sorted = nil // invalidate cached suggestion list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *catalog) hasCommand(path string) bool {
|
||||||
|
_, ok := c.flagsByPath[path]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasFlag reports whether flag is accepted by command path (universal flags
|
||||||
|
// always pass).
|
||||||
|
func (c *catalog) hasFlag(path, flag string) bool {
|
||||||
|
if universalFlags[flag] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
set := c.flagsByPath[path]
|
||||||
|
return set[flag]
|
||||||
|
}
|
||||||
|
|
||||||
|
// longestPrefix returns the longest known command path that is a prefix of
|
||||||
|
// words, plus how many words it consumed. This separates real subcommands from
|
||||||
|
// trailing positionals (e.g. "api GET /path" resolves to "api"). When words is
|
||||||
|
// empty it falls back to the root command. ok=false means not even the first
|
||||||
|
// word names a command.
|
||||||
|
func (c *catalog) longestPrefix(words []string) (path string, n int, ok bool) {
|
||||||
|
if len(words) == 0 {
|
||||||
|
if c.hasCommand("") {
|
||||||
|
return "", 0, true
|
||||||
|
}
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
for i := len(words); i >= 1; i-- {
|
||||||
|
cand := strings.Join(words[:i], " ")
|
||||||
|
if c.hasCommand(cand) {
|
||||||
|
return cand, i, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// paths returns all known command paths, sorted.
|
||||||
|
func (c *catalog) paths() []string {
|
||||||
|
out := make([]string, 0, len(c.flagsByPath))
|
||||||
|
for p := range c.flagsByPath {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggestCommand returns the known command path closest to want (small edit
|
||||||
|
// distance), for error hints. Returns "" when nothing is reasonably close.
|
||||||
|
func (c *catalog) suggestCommand(want string) string {
|
||||||
|
if c.sorted == nil {
|
||||||
|
c.sorted = c.paths() // built once after the catalog is fully populated
|
||||||
|
}
|
||||||
|
return closest(want, c.sorted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggestFlag returns the flag of path closest to flag, for error hints.
|
||||||
|
func (c *catalog) suggestFlag(path, flag string) string {
|
||||||
|
set := c.flagsByPath[path]
|
||||||
|
cands := make([]string, 0, len(set))
|
||||||
|
for f := range set {
|
||||||
|
cands = append(cands, f)
|
||||||
|
}
|
||||||
|
sort.Strings(cands)
|
||||||
|
return closest(flag, cands)
|
||||||
|
}
|
||||||
|
|
||||||
|
// closest returns the candidate with the smallest Levenshtein distance to want,
|
||||||
|
// but only if that distance is within a tolerance scaled to want's length
|
||||||
|
// (avoids absurd suggestions).
|
||||||
|
func closest(want string, cands []string) string {
|
||||||
|
best := ""
|
||||||
|
bestD := 1 << 30
|
||||||
|
for _, cand := range cands {
|
||||||
|
d := levenshtein(want, cand)
|
||||||
|
if d < bestD {
|
||||||
|
bestD, best = d, cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tol := len(want)/2 + 1
|
||||||
|
if bestD > tol {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func levenshtein(a, b string) int {
|
||||||
|
ra, rb := []rune(a), []rune(b)
|
||||||
|
prev := make([]int, len(rb)+1)
|
||||||
|
for j := range prev {
|
||||||
|
prev[j] = j
|
||||||
|
}
|
||||||
|
for i := 1; i <= len(ra); i++ {
|
||||||
|
cur := make([]int, len(rb)+1)
|
||||||
|
cur[0] = i
|
||||||
|
for j := 1; j <= len(rb); j++ {
|
||||||
|
cost := 1
|
||||||
|
if ra[i-1] == rb[j-1] {
|
||||||
|
cost = 0
|
||||||
|
}
|
||||||
|
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
|
||||||
|
}
|
||||||
|
prev = cur
|
||||||
|
}
|
||||||
|
return prev[len(rb)]
|
||||||
|
}
|
||||||
60
cmd/cmdexample_check_test.go
Normal file
60
cmd/cmdexample_check_test.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd_test
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Finding kinds.
|
||||||
|
const (
|
||||||
|
unknownCommand = "unknown_command"
|
||||||
|
unknownFlag = "unknown_flag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// finding is a single mismatch between an example command reference and the
|
||||||
|
// catalog.
|
||||||
|
type finding struct {
|
||||||
|
line int
|
||||||
|
raw string
|
||||||
|
kind string // unknownCommand | unknownFlag
|
||||||
|
path string // resolved command path (unknownFlag) or attempted path (unknownCommand)
|
||||||
|
flag string // offending flag (unknownFlag only)
|
||||||
|
suggest string // nearest known command/flag, "" if none close
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRefs validates refs against cat and returns all mismatches in order.
|
||||||
|
func checkRefs(cat *catalog, refs []ref) []finding {
|
||||||
|
var out []finding
|
||||||
|
for _, r := range refs {
|
||||||
|
path, n, ok := cat.longestPrefix(r.words)
|
||||||
|
if !ok {
|
||||||
|
attempted := strings.Join(r.words, " ")
|
||||||
|
out = append(out, finding{
|
||||||
|
line: r.line, raw: r.raw, kind: unknownCommand,
|
||||||
|
path: attempted, suggest: cat.suggestCommand(attempted),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Leftover words after a group node are an unknown subcommand (e.g. a
|
||||||
|
// mistyped method like "batch_modify_message"). After a leaf they are
|
||||||
|
// positionals (e.g. "api GET /path"), so only groups trigger this.
|
||||||
|
if n < len(r.words) && cat.isGroup(path) {
|
||||||
|
attempted := strings.Join(r.words, " ")
|
||||||
|
out = append(out, finding{
|
||||||
|
line: r.line, raw: r.raw, kind: unknownCommand,
|
||||||
|
path: attempted, suggest: cat.suggestCommand(attempted),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, f := range r.flags {
|
||||||
|
if cat.hasFlag(path, f) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, finding{
|
||||||
|
line: r.line, raw: r.raw, kind: unknownFlag,
|
||||||
|
path: path, flag: f, suggest: cat.suggestFlag(path, f),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
222
cmd/cmdexample_parse_test.go
Normal file
222
cmd/cmdexample_parse_test.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ref is one lark-cli command reference extracted from a shortcut example.
|
||||||
|
type ref struct {
|
||||||
|
line int // 1-based line number (the line where the command starts)
|
||||||
|
raw string // reconstructed command text, for error display
|
||||||
|
words []string // command words before the first flag (subcommand candidates)
|
||||||
|
flags []string // flag tokens used, e.g. "--query", "-q"
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliToken = "lark-cli"
|
||||||
|
|
||||||
|
// subcommandStart guards against false positives from prose: a real command's
|
||||||
|
// first word is ASCII (a service name or a +shortcut). A token starting with
|
||||||
|
// CJK / punctuation is treated as narration, not a command.
|
||||||
|
var subcommandStart = regexp.MustCompile(`^[A-Za-z+]`)
|
||||||
|
|
||||||
|
// shellStops are standalone tokens that terminate a command (pipes, redirects,
|
||||||
|
// separators). Separators glued to a token (`get;`, `foo|`) are handled inline.
|
||||||
|
var shellStops = map[string]bool{
|
||||||
|
"|": true, "||": true, "&&": true, "&": true, ";": true,
|
||||||
|
">": true, ">>": true, "<": true, "2>": true, "2>&1": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordTrailPunct is sentence / CJK punctuation that can cling to a command word
|
||||||
|
// in prose ("auth login." / "auth login,"); stripped so the word still resolves
|
||||||
|
// instead of being dropped as an unknown command or non-ASCII narration.
|
||||||
|
const wordTrailPunct = `.,;:!?"')]},。、;:!?)】」』`
|
||||||
|
|
||||||
|
// parseRefs extracts every lark-cli command reference from text (a shortcut's
|
||||||
|
// Tips line, which may embed an "Example: lark-cli ..." command). It is
|
||||||
|
// deliberately format-agnostic: it keys on the "lark-cli" token whether it sits
|
||||||
|
// in a ```bash fence, an inline `code` span, or bare prose. Backslash
|
||||||
|
// line-continuations are joined first so a multi-line invocation is parsed as
|
||||||
|
// one command; inline-code backticks and trailing # comments terminate it.
|
||||||
|
func parseRefs(content string) []ref {
|
||||||
|
var refs []ref
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
for i := 0; i < len(lines); i++ {
|
||||||
|
lineNo := i + 1
|
||||||
|
logical := lines[i]
|
||||||
|
// Shell line continuation: a trailing backslash joins the next physical
|
||||||
|
// line. Without this, flags on the continuation lines of a multi-line
|
||||||
|
// `lark-cli ... \` example are never seen by the checker.
|
||||||
|
for endsWithBackslash(logical) && i+1 < len(lines) {
|
||||||
|
logical = strings.TrimRight(logical, " \t")
|
||||||
|
logical = logical[:len(logical)-1] // drop the trailing backslash
|
||||||
|
i++
|
||||||
|
logical += " " + lines[i]
|
||||||
|
}
|
||||||
|
refs = append(refs, parseLine(logical, lineNo)...)
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
func endsWithBackslash(s string) bool {
|
||||||
|
return strings.HasSuffix(strings.TrimRight(s, " \t"), `\`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLine(line string, lineNo int) []ref {
|
||||||
|
var refs []ref
|
||||||
|
rest := line
|
||||||
|
for {
|
||||||
|
idx := strings.Index(rest, cliToken)
|
||||||
|
if idx < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
after := rest[idx+len(cliToken):]
|
||||||
|
beforeOK := idx == 0 || isBoundary(rest[idx-1])
|
||||||
|
afterOK := after == "" || isBoundary(after[0])
|
||||||
|
if beforeOK && afterOK {
|
||||||
|
if words, flags, raw, ok := parseCmd(after); ok {
|
||||||
|
refs = append(refs, ref{line: lineNo, raw: cliToken + raw, words: words, flags: flags})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest = after
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCmd tokenizes the text following "lark-cli" into leading command words
|
||||||
|
// (the subcommand path, up to the first flag) and flag tokens. It stops at a
|
||||||
|
// shell separator (standalone or glued), an inline-code backtick, a comment, or
|
||||||
|
// a placeholder/prose word. ok=false filters out non-commands.
|
||||||
|
func parseCmd(after string) (words, flags []string, raw string, ok bool) {
|
||||||
|
// An inline code span ends at the next backtick; a command never spans one.
|
||||||
|
if i := strings.IndexByte(after, '`'); i >= 0 {
|
||||||
|
after = after[:i]
|
||||||
|
}
|
||||||
|
// Drop $(...) command substitutions so flags belonging to the inner command
|
||||||
|
// (e.g. `--data "$(jq -n --arg x ...)"`) are not mistaken for lark-cli flags.
|
||||||
|
after = stripCmdSubst(after)
|
||||||
|
|
||||||
|
var kept []string
|
||||||
|
inFlags := false
|
||||||
|
for _, orig := range strings.Fields(after) {
|
||||||
|
tok := orig
|
||||||
|
if shellStops[tok] || strings.HasPrefix(tok, "#") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// A shell separator glued to a token ends the command mid-token
|
||||||
|
// ("get;", "foo|next"): keep the part before it, handle it, then stop.
|
||||||
|
stop := false
|
||||||
|
if i := strings.IndexAny(tok, ";|"); i >= 0 {
|
||||||
|
tok, stop = tok[:i], true
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case tok == "" || tok == "-":
|
||||||
|
// empty (after a glued separator) or a bare stdin marker — skip
|
||||||
|
case strings.HasPrefix(tok, "-"):
|
||||||
|
if f := normalizeFlag(tok); f != "" {
|
||||||
|
inFlags = true
|
||||||
|
flags = append(flags, f)
|
||||||
|
kept = append(kept, tok)
|
||||||
|
}
|
||||||
|
case inFlags:
|
||||||
|
// positional / flag value after the first flag — not a command word
|
||||||
|
kept = append(kept, tok)
|
||||||
|
default:
|
||||||
|
// Command-path word. ASCII placeholder markers (<x>, [x], {x|y},
|
||||||
|
// +<verb>, ...) end the command — checked on the RAW token so the
|
||||||
|
// trailing-punct stripping below cannot erase a "..." ellipsis
|
||||||
|
// ("base +..." must stay a placeholder, not become "+").
|
||||||
|
if strings.ContainsAny(tok, "<>[]{}|") || strings.Contains(tok, "...") {
|
||||||
|
stop = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Strip trailing sentence/CJK punctuation so "login." / "login,"
|
||||||
|
// resolve to "login"; non-ASCII narration ends the command.
|
||||||
|
w := strings.TrimRight(tok, wordTrailPunct)
|
||||||
|
if w == "" || hasNonASCII(w) {
|
||||||
|
stop = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
words = append(words, w)
|
||||||
|
kept = append(kept, tok)
|
||||||
|
}
|
||||||
|
if stop {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) > 0 {
|
||||||
|
raw = " " + strings.Join(kept, " ")
|
||||||
|
}
|
||||||
|
// Keep root-only refs ("lark-cli --help") and refs whose first word looks
|
||||||
|
// like a subcommand; drop prose ("lark-cli 就能搞定 ...").
|
||||||
|
if len(words) == 0 {
|
||||||
|
return words, flags, raw, len(flags) > 0
|
||||||
|
}
|
||||||
|
if !subcommandStart.MatchString(words[0]) {
|
||||||
|
return nil, nil, "", false
|
||||||
|
}
|
||||||
|
return words, flags, raw, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripCmdSubst removes $(...) command substitutions (including nested ones)
|
||||||
|
// from s, leaving the surrounding text intact. Backtick substitutions are
|
||||||
|
// already handled upstream (a command never spans a backtick).
|
||||||
|
func stripCmdSubst(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
depth := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if depth == 0 && i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
|
||||||
|
depth = 1
|
||||||
|
i++ // skip '('
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if depth > 0 {
|
||||||
|
switch s[i] {
|
||||||
|
case '(':
|
||||||
|
depth++
|
||||||
|
case ')':
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPlaceholderOrProse reports whether a command word is a doc placeholder
|
||||||
|
// (<resource>, [flags], {a|b}, +<verb>, ...) or narration (CJK / other
|
||||||
|
// non-ASCII), rather than a literal command token.
|
||||||
|
func isPlaceholderOrProse(w string) bool {
|
||||||
|
if hasNonASCII(w) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.ContainsAny(w, "<>[]{}|") || strings.Contains(w, "...")
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasNonASCII(s string) bool {
|
||||||
|
return strings.IndexFunc(s, func(r rune) bool { return r > 127 }) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// flagShape matches the leading flag token, stripping any trailing junk such as
|
||||||
|
// a "=value" suffix or punctuation that bled in from the surrounding markdown
|
||||||
|
// ("--help\"", "--help;", "--params={}"). The underscore is allowed because
|
||||||
|
// real flags use it ("--input_format", "--output_as"). Returns "" for non-flags.
|
||||||
|
var flagShape = regexp.MustCompile(`^--?[A-Za-z][A-Za-z0-9_-]*`)
|
||||||
|
|
||||||
|
// normalizeFlag extracts the canonical flag token from tok, or "" if tok is not
|
||||||
|
// a real flag (e.g. a shell-string fragment like "-草稿'").
|
||||||
|
func normalizeFlag(tok string) string {
|
||||||
|
return flagShape.FindString(tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBoundary(b byte) bool {
|
||||||
|
switch b {
|
||||||
|
case ' ', '\t', '`', '(', ')', '\'', '"', '*':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
113
cmd/cmdexample_test.go
Normal file
113
cmd/cmdexample_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// This file and its cmdexample_*_test.go siblings implement a test-only check:
|
||||||
|
// the example commands embedded in shortcut definitions (the "Example: lark-cli
|
||||||
|
// ..." lines in each shortcut's Tips, shown in --help) must match the real
|
||||||
|
// command tree. It lives entirely in _test.go files (package cmd_test) so it
|
||||||
|
// ships in no binary and is not importable by product code; the truth source is
|
||||||
|
// cmd.Build, the same tree the binary uses, so the check cannot drift.
|
||||||
|
//
|
||||||
|
// It runs in the standard unit-test CI job (go test ./cmd/...). A mismatch — an
|
||||||
|
// example using a renamed command or an unaccepted flag — fails that job.
|
||||||
|
|
||||||
|
package cmd_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/cmd"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/shortcuts"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestShortcutExampleCommands checks the example commands embedded in every
|
||||||
|
// shortcut's Tips against the live command tree. A shortcut that defines no
|
||||||
|
// example is simply skipped.
|
||||||
|
//
|
||||||
|
// Because the examples and the command definitions live in the same Go code,
|
||||||
|
// this is a self-consistency check: any mismatch (an example using a renamed
|
||||||
|
// command or a flag the command doesn't accept) is a bug to fix at the source.
|
||||||
|
// It runs over all shortcuts — no baseline, no diff — since a wrong example is
|
||||||
|
// always a defect, never acceptable "pre-existing drift".
|
||||||
|
func TestShortcutExampleCommands(t *testing.T) {
|
||||||
|
// Reproducibility: use the embedded API metadata (not a developer's stale
|
||||||
|
// ~/.lark-cli remote cache, which can miss commands) and an empty config
|
||||||
|
// dir so local strict mode / plugins / policy cannot reshape the tree.
|
||||||
|
// t.Setenv auto-restores after the test, so other cmd tests are unaffected.
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
cat := buildCmdExampleCatalog()
|
||||||
|
|
||||||
|
type located struct {
|
||||||
|
shortcut string
|
||||||
|
f finding
|
||||||
|
}
|
||||||
|
var findings []located
|
||||||
|
for _, sc := range shortcuts.AllShortcuts() {
|
||||||
|
var refs []ref
|
||||||
|
for _, tip := range sc.Tips {
|
||||||
|
refs = append(refs, parseRefs(tip)...)
|
||||||
|
}
|
||||||
|
label := strings.TrimSpace(sc.Service + " " + sc.Command)
|
||||||
|
for _, f := range checkRefs(cat, refs) {
|
||||||
|
findings = append(findings, located{shortcut: label, f: f})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(findings) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Slice(findings, func(i, j int) bool { return findings[i].shortcut < findings[j].shortcut })
|
||||||
|
for _, lf := range findings {
|
||||||
|
hint := ""
|
||||||
|
if lf.f.suggest != "" {
|
||||||
|
hint = " (did you mean " + lf.f.suggest + "?)"
|
||||||
|
}
|
||||||
|
if lf.f.kind == unknownFlag {
|
||||||
|
t.Errorf("shortcut %q example uses unknown flag %s on %q%s\n %s",
|
||||||
|
lf.shortcut, lf.f.flag, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
|
||||||
|
} else {
|
||||||
|
t.Errorf("shortcut %q example uses unknown command %q%s\n %s",
|
||||||
|
lf.shortcut, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("%d shortcut example command(s) don't match the real CLI — "+
|
||||||
|
"fix the Example in the shortcut definition.", len(findings))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCmdExampleCatalog walks the live cobra command tree and records every
|
||||||
|
// command path (minus the "lark-cli" root prefix) with its accepted flags and
|
||||||
|
// whether it is a parent group. This is the same Build() the binary uses, so
|
||||||
|
// the catalog can never drift from the real commands.
|
||||||
|
func buildCmdExampleCatalog() *catalog {
|
||||||
|
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
|
||||||
|
cat := newCatalog()
|
||||||
|
var walk func(c *cobra.Command)
|
||||||
|
walk = func(c *cobra.Command) {
|
||||||
|
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
|
||||||
|
var flags []string
|
||||||
|
add := func(fl *pflag.Flag) {
|
||||||
|
flags = append(flags, "--"+fl.Name)
|
||||||
|
if fl.Shorthand != "" {
|
||||||
|
flags = append(flags, "-"+fl.Shorthand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Flags().VisitAll(add)
|
||||||
|
c.InheritedFlags().VisitAll(add)
|
||||||
|
c.PersistentFlags().VisitAll(add) // root's own persistent flags (e.g. --profile)
|
||||||
|
cat.addCommand(path, flags)
|
||||||
|
cat.setGroup(path, c.HasSubCommands())
|
||||||
|
for _, sub := range c.Commands() {
|
||||||
|
walk(sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(root)
|
||||||
|
return cat
|
||||||
|
}
|
||||||
233
cmd/cmdexample_units_test.go
Normal file
233
cmd/cmdexample_units_test.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testCatalog() *catalog {
|
||||||
|
c := newCatalog()
|
||||||
|
c.addCommand("", []string{"--profile"}) // root
|
||||||
|
c.setGroup("", true)
|
||||||
|
c.addCommand("contact", []string{"--profile"})
|
||||||
|
c.setGroup("contact", true)
|
||||||
|
c.addCommand("contact +search-user", []string{"--query", "--as", "--format", "-q"})
|
||||||
|
c.addCommand("api", []string{"--params", "--data", "--as"}) // leaf (no subcommands)
|
||||||
|
c.addCommand("mail", nil)
|
||||||
|
c.setGroup("mail", true)
|
||||||
|
c.addCommand("mail user_mailbox.messages", []string{"--profile"})
|
||||||
|
c.setGroup("mail user_mailbox.messages", true)
|
||||||
|
c.addCommand("mail user_mailbox.messages batch_modify", []string{"--params", "--data"})
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExampleCatalogHasCommandAndFlag(t *testing.T) {
|
||||||
|
c := testCatalog()
|
||||||
|
if !c.hasCommand("contact +search-user") {
|
||||||
|
t.Fatal("expected contact +search-user to exist")
|
||||||
|
}
|
||||||
|
if c.hasCommand("contact +nope") {
|
||||||
|
t.Fatal("did not expect contact +nope")
|
||||||
|
}
|
||||||
|
if !c.hasFlag("contact +search-user", "--query") {
|
||||||
|
t.Fatal("--query should be valid")
|
||||||
|
}
|
||||||
|
if c.hasFlag("contact +search-user", "--nope") {
|
||||||
|
t.Fatal("--nope should be invalid")
|
||||||
|
}
|
||||||
|
// universal flags pass on any command
|
||||||
|
for _, f := range []string{"--help", "-h", "--version"} {
|
||||||
|
if !c.hasFlag("contact +search-user", f) {
|
||||||
|
t.Fatalf("universal flag %s should pass", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExampleLongestPrefix(t *testing.T) {
|
||||||
|
c := testCatalog()
|
||||||
|
tests := []struct {
|
||||||
|
words []string
|
||||||
|
want string
|
||||||
|
wantN int
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{[]string{"contact", "+search-user"}, "contact +search-user", 2, true},
|
||||||
|
{[]string{"api", "GET", "/open-apis/x"}, "api", 1, true}, // trailing positionals
|
||||||
|
{[]string{"nope"}, "", 0, false},
|
||||||
|
{nil, "", 0, true}, // empty -> root
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, n, ok := c.longestPrefix(tt.words)
|
||||||
|
if got != tt.want || n != tt.wantN || ok != tt.wantOK {
|
||||||
|
t.Errorf("longestPrefix(%v) = (%q,%d,%v), want (%q,%d,%v)",
|
||||||
|
tt.words, got, n, ok, tt.want, tt.wantN, tt.wantOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refWordsOf(refs []ref) [][]string {
|
||||||
|
var out [][]string
|
||||||
|
for _, r := range refs {
|
||||||
|
out = append(out, r.words)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExampleParseRefsExtractsCommands(t *testing.T) {
|
||||||
|
content := strings.Join([]string{
|
||||||
|
"运行 `lark-cli contact +search-user --query 张三` 搜索", // inline code
|
||||||
|
"```bash",
|
||||||
|
"lark-cli api GET /open-apis/x --params '{}'", // bash block
|
||||||
|
"```",
|
||||||
|
"用 lark-cli mail user_mailbox.messages batch_modify 即可", // bare prose command
|
||||||
|
"npx foo | lark-cli api GET /y", // after a pipe
|
||||||
|
}, "\n")
|
||||||
|
refs := parseRefs(content)
|
||||||
|
if len(refs) != 4 {
|
||||||
|
t.Fatalf("expected 4 refs, got %d: %v", len(refs), refWordsOf(refs))
|
||||||
|
}
|
||||||
|
if got := refs[0]; strings.Join(got.words, " ") != "contact +search-user" ||
|
||||||
|
len(got.flags) != 1 || got.flags[0] != "--query" {
|
||||||
|
t.Errorf("ref0 = %+v", got)
|
||||||
|
}
|
||||||
|
if got := refs[1]; strings.Join(got.words, " ") != "api GET /open-apis/x" {
|
||||||
|
t.Errorf("ref1 words = %v", got.words)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExampleParseRefsFiltersPlaceholdersAndProse(t *testing.T) {
|
||||||
|
// A line whose first word is prose yields no command at all.
|
||||||
|
if refs := parseRefs("lark-cli 就能搞定这件事"); len(refs) != 0 {
|
||||||
|
t.Errorf("prose-first line should yield 0 refs, got %v", refWordsOf(refs))
|
||||||
|
}
|
||||||
|
// Syntax templates / trailing prose may leave a real leading word ("mail"),
|
||||||
|
// but no placeholder or CJK token may leak into the command words — that is
|
||||||
|
// what prevents false positives like an "<resource>" unknown-command report.
|
||||||
|
for _, line := range []string{
|
||||||
|
"lark-cli mail <resource> <method> [flags]",
|
||||||
|
"lark-cli apps +<verb> [flags]",
|
||||||
|
"lark-cli base +...",
|
||||||
|
"lark-cli mail 写信场景下的格式说明",
|
||||||
|
} {
|
||||||
|
for _, r := range parseRefs(line) {
|
||||||
|
for _, w := range r.words {
|
||||||
|
if isPlaceholderOrProse(w) {
|
||||||
|
t.Errorf("%q: placeholder/prose token %q leaked into words %v", line, w, r.words)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExampleParseRefsStripsTrailingJunk(t *testing.T) {
|
||||||
|
// frontmatter-style quoted value: the trailing quote must not bleed into the flag
|
||||||
|
refs := parseRefs(`cliHelp: "lark-cli contact --help"`)
|
||||||
|
if len(refs) != 1 {
|
||||||
|
t.Fatalf("expected 1 ref, got %d", len(refs))
|
||||||
|
}
|
||||||
|
if len(refs[0].flags) != 1 || refs[0].flags[0] != "--help" {
|
||||||
|
t.Errorf("expected flag --help, got %v", refs[0].flags)
|
||||||
|
}
|
||||||
|
// bare "-" (stdin marker) and "=value" suffix
|
||||||
|
refs = parseRefs("lark-cli api GET /x --params={} --data -")
|
||||||
|
if len(refs) != 1 {
|
||||||
|
t.Fatalf("expected 1 ref, got %d", len(refs))
|
||||||
|
}
|
||||||
|
flags := strings.Join(refs[0].flags, " ")
|
||||||
|
if flags != "--params --data" {
|
||||||
|
t.Errorf("expected '--params --data', got %q", flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExampleCheck(t *testing.T) {
|
||||||
|
c := testCatalog()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
r ref
|
||||||
|
wantKind string // "" = no finding
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{"valid shortcut", ref{words: []string{"contact", "+search-user"}, flags: []string{"--query"}}, "", ""},
|
||||||
|
{"valid leaf positional", ref{words: []string{"api", "GET", "/x"}}, "", ""},
|
||||||
|
{"unknown top command", ref{words: []string{"nope"}}, unknownCommand, "nope"},
|
||||||
|
{"group leftover = unknown subcommand",
|
||||||
|
ref{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}},
|
||||||
|
unknownCommand, "mail user_mailbox.messages batch_modify_message"},
|
||||||
|
{"unknown flag", ref{words: []string{"contact", "+search-user"}, flags: []string{"--nope"}}, unknownFlag, "contact +search-user"},
|
||||||
|
{"universal flag ok", ref{words: []string{"contact", "+search-user"}, flags: []string{"--help"}}, "", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fs := checkRefs(c, []ref{tt.r})
|
||||||
|
if tt.wantKind == "" {
|
||||||
|
if len(fs) != 0 {
|
||||||
|
t.Fatalf("expected no finding, got %+v", fs)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(fs) != 1 {
|
||||||
|
t.Fatalf("expected 1 finding, got %d: %+v", len(fs), fs)
|
||||||
|
}
|
||||||
|
if fs[0].kind != tt.wantKind || fs[0].path != tt.wantPath {
|
||||||
|
t.Errorf("got kind=%s path=%q, want kind=%s path=%q", fs[0].kind, fs[0].path, tt.wantKind, tt.wantPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExampleCheckSuggestsNearest(t *testing.T) {
|
||||||
|
c := testCatalog()
|
||||||
|
fs := checkRefs(c, []ref{{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}})
|
||||||
|
if len(fs) != 1 || fs[0].suggest != "mail user_mailbox.messages batch_modify" {
|
||||||
|
t.Fatalf("expected suggestion 'mail user_mailbox.messages batch_modify', got %+v", fs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCmdExampleParseRefsRobustness covers the parser edge cases hardened after
|
||||||
|
// review: backslash continuation, underscore flags, $(...) substitution, glued
|
||||||
|
// separators, trailing punctuation, and the "..." placeholder.
|
||||||
|
func TestCmdExampleParseRefsRobustness(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, content, wantWords, wantFlags string
|
||||||
|
wantRefs int
|
||||||
|
}{
|
||||||
|
{"backslash continuation joins flags",
|
||||||
|
"lark-cli contact +search-user \\\n --query foo \\\n --as user",
|
||||||
|
"contact +search-user", "--query --as", 1},
|
||||||
|
{"underscore flag not truncated",
|
||||||
|
"lark-cli whiteboard +update --input_format mermaid",
|
||||||
|
"whiteboard +update", "--input_format", 1},
|
||||||
|
{"command-substitution flags ignored",
|
||||||
|
`lark-cli slides x create --data "$(jq -n --arg c '{}')" --as user`,
|
||||||
|
"slides x create", "--data --as", 1},
|
||||||
|
{"glued separator truncates",
|
||||||
|
"lark-cli auth login; echo done",
|
||||||
|
"auth login", "", 1},
|
||||||
|
{"trailing CJK punctuation stripped",
|
||||||
|
"用 lark-cli auth login。",
|
||||||
|
"auth login", "", 1},
|
||||||
|
{"ellipsis placeholder stays placeholder",
|
||||||
|
"lark-cli base +...",
|
||||||
|
"base", "", 1},
|
||||||
|
}
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
refs := parseRefs(tt.content)
|
||||||
|
if len(refs) != tt.wantRefs {
|
||||||
|
t.Fatalf("refs=%d want %d: %v", len(refs), tt.wantRefs, refWordsOf(refs))
|
||||||
|
}
|
||||||
|
if tt.wantRefs == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got := strings.Join(refs[0].words, " "); got != tt.wantWords {
|
||||||
|
t.Errorf("words=%q want %q", got, tt.wantWords)
|
||||||
|
}
|
||||||
|
if got := strings.Join(refs[0].flags, " "); got != tt.wantFlags {
|
||||||
|
t.Errorf("flags=%q want %q", got, tt.wantFlags)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -341,6 +341,9 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||||
printLangPreferenceConfirmation(opts)
|
printLangPreferenceConfirmation(opts)
|
||||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
||||||
|
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +383,9 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
}
|
}
|
||||||
printLangPreferenceConfirmation(opts)
|
printLangPreferenceConfirmation(opts)
|
||||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||||
|
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,6 +425,11 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
|
||||||
}
|
}
|
||||||
printLangPreferenceConfirmation(opts)
|
printLangPreferenceConfirmation(opts)
|
||||||
|
if result.AppSecret != "" {
|
||||||
|
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,5 +518,10 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
}
|
}
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||||
printLangPreferenceConfirmation(opts)
|
printLangPreferenceConfirmation(opts)
|
||||||
|
if appSecretInput != "" {
|
||||||
|
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
92
cmd/config/init_probe.go
Normal file
92
cmd/config/init_probe.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/build"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/credential"
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeTimeout is the total wall-clock budget for the credential probe step
|
||||||
|
// (covering both TAT acquisition and the subsequent probe request).
|
||||||
|
const probeTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
// runProbe runs a best-effort credential validation after config init has
|
||||||
|
// persisted the App ID and App Secret. It returns a non-nil error only for a
|
||||||
|
// deterministic credential-rejection signal; every other outcome returns nil
|
||||||
|
// so that valid configurations and transient/upstream noise never block the
|
||||||
|
// command.
|
||||||
|
//
|
||||||
|
// The function performs up to two HTTP calls in series, bounded by
|
||||||
|
// probeTimeout:
|
||||||
|
//
|
||||||
|
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
|
||||||
|
// returns a typed errs.* error (via the shared classifyTATResponseCode)
|
||||||
|
// only when the unified Token Endpoint deterministically rejected the
|
||||||
|
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
|
||||||
|
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
|
||||||
|
// typed error is propagated so the root dispatcher renders the canonical
|
||||||
|
// envelope and `config init` exits non-zero — identical to how every other
|
||||||
|
// token-resolving command reports the same bad credentials. Ambiguous
|
||||||
|
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
|
||||||
|
// timeouts) come back as raw untyped errors and are swallowed (return nil),
|
||||||
|
// so valid configurations are never disturbed by upstream noise.
|
||||||
|
// errs.IsTyped is the discriminator.
|
||||||
|
//
|
||||||
|
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||||
|
// that call (success, server error, timeout, parse failure) is always
|
||||||
|
// ignored — return nil regardless.
|
||||||
|
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
|
||||||
|
if factory == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
httpClient, err := factory.HttpClient()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
|
||||||
|
if err != nil {
|
||||||
|
// A typed error from FetchTAT is a deterministic credential rejection
|
||||||
|
// (classifyTATResponseCode). Propagate it so config init exits with the
|
||||||
|
// same envelope the rest of the CLI uses for bad credentials. Untyped
|
||||||
|
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
|
||||||
|
// silent and let the command succeed.
|
||||||
|
if errs.IsTyped(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TAT succeeded — fire the probe call. Any outcome is ignored.
|
||||||
|
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
|
||||||
|
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
287
cmd/config/init_probe_test.go
Normal file
287
cmd/config/init_probe_test.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/build"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeRT routes requests to per-path handlers and records what it saw.
|
||||||
|
type fakeRT struct {
|
||||||
|
tatHandler func(req *http.Request) (*http.Response, error)
|
||||||
|
probeHandler func(req *http.Request) (*http.Response, error)
|
||||||
|
tatCalls int
|
||||||
|
probeCalls int
|
||||||
|
probeReq *http.Request
|
||||||
|
probeBody string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
|
||||||
|
f.tatCalls++
|
||||||
|
if f.tatHandler == nil {
|
||||||
|
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
|
||||||
|
}
|
||||||
|
return f.tatHandler(req)
|
||||||
|
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
|
||||||
|
f.probeCalls++
|
||||||
|
f.probeReq = req
|
||||||
|
if req.Body != nil {
|
||||||
|
b, _ := io.ReadAll(req.Body)
|
||||||
|
f.probeBody = string(b)
|
||||||
|
}
|
||||||
|
if f.probeHandler == nil {
|
||||||
|
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
|
||||||
|
}
|
||||||
|
return f.probeHandler(req)
|
||||||
|
}
|
||||||
|
return nil, errors.New("unexpected URL: " + req.URL.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonResp(code int, body string) *http.Response {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: code,
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeFactory builds a test Factory whose HttpClient is overridden to use
|
||||||
|
// the caller-supplied RoundTripper.
|
||||||
|
//
|
||||||
|
// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams,
|
||||||
|
// Credential, Keychain and FileIO wiring is in place (per repo test-factory
|
||||||
|
// guidance). The HttpClient is then swapped to our stub so we can drive
|
||||||
|
// exact HTTP responses for the probe. Config-dir isolation is set up via
|
||||||
|
// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config
|
||||||
|
// touch lands in a temp dir rather than the developer's real config.
|
||||||
|
//
|
||||||
|
// The returned buffer is the Factory's stderr. runProbe never writes to
|
||||||
|
// stderr (it propagates a typed error or stays silent), so every test asserts
|
||||||
|
// this buffer stays empty as an invariant.
|
||||||
|
func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
f.HttpClient = func() (*http.Client, error) {
|
||||||
|
return &http.Client{Transport: rt}, nil
|
||||||
|
}
|
||||||
|
return f, errBuf
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertConfigRejection asserts runProbe propagated a deterministic credential
|
||||||
|
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
|
||||||
|
// is the same typed error every other token-resolving command returns for the
|
||||||
|
// same bad credentials, and nothing is written to stderr (the root dispatcher
|
||||||
|
// renders the envelope). The numeric code is not asserted: the unified v3 Token
|
||||||
|
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
|
||||||
|
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected *errs.ConfigError, got nil")
|
||||||
|
}
|
||||||
|
var cfgErr *errs.ConfigError
|
||||||
|
if !errors.As(err, &cfgErr) {
|
||||||
|
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if cfgErr.Category != errs.CategoryConfig {
|
||||||
|
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
|
||||||
|
}
|
||||||
|
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||||
|
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||||
|
}
|
||||||
|
if errBuf.Len() != 0 {
|
||||||
|
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertSilent asserts runProbe stayed quiet: no propagated error and nothing
|
||||||
|
// written to stderr. Used for every ambiguous (non-credential) outcome.
|
||||||
|
func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected nil (silent), got error: %v", err)
|
||||||
|
}
|
||||||
|
if errBuf.Len() != 0 {
|
||||||
|
t.Errorf("expected no stderr output, got: %q", errBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
|
||||||
|
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
|
||||||
|
// propagated. The probe endpoint must not be called when TAT fails.
|
||||||
|
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||||
|
rt := &fakeRT{
|
||||||
|
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||||
|
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
|
||||||
|
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||||
|
|
||||||
|
if rt.probeCalls != 0 {
|
||||||
|
t.Error("probe endpoint must not be called when TAT fails")
|
||||||
|
}
|
||||||
|
assertConfigRejection(t, err, errBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unauthorized_client is treated as the same credential rejection, propagated.
|
||||||
|
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
|
||||||
|
rt := &fakeRT{
|
||||||
|
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||||
|
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
|
||||||
|
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
|
||||||
|
// it rather than swallowing — but is not a credential (ConfigError) rejection.
|
||||||
|
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
|
||||||
|
rt := &fakeRT{
|
||||||
|
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||||
|
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||||
|
if err == nil || !errs.IsTyped(err) {
|
||||||
|
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if errBuf.Len() != 0 {
|
||||||
|
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential
|
||||||
|
// rejection) → silent, exit 0.
|
||||||
|
func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
|
||||||
|
for _, code := range []int{401, 403, 500} {
|
||||||
|
rt := &fakeRT{
|
||||||
|
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||||
|
return jsonResp(code, `nope`), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProbe_TATTransportError_Silent(t *testing.T) {
|
||||||
|
rt := &fakeRT{
|
||||||
|
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||||
|
return nil, errors.New("network down")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
|
||||||
|
rt := &fakeRT{
|
||||||
|
probeHandler: func(req *http.Request) (*http.Response, error) {
|
||||||
|
return jsonResp(500, `server error`), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||||
|
if rt.probeCalls != 1 {
|
||||||
|
t.Errorf("probe should be called once, got %d", rt.probeCalls)
|
||||||
|
}
|
||||||
|
assertSilent(t, err, errBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
|
||||||
|
rt := &fakeRT{}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||||
|
if rt.tatCalls != 1 || rt.probeCalls != 1 {
|
||||||
|
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
|
||||||
|
}
|
||||||
|
assertSilent(t, err, errBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProbe_ProbeRequestShape(t *testing.T) {
|
||||||
|
rt := &fakeRT{}
|
||||||
|
f, _ := fakeFactory(t, rt)
|
||||||
|
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rt.probeReq == nil {
|
||||||
|
t.Fatal("probe request not captured")
|
||||||
|
}
|
||||||
|
if rt.probeReq.Method != http.MethodPost {
|
||||||
|
t.Errorf("probe method = %s, want POST", rt.probeReq.Method)
|
||||||
|
}
|
||||||
|
if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" {
|
||||||
|
t.Errorf("probe URL = %s", got)
|
||||||
|
}
|
||||||
|
if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" {
|
||||||
|
t.Errorf("Authorization = %q, want Bearer t-ok", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) {
|
||||||
|
t.Errorf("probe body missing from field: %s", rt.probeBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
|
||||||
|
rt := &fakeRT{}
|
||||||
|
f, _ := fakeFactory(t, rt)
|
||||||
|
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if rt.probeReq == nil {
|
||||||
|
t.Fatal("probe request not captured")
|
||||||
|
}
|
||||||
|
if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") {
|
||||||
|
t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
f.HttpClient = func() (*http.Client, error) {
|
||||||
|
return nil, errors.New("client init failed")
|
||||||
|
}
|
||||||
|
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProbe_TimeoutHonored(t *testing.T) {
|
||||||
|
rt := &fakeRT{
|
||||||
|
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||||
|
<-req.Context().Done()
|
||||||
|
return nil, req.Context().Err()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f, errBuf := fakeFactory(t, rt)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if elapsed > 4*time.Second {
|
||||||
|
t.Errorf("runProbe took %v, expected <= ~3s", elapsed)
|
||||||
|
}
|
||||||
|
// A timeout is an ambiguous failure (context deadline → untyped), so it
|
||||||
|
// must stay silent and not block.
|
||||||
|
assertSilent(t, err, errBuf)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/event"
|
"github.com/larksuite/cli/internal/event"
|
||||||
@@ -38,7 +39,8 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
|||||||
|
|
||||||
logger, err := bus.SetupBusLogger(eventsDir)
|
logger, err := bus.SetupBusLogger(eventsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||||
|
"set up bus logger: %s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tr := transport.New()
|
tr := transport.New()
|
||||||
@@ -58,7 +60,14 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return b.Run(ctx)
|
if err := b.Run(ctx); err != nil {
|
||||||
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||||
|
"event bus daemon exited: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
cmd/event/bus_test.go
Normal file
45
cmd/event/bus_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The hidden `event _bus` daemon command must exit with a typed file_io error
|
||||||
|
// when its log directory cannot be created (the error is only visible in the
|
||||||
|
// forked process's captured stderr / bus.log).
|
||||||
|
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
// Block the events/ root with a regular file so MkdirAll fails.
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
|
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||||
|
})
|
||||||
|
cmd := NewCmdBus(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected logger setup error")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
|
||||||
|
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||||
|
errs.CategoryInternal, errs.SubtypeFileIO)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/appmeta"
|
"github.com/larksuite/cli/internal/appmeta"
|
||||||
"github.com/larksuite/cli/internal/auth"
|
"github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
@@ -64,8 +65,8 @@ Use 'event schema <EventKey>' for parameter details.`,
|
|||||||
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
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().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().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().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. Bounded runs ignore stdin EOF.")
|
||||||
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().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'). Bounded runs ignore stdin EOF.")
|
||||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
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) {
|
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||||
@@ -101,11 +102,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
|||||||
|
|
||||||
if o.jqExpr != "" {
|
if o.jqExpr != "" {
|
||||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||||
return output.ErrWithHint(
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||||
output.ExitValidation, "validation",
|
WithParam("--jq").
|
||||||
err.Error(),
|
WithCause(err).
|
||||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,8 +184,9 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
|||||||
errOut = io.Discard
|
errOut = io.Discard
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
|
||||||
if !f.IOStreams.IsTerminal {
|
// Bounded runs already have --max-events/--timeout as their lifecycle control.
|
||||||
|
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
|
||||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,12 +261,12 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
|||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return output.ErrWithHint(
|
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||||
output.ExitAuth, "auth",
|
"missing required scopes for EventKey %s (as %s): %s",
|
||||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
WithIdentity(string(pf.identity)).
|
||||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
WithMissingScopes(missing...).
|
||||||
)
|
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
|
||||||
}
|
}
|
||||||
|
|
||||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||||
@@ -300,23 +301,27 @@ func preflightEventTypes(pf *preflightCtx) error {
|
|||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return output.ErrWithHint(
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||||
output.ExitValidation, "validation",
|
"EventKey %s requires event types not subscribed in console: %s",
|
||||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
pf.keyDef.Key, strings.Join(missing, ", ")).
|
||||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
WithHint("subscribe these events and publish a new app version at: %s",
|
||||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
consoleEventSubscriptionURL(pf.brand, pf.appID))
|
||||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||||
func sanitizeOutputDir(dir string) (string, error) {
|
func sanitizeOutputDir(dir string) (string, error) {
|
||||||
if strings.HasPrefix(dir, "~") {
|
if strings.HasPrefix(dir, "~") {
|
||||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"%s; use a relative path like ./output instead", errOutputDirTilde).
|
||||||
|
WithParam("--output-dir").
|
||||||
|
WithCause(errOutputDirTilde)
|
||||||
}
|
}
|
||||||
safe, err := validate.SafeOutputPath(dir)
|
safe, err := validate.SafeOutputPath(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"%s %q: %s", errOutputDirUnsafe, dir, err).
|
||||||
|
WithParam("--output-dir").
|
||||||
|
WithCause(errOutputDirUnsafe)
|
||||||
}
|
}
|
||||||
return safe, nil
|
return safe, nil
|
||||||
}
|
}
|
||||||
@@ -328,18 +333,21 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
|||||||
}
|
}
|
||||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||||
|
"resolve tenant access token: %s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
if result == nil || result.Token == "" {
|
if result == nil || result.Token == "" {
|
||||||
return "", output.ErrWithHint(
|
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||||
output.ExitAuth, "auth",
|
"no tenant access token available for app %s", appID).
|
||||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
|
||||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return result.Token, nil
|
return result.Token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
|
||||||
var (
|
var (
|
||||||
errInvalidParamFormat = errors.New("invalid --param format")
|
errInvalidParamFormat = errors.New("invalid --param format")
|
||||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||||
@@ -351,7 +359,10 @@ func parseParams(raw []string) (map[string]string, error) {
|
|||||||
for _, kv := range raw {
|
for _, kv := range raw {
|
||||||
k, v, ok := strings.Cut(kv, "=")
|
k, v, ok := strings.Cut(kv, "=")
|
||||||
if !ok || k == "" {
|
if !ok || k == "" {
|
||||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"%s %q: expected key=value", errInvalidParamFormat, kv).
|
||||||
|
WithParam("--param").
|
||||||
|
WithCause(errInvalidParamFormat)
|
||||||
}
|
}
|
||||||
m[k] = v
|
m[k] = v
|
||||||
}
|
}
|
||||||
@@ -370,3 +381,8 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
|||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
|
||||||
|
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
|
||||||
|
return !isTerminal && maxEvents <= 0 && timeout <= 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,3 +61,70 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
|||||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShouldWatchStdinEOF(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
isTerminal bool
|
||||||
|
maxEvents int
|
||||||
|
timeout time.Duration
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "terminal",
|
||||||
|
isTerminal: true,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal unbounded",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal negative max events is unbounded",
|
||||||
|
maxEvents: -1,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal negative timeout is unbounded",
|
||||||
|
timeout: -1 * time.Second,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal max events bounded",
|
||||||
|
maxEvents: 1,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal timeout bounded",
|
||||||
|
timeout: 10 * time.Minute,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal both bounds positive",
|
||||||
|
maxEvents: 1,
|
||||||
|
timeout: 10 * time.Minute,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal bounded max events with negative timeout",
|
||||||
|
maxEvents: 1,
|
||||||
|
timeout: -1 * time.Second,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non terminal bounded timeout with negative max events",
|
||||||
|
maxEvents: -1,
|
||||||
|
timeout: 10 * time.Minute,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,14 @@
|
|||||||
package event
|
package event
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/credential"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseParams(t *testing.T) {
|
func TestParseParams(t *testing.T) {
|
||||||
@@ -73,6 +78,7 @@ func TestParseParams(t *testing.T) {
|
|||||||
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
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)
|
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
||||||
}
|
}
|
||||||
|
assertInvalidArgumentParam(t, err, "--param")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -90,6 +96,77 @@ func TestParseParams(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emptyTokenResolver resolves to a result that carries no token.
|
||||||
|
type emptyTokenResolver struct{}
|
||||||
|
|
||||||
|
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||||
|
return &credential.TokenResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// failingTokenResolver fails outright with an untyped error.
|
||||||
|
type failingTokenResolver struct{}
|
||||||
|
|
||||||
|
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||||
|
return nil, errors.New("backend unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
||||||
|
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||||
|
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||||
|
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||||
|
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||||
|
}
|
||||||
|
var malformed *credential.MalformedTokenResultError
|
||||||
|
if !errors.As(err, &malformed) {
|
||||||
|
t.Error("empty-token failure should preserve the credential-layer cause")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
|
||||||
|
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||||
|
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||||
|
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||||
|
}
|
||||||
|
if errors.Unwrap(err) == nil {
|
||||||
|
t.Error("resolver failure should preserve its cause")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertInvalidArgumentParam verifies err is a typed validation error with
|
||||||
|
// subtype invalid_argument naming the given flag in its param field.
|
||||||
|
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
|
||||||
|
t.Helper()
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||||
|
}
|
||||||
|
if ve.Param != param {
|
||||||
|
t.Errorf("param = %q, want %q", ve.Param, param)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSanitizeOutputDir(t *testing.T) {
|
func TestSanitizeOutputDir(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -130,6 +207,7 @@ func TestSanitizeOutputDir(t *testing.T) {
|
|||||||
if !errors.Is(err, tc.wantSentry) {
|
if !errors.Is(err, tc.wantSentry) {
|
||||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||||
}
|
}
|
||||||
|
assertInvalidArgumentParam(t, err, "--output-dir")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -143,6 +143,79 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writeStatusText(&buf, []appStatus{
|
||||||
|
{
|
||||||
|
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||||
|
State: stateRunning,
|
||||||
|
PID: 1234,
|
||||||
|
UptimeSec: 60,
|
||||||
|
Active: 2,
|
||||||
|
Consumers: []protocol.ConsumerInfo{
|
||||||
|
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
|
||||||
|
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "SUB") {
|
||||||
|
t.Errorf("missing SUB column header: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "alice") {
|
||||||
|
t.Errorf("missing alice suffix in SUB column: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "bob") {
|
||||||
|
t.Errorf("missing bob suffix in SUB column: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writeStatusText(&buf, []appStatus{
|
||||||
|
{
|
||||||
|
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||||
|
State: stateRunning,
|
||||||
|
PID: 1234,
|
||||||
|
UptimeSec: 60,
|
||||||
|
Active: 1,
|
||||||
|
Consumers: []protocol.ConsumerInfo{
|
||||||
|
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "SUB") {
|
||||||
|
t.Errorf("missing SUB header: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "-") {
|
||||||
|
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writeStatusText(&buf, []appStatus{
|
||||||
|
{
|
||||||
|
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||||
|
State: stateRunning,
|
||||||
|
PID: 1234,
|
||||||
|
UptimeSec: 60,
|
||||||
|
Active: 1,
|
||||||
|
Consumers: []protocol.ConsumerInfo{
|
||||||
|
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "SUB") {
|
||||||
|
t.Errorf("missing SUB header: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "-") {
|
||||||
|
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := writeStatusJSON(&buf, []appStatus{
|
if err := writeStatusJSON(&buf, []appStatus{
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/appmeta"
|
"github.com/larksuite/cli/internal/appmeta"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
eventlib "github.com/larksuite/cli/internal/event"
|
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 {
|
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||||
@@ -89,19 +89,17 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||||
t.Errorf("error should name the missing event type, got: %v", err)
|
t.Errorf("error should name the missing event type, got: %v", err)
|
||||||
}
|
}
|
||||||
var exit *output.ExitError
|
p, ok := errs.ProblemOf(err)
|
||||||
if !errors.As(err, &exit) {
|
if !ok {
|
||||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if exit.Code != output.ExitValidation {
|
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||||
}
|
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||||
if exit.Detail == nil {
|
|
||||||
t.Fatal("expected Detail with hint")
|
|
||||||
}
|
}
|
||||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
if !strings.Contains(p.Hint, wantURL) {
|
||||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,17 +143,19 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||||
t.Errorf("error should name missing scope, got: %v", err)
|
t.Errorf("error should name missing scope, got: %v", err)
|
||||||
}
|
}
|
||||||
var exit *output.ExitError
|
var permErr *errs.PermissionError
|
||||||
if !errors.As(err, &exit) {
|
if !errors.As(err, &permErr) {
|
||||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if exit.Code != output.ExitAuth {
|
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
|
||||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
|
||||||
|
errs.CategoryAuthorization, errs.SubtypeMissingScope)
|
||||||
}
|
}
|
||||||
if exit.Detail == nil {
|
wantMissing := []string{"im:message.group_at_msg"}
|
||||||
t.Fatal("expected Detail with hint, got nil Detail")
|
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
|
||||||
|
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
|
||||||
}
|
}
|
||||||
hint := exit.Detail.Hint
|
hint := permErr.Hint
|
||||||
wantSubstrings := []string{
|
wantSubstrings := []string{
|
||||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||||
"im:message.group_at_msg",
|
"im:message.group_at_msg",
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ package event
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/client"
|
"github.com/larksuite/cli/internal/client"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
)
|
)
|
||||||
@@ -26,7 +26,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
|||||||
As: r.accessIdentity,
|
As: r.accessIdentity,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||||
|
"api %s %s: %s", method, path, err).WithCause(err)
|
||||||
}
|
}
|
||||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||||
ct := resp.Header.Get("Content-Type")
|
ct := resp.Header.Get("Content-Type")
|
||||||
@@ -36,11 +40,20 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
|||||||
if len(body) > maxBodyEcho {
|
if len(body) > maxBodyEcho {
|
||||||
body = body[:maxBodyEcho] + "…(truncated)"
|
body = body[:maxBodyEcho] + "…(truncated)"
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
if resp.StatusCode >= 500 {
|
||||||
|
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
|
||||||
|
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
|
||||||
|
}
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||||
|
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||||
}
|
}
|
||||||
result, err := client.ParseJSONResponse(resp)
|
result, err := client.ParseJSONResponse(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||||
|
"api %s %s: %s", method, path, err).WithCause(err)
|
||||||
}
|
}
|
||||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||||
return json.RawMessage(resp.RawBody), apiErr
|
return json.RawMessage(resp.RawBody), apiErr
|
||||||
|
|||||||
147
cmd/event/runtime_test.go
Normal file
147
cmd/event/runtime_test.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/client"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/credential"
|
||||||
|
)
|
||||||
|
|
||||||
|
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||||
|
type staticTokenResolver struct{}
|
||||||
|
|
||||||
|
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||||
|
return &credential.TokenResult{Token: "test-token"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubRoundTripper intercepts every outgoing request with a canned response.
|
||||||
|
type stubRoundTripper struct {
|
||||||
|
respond func(*http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
|
||||||
|
|
||||||
|
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||||
|
sdk := lark.NewClient("test-app", "test-secret",
|
||||||
|
lark.WithEnableTokenCache(false),
|
||||||
|
lark.WithLogLevel(larkcore.LogLevelError),
|
||||||
|
lark.WithHttpClient(&http.Client{Transport: rt}),
|
||||||
|
)
|
||||||
|
return &consumeRuntime{
|
||||||
|
client: &client.APIClient{
|
||||||
|
SDK: sdk,
|
||||||
|
ErrOut: io.Discard,
|
||||||
|
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||||
|
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||||
|
},
|
||||||
|
accessIdentity: core.AsBot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
|
||||||
|
return func(r *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Header: http.Header{"Content-Type": []string{contentType}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Request: r,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != category || p.Subtype != subtype {
|
||||||
|
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
|
||||||
|
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
|
||||||
|
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||||
|
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||||
|
if !strings.Contains(err.Error(), "returned 404") {
|
||||||
|
t.Errorf("error should echo the HTTP status, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
|
||||||
|
long := strings.Repeat("x", 300)
|
||||||
|
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
|
||||||
|
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||||
|
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
|
||||||
|
p, _ := errs.ProblemOf(err)
|
||||||
|
if !p.Retryable {
|
||||||
|
t.Fatal("5xx non-JSON response should be marked retryable")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "…(truncated)") {
|
||||||
|
t.Errorf("long body should be truncated in the message, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
|
||||||
|
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
|
||||||
|
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||||
|
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
|
||||||
|
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
|
||||||
|
return nil, errors.New("connection refused")
|
||||||
|
}})
|
||||||
|
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryNetwork {
|
||||||
|
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
|
||||||
|
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||||
|
`{"code":99991663,"msg":"app not found"}`)})
|
||||||
|
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if _, ok := errs.ProblemOf(err); !ok {
|
||||||
|
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
|
||||||
|
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||||
|
`{"code":0,"data":{"ok":true}}`)})
|
||||||
|
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), `"code":0`) {
|
||||||
|
t.Errorf("raw body should pass through, got: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
eventlib "github.com/larksuite/cli/internal/event"
|
eventlib "github.com/larksuite/cli/internal/event"
|
||||||
"github.com/larksuite/cli/internal/event/schemas"
|
"github.com/larksuite/cli/internal/event/schemas"
|
||||||
@@ -39,12 +40,14 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
|
|||||||
if len(def.Schema.FieldOverrides) > 0 {
|
if len(def.Schema.FieldOverrides) > 0 {
|
||||||
var parsed map[string]interface{}
|
var parsed map[string]interface{}
|
||||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||||
|
"parse base schema for field overrides: %s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||||
out, err := json.Marshal(parsed)
|
out, err := json.Marshal(parsed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||||
|
"serialize schema with field overrides: %s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
return out, orphans, nil
|
return out, orphans, nil
|
||||||
}
|
}
|
||||||
@@ -73,7 +76,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
|||||||
copy(buf, s.Raw)
|
copy(buf, s.Raw)
|
||||||
return buf, nil
|
return buf, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||||
@@ -131,12 +134,16 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
|||||||
if len(def.Params) > 0 {
|
if len(def.Params) > 0 {
|
||||||
fmt.Fprintf(out, "\nParameters:\n")
|
fmt.Fprintf(out, "\nParameters:\n")
|
||||||
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
|
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n")
|
||||||
for _, p := range def.Params {
|
for _, p := range def.Params {
|
||||||
required := "no"
|
required := "no"
|
||||||
if p.Required {
|
if p.Required {
|
||||||
required = "yes"
|
required = "yes"
|
||||||
}
|
}
|
||||||
|
subKey := "no"
|
||||||
|
if p.SubscriptionKey {
|
||||||
|
subKey = "yes"
|
||||||
|
}
|
||||||
defaultVal := p.Default
|
defaultVal := p.Default
|
||||||
if defaultVal == "" {
|
if defaultVal == "" {
|
||||||
defaultVal = "-"
|
defaultVal = "-"
|
||||||
@@ -145,7 +152,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
|||||||
if desc == "" {
|
if desc == "" {
|
||||||
desc = "-"
|
desc = "-"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
|
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc)
|
||||||
}
|
}
|
||||||
w.Flush()
|
w.Flush()
|
||||||
|
|
||||||
@@ -165,7 +172,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
|||||||
|
|
||||||
resolved, _, err := resolveSchemaJSON(def)
|
resolved, _, err := resolveSchemaJSON(def)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
return err
|
||||||
}
|
}
|
||||||
if resolved != nil {
|
if resolved != nil {
|
||||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
eventlib "github.com/larksuite/cli/internal/event"
|
eventlib "github.com/larksuite/cli/internal/event"
|
||||||
@@ -95,6 +96,79 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||||
|
const syntheticKey = "test.evt_sub"
|
||||||
|
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||||
|
|
||||||
|
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||||
|
Key: syntheticKey,
|
||||||
|
EventType: syntheticKey,
|
||||||
|
Params: []eventlib.ParamDef{
|
||||||
|
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
|
||||||
|
{Name: "folders", Description: "filter only"},
|
||||||
|
},
|
||||||
|
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||||
|
})
|
||||||
|
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||||
|
if err := runSchema(f, syntheticKey, false); err != nil {
|
||||||
|
t.Fatalf("runSchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, "SUB-KEY") {
|
||||||
|
t.Errorf("missing SUB-KEY column header in:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the mailbox row and verify "yes" is present
|
||||||
|
var mailboxRow string
|
||||||
|
for _, ln := range strings.Split(out, "\n") {
|
||||||
|
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
|
||||||
|
mailboxRow = ln
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(mailboxRow, "yes") {
|
||||||
|
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the folders row and verify "no" is present
|
||||||
|
var foldersRow string
|
||||||
|
for _, ln := range strings.Split(out, "\n") {
|
||||||
|
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
|
||||||
|
foldersRow = ln
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(foldersRow, "no") {
|
||||||
|
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
|
||||||
|
const syntheticKey = "test.evt_json"
|
||||||
|
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||||
|
|
||||||
|
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||||
|
Key: syntheticKey,
|
||||||
|
EventType: syntheticKey,
|
||||||
|
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||||
|
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||||
|
})
|
||||||
|
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||||
|
if err := runSchema(f, syntheticKey, true); err != nil {
|
||||||
|
t.Fatalf("runSchema json: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(stdout.String(), `"subscription_key"`) {
|
||||||
|
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), `true`) {
|
||||||
|
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||||
const syntheticKey = "t.custom.overlay"
|
const syntheticKey = "t.custom.overlay"
|
||||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||||
@@ -129,3 +203,38 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
|||||||
t.Errorf("overlay format = %v, want open_id", got)
|
t.Errorf("overlay format = %v, want open_id", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
|
||||||
|
_, err := renderSpec(&eventlib.SchemaSpec{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for spec with neither Type nor Raw")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryInternal {
|
||||||
|
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
|
||||||
|
def := &eventlib.KeyDefinition{
|
||||||
|
Key: "synthetic.invalid.base",
|
||||||
|
Schema: eventlib.SchemaDef{
|
||||||
|
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
|
||||||
|
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, _, err := resolveSchemaJSON(def)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unparsable base schema")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryInternal {
|
||||||
|
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -242,12 +243,17 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
|
|||||||
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
||||||
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
||||||
if len(s.Consumers) > 0 {
|
if len(s.Consumers) > 0 {
|
||||||
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
|
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"}
|
||||||
rows := make([][]string, 0, len(s.Consumers))
|
rows := make([][]string, 0, len(s.Consumers))
|
||||||
for _, c := range s.Consumers {
|
for _, c := range s.Consumers {
|
||||||
|
subDisplay := "-"
|
||||||
|
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
|
||||||
|
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
|
||||||
|
}
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
fmt.Sprintf("pid=%d", c.PID),
|
fmt.Sprintf("pid=%d", c.PID),
|
||||||
c.EventKey,
|
c.EventKey,
|
||||||
|
subDisplay,
|
||||||
fmt.Sprintf("%d", c.Received),
|
fmt.Sprintf("%d", c.Received),
|
||||||
fmt.Sprintf("%d", c.Dropped),
|
fmt.Sprintf("%d", c.Dropped),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
eventlib "github.com/larksuite/cli/internal/event"
|
eventlib "github.com/larksuite/cli/internal/event"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/suggest"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxSuggestions = 3
|
const maxSuggestions = 3
|
||||||
@@ -28,7 +29,7 @@ func suggestEventKeys(input string) []string {
|
|||||||
hits = append(hits, match{def.Key, 0})
|
hits = append(hits, match{def.Key, 0})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if d := levenshtein(input, def.Key); d <= threshold {
|
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
|
||||||
hits = append(hits, match{def.Key, d})
|
hits = append(hits, match{def.Key, d})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,40 +64,6 @@ func unknownEventKeyErr(key string) error {
|
|||||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||||
}
|
}
|
||||||
return output.ErrWithHint(
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||||
output.ExitValidation, "validation",
|
WithHint("Run 'lark-cli event list' to see available keys.")
|
||||||
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)]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,27 +10,6 @@ import (
|
|||||||
_ "github.com/larksuite/cli/events"
|
_ "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) {
|
func TestSuggestEventKeys(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
70
cmd/flag_suggest_test.go
Normal file
70
cmd/flag_suggest_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnknownFlagName(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
name string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"unknown flag: --query", "query", true},
|
||||||
|
{"unknown flag: --with-styles", "with-styles", true},
|
||||||
|
{"unknown shorthand flag: 'z' in -z", "", false},
|
||||||
|
{"flag needs an argument: --find", "", false},
|
||||||
|
{`invalid argument "x" for "--count"`, "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
name, ok := unknownFlagName(errors.New(c.in))
|
||||||
|
if name != c.name || ok != c.ok {
|
||||||
|
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||||
|
c := &cobra.Command{Use: "demo"}
|
||||||
|
c.Flags().String("range", "", "")
|
||||||
|
c.Flags().String("find", "", "")
|
||||||
|
c.Flags().Bool("dry-run", false, "")
|
||||||
|
|
||||||
|
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
|
||||||
|
var exitErr *output.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||||
|
}
|
||||||
|
if exitErr.Detail.Type != "unknown_flag" {
|
||||||
|
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||||
|
}
|
||||||
|
if !strings.Contains(exitErr.Detail.Hint, "--range") {
|
||||||
|
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
|
||||||
|
}
|
||||||
|
detail, _ := exitErr.Detail.Detail.(map[string]any)
|
||||||
|
valid, _ := detail["valid_flags"].([]string)
|
||||||
|
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
|
||||||
|
t.Errorf("valid_flags should list find & range, got %v", valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
||||||
|
c := &cobra.Command{Use: "demo"}
|
||||||
|
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
||||||
|
var exitErr *output.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||||
|
}
|
||||||
|
if exitErr.Detail.Type != "flag_error" {
|
||||||
|
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
cmd/notice_test.go
Normal file
61
cmd/notice_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/deprecation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// composePendingNotice must surface a deprecated-command alias under the
|
||||||
|
// "deprecated_command" key, with the migration target and a skill-update hint,
|
||||||
|
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
|
||||||
|
// without ever reading --help.
|
||||||
|
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
|
||||||
|
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||||
|
|
||||||
|
deprecation.SetPending(&deprecation.Notice{
|
||||||
|
Command: "+read",
|
||||||
|
Replacement: "+cells-get",
|
||||||
|
Skill: "lark-sheets",
|
||||||
|
})
|
||||||
|
|
||||||
|
got := composePendingNotice()
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
|
||||||
|
}
|
||||||
|
entry, ok := got["deprecated_command"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing deprecated_command key: %#v", got)
|
||||||
|
}
|
||||||
|
if entry["command"] != "+read" {
|
||||||
|
t.Errorf("command = %v, want +read", entry["command"])
|
||||||
|
}
|
||||||
|
if entry["replacement"] != "+cells-get" {
|
||||||
|
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
|
||||||
|
}
|
||||||
|
if entry["skill"] != "lark-sheets" {
|
||||||
|
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
|
||||||
|
}
|
||||||
|
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
|
||||||
|
t.Errorf("message missing skill-update hint: %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With nothing pending, the provider returns nil so no "_notice" field is
|
||||||
|
// emitted on a clean run.
|
||||||
|
func TestComposePendingNoticeEmpty(t *testing.T) {
|
||||||
|
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||||
|
deprecation.SetPending(nil)
|
||||||
|
|
||||||
|
if got := composePendingNotice(); got != nil {
|
||||||
|
// update/skills pending are process-global; only assert the absence of
|
||||||
|
// our own key to stay robust against unrelated pending state.
|
||||||
|
if _, ok := got["deprecated_command"]; ok {
|
||||||
|
t.Fatalf("deprecated_command present after clear: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
398
cmd/root.go
398
cmd/root.go
@@ -18,14 +18,17 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/deprecation"
|
||||||
"github.com/larksuite/cli/internal/errclass"
|
"github.com/larksuite/cli/internal/errclass"
|
||||||
"github.com/larksuite/cli/internal/errcompat"
|
"github.com/larksuite/cli/internal/errcompat"
|
||||||
"github.com/larksuite/cli/internal/hook"
|
"github.com/larksuite/cli/internal/hook"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
"github.com/larksuite/cli/internal/skillscheck"
|
"github.com/larksuite/cli/internal/skillscheck"
|
||||||
|
"github.com/larksuite/cli/internal/suggest"
|
||||||
"github.com/larksuite/cli/internal/update"
|
"github.com/larksuite/cli/internal/update"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
)
|
)
|
||||||
|
|
||||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||||
@@ -48,20 +51,6 @@ EXAMPLES:
|
|||||||
# Generic API call
|
# Generic API call
|
||||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||||
|
|
||||||
FLAGS:
|
|
||||||
--params <json> URL/query parameters JSON
|
|
||||||
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
|
|
||||||
--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)
|
|
||||||
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
|
|
||||||
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
|
|
||||||
-o, --output <path> output file path for binary responses
|
|
||||||
--jq <expr> jq expression to filter JSON output
|
|
||||||
-q <expr> shorthand for --jq
|
|
||||||
--dry-run print request without executing
|
|
||||||
|
|
||||||
AI AGENT SKILLS:
|
AI AGENT SKILLS:
|
||||||
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
||||||
teach the agent Lark API patterns, best practices, and workflows.
|
teach the agent Lark API patterns, best practices, and workflows.
|
||||||
@@ -83,7 +72,15 @@ COMMUNITY:
|
|||||||
More help: lark-cli <command> --help`
|
More help: lark-cli <command> --help`
|
||||||
|
|
||||||
// Execute runs the root command and returns the process exit code.
|
// Execute runs the root command and returns the process exit code.
|
||||||
|
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||||
|
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
||||||
|
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
|
||||||
|
// from here. It stays nil in unit tests that invoke a RunE directly with
|
||||||
|
// explicit args — correct, since those don't exercise the whitelist path.
|
||||||
|
var rawInvocationArgs []string
|
||||||
|
|
||||||
func Execute() int {
|
func Execute() int {
|
||||||
|
rawInvocationArgs = os.Args[1:]
|
||||||
inv, err := BootstrapInvocationContext(os.Args[1:])
|
inv, err := BootstrapInvocationContext(os.Args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||||
@@ -147,29 +144,49 @@ func setupNotices() {
|
|||||||
skillscheck.Init(build.Version)
|
skillscheck.Init(build.Version)
|
||||||
|
|
||||||
// Composed notice provider — emits keys only when each pending is set.
|
// Composed notice provider — emits keys only when each pending is set.
|
||||||
output.PendingNotice = func() map[string]interface{} {
|
output.PendingNotice = composePendingNotice
|
||||||
notice := map[string]interface{}{}
|
}
|
||||||
if info := update.GetPending(); info != nil {
|
|
||||||
notice["update"] = map[string]interface{}{
|
// composePendingNotice merges all process-level pending notices (available
|
||||||
"current": info.Current,
|
// update, skills/binary drift, deprecated-command alias) into the map surfaced
|
||||||
"latest": info.Latest,
|
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
|
||||||
"message": info.Message(),
|
// Extracted from Execute so the composition is unit-testable.
|
||||||
"command": "lark-cli update",
|
func composePendingNotice() 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
|
|
||||||
}
|
}
|
||||||
|
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 dep := deprecation.GetPending(); dep != nil {
|
||||||
|
entry := map[string]interface{}{
|
||||||
|
"command": dep.Command,
|
||||||
|
"message": dep.Message(),
|
||||||
|
"action": "lark-cli update",
|
||||||
|
}
|
||||||
|
if dep.Replacement != "" {
|
||||||
|
entry["replacement"] = dep.Replacement
|
||||||
|
}
|
||||||
|
if dep.Skill != "" {
|
||||||
|
entry["skill"] = dep.Skill
|
||||||
|
}
|
||||||
|
notice["deprecated_command"] = entry
|
||||||
|
}
|
||||||
|
if len(notice) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return notice
|
||||||
}
|
}
|
||||||
|
|
||||||
// isCompletionCommand returns true if args indicate a shell completion request.
|
// isCompletionCommand returns true if args indicate a shell completion request.
|
||||||
@@ -255,6 +272,13 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
|||||||
return typedExit
|
return typedExit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partial-failure (batch / multi-status): the ok:false result envelope is
|
||||||
|
// already on stdout; set the exit code and write nothing to stderr.
|
||||||
|
var pfErr *output.PartialFailureError
|
||||||
|
if errors.As(err, &pfErr) {
|
||||||
|
return pfErr.Code
|
||||||
|
}
|
||||||
|
|
||||||
if exitErr := asExitError(err); exitErr != nil {
|
if exitErr := asExitError(err); exitErr != nil {
|
||||||
if !exitErr.Raw {
|
if !exitErr.Raw {
|
||||||
// Raw errors (e.g. from `api` command via output.MarkRaw)
|
// Raw errors (e.g. from `api` command via output.MarkRaw)
|
||||||
@@ -267,6 +291,19 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
|||||||
return exitErr.Code
|
return exitErr.Code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A backward-compat alias records its deprecation notice in PreRunE, which
|
||||||
|
// runs before cobra's required-flag validation — but a missing required flag
|
||||||
|
// fails before RunE and lands here, where the bare "Error:" line would drop
|
||||||
|
// the notice. When a deprecation is pending, route through the structured
|
||||||
|
// envelope so the migration hint still reaches the caller; all other errors
|
||||||
|
// keep the existing plain output.
|
||||||
|
if deprecation.GetPending() != nil {
|
||||||
|
output.WriteErrorEnvelope(errOut, &output.ExitError{
|
||||||
|
Code: 1,
|
||||||
|
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
|
||||||
|
}, string(f.ResolvedIdentity))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
fmt.Fprintln(errOut, "Error:", err)
|
fmt.Fprintln(errOut, "Error:", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -308,6 +345,12 @@ func asExitError(err error) *output.ExitError {
|
|||||||
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||||
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
||||||
cmd.RunE = unknownSubcommandRunE
|
cmd.RunE = unknownSubcommandRunE
|
||||||
|
// Route an unknown subcommand to unknownSubcommandRunE even when flags
|
||||||
|
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
|
||||||
|
// consumes no flags itself, so unknown flags belong to the (missing)
|
||||||
|
// subcommand; whitelisting them here prevents cobra from erroring on the
|
||||||
|
// flag first and printing usage instead of our structured suggestion.
|
||||||
|
cmd.FParseErrWhitelist.UnknownFlags = true
|
||||||
if cmd.Annotations == nil {
|
if cmd.Annotations == nil {
|
||||||
cmd.Annotations = map[string]string{}
|
cmd.Annotations = map[string]string{}
|
||||||
}
|
}
|
||||||
@@ -327,14 +370,89 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
|||||||
// they have moved to the typed surface.
|
// they have moved to the typed surface.
|
||||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return cmd.Help()
|
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
|
||||||
|
// like the global --profile, legitimately prints help. But a flag that
|
||||||
|
// belongs to a (missing) subcommand is a user error: the guard's
|
||||||
|
// FParseErrWhitelist swallows such flags and leaves args empty, so without
|
||||||
|
// the checks below they would silently fall through to help + exit 0 —
|
||||||
|
// letting an agent mistake a malformed call (`im --format json`,
|
||||||
|
// `sheets --badflag`) for success. Recover the swallowed tokens from the
|
||||||
|
// raw invocation and fail structured instead.
|
||||||
|
flags := flagTokensInArgs(rawInvocationArgs)
|
||||||
|
if len(flags) == 0 {
|
||||||
|
return cmd.Help()
|
||||||
|
}
|
||||||
|
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
|
||||||
|
return &output.ExitError{
|
||||||
|
Code: output.ExitValidation,
|
||||||
|
Detail: &output.ErrDetail{
|
||||||
|
Type: "unknown_flag",
|
||||||
|
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
|
||||||
|
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||||
|
Detail: map[string]any{
|
||||||
|
// Keep the same detail keys as flagDidYouMean's unknown_flag
|
||||||
|
// so a consumer keyed on Type can read a stable shape. The
|
||||||
|
// subcommand isn't resolved here, so suggestions/valid_flags
|
||||||
|
// have no meaningful universe to draw from — emit empty
|
||||||
|
// rather than the group's own (misleading) flags. unknown is
|
||||||
|
// the back-compat singular field; unknown_flags carries the
|
||||||
|
// full list when more than one flag was supplied.
|
||||||
|
"unknown": strings.Join(unknown, ", "),
|
||||||
|
"unknown_flags": unknown,
|
||||||
|
"command_path": cmd.CommandPath(),
|
||||||
|
"suggestions": []string{},
|
||||||
|
"valid_flags": []string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The remaining flags are all defined somewhere in the tree. Those valid
|
||||||
|
// on the group itself or inherited (e.g. the global --profile) do not
|
||||||
|
// require a subcommand, so a bare group carrying only those still prints
|
||||||
|
// help. Anything left belongs to a subcommand that was omitted
|
||||||
|
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
|
||||||
|
// real, the subcommand is what's missing.
|
||||||
|
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
|
||||||
|
if len(misplaced) == 0 {
|
||||||
|
return cmd.Help()
|
||||||
|
}
|
||||||
|
return &output.ExitError{
|
||||||
|
Code: output.ExitValidation,
|
||||||
|
Detail: &output.ErrDetail{
|
||||||
|
Type: "missing_subcommand",
|
||||||
|
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
|
||||||
|
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||||
|
Detail: map[string]any{
|
||||||
|
"command_path": cmd.CommandPath(),
|
||||||
|
"flags": misplaced,
|
||||||
|
"suggestions": []string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
unknown := args[0]
|
unknown := args[0]
|
||||||
available := availableSubcommandNames(cmd)
|
available, deprecated := availableSubcommandNames(cmd)
|
||||||
|
// Rank suggestions across both current and deprecated names so a mistyped
|
||||||
|
// legacy command (e.g. +raed → +read) still resolves; the alias stays
|
||||||
|
// runnable and self-flags via the _notice on execution.
|
||||||
|
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
|
||||||
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
||||||
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
||||||
if len(available) > 0 {
|
if len(suggestions) > 0 {
|
||||||
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
|
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
|
||||||
|
strings.Join(suggestions, ", "), cmd.CommandPath())
|
||||||
|
}
|
||||||
|
detail := map[string]any{
|
||||||
|
"unknown": unknown,
|
||||||
|
"command_path": cmd.CommandPath(),
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"available": available,
|
||||||
|
}
|
||||||
|
// Only services with backward-compat aliases (currently sheets) carry a
|
||||||
|
// deprecated bucket; omit the key elsewhere so every other service's
|
||||||
|
// envelope is unchanged.
|
||||||
|
if len(deprecated) > 0 {
|
||||||
|
detail["deprecated"] = deprecated
|
||||||
}
|
}
|
||||||
return &output.ExitError{
|
return &output.ExitError{
|
||||||
Code: output.ExitValidation,
|
Code: output.ExitValidation,
|
||||||
@@ -342,17 +460,114 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
|||||||
Type: "unknown_subcommand",
|
Type: "unknown_subcommand",
|
||||||
Message: msg,
|
Message: msg,
|
||||||
Hint: hint,
|
Hint: hint,
|
||||||
Detail: map[string]any{
|
Detail: detail,
|
||||||
"unknown": unknown,
|
|
||||||
"command_path": cmd.CommandPath(),
|
|
||||||
"available": available,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
|
||||||
subs := make([]string, 0, len(cmd.Commands()))
|
// rawArgs, stopping at the "--" positional terminator. Whether a flag is
|
||||||
|
// defined is not considered (see unknownFlagTokens for that). A pure group
|
||||||
|
// with any flag token but no subcommand is a user error — a pure group
|
||||||
|
// consumes no flags of its own, so the flag must belong to a subcommand — so
|
||||||
|
// the caller fails structured instead of falling through to help.
|
||||||
|
func flagTokensInArgs(rawArgs []string) []string {
|
||||||
|
var toks []string
|
||||||
|
for _, a := range rawArgs {
|
||||||
|
if a == "--" {
|
||||||
|
break // everything after -- is positional
|
||||||
|
}
|
||||||
|
if len(a) < 2 || a[0] != '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
toks = append(toks, a)
|
||||||
|
}
|
||||||
|
return toks
|
||||||
|
}
|
||||||
|
|
||||||
|
// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define
|
||||||
|
// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard
|
||||||
|
// whitelists unknown flags on pure groups so a mistyped subcommand still reaches
|
||||||
|
// the suggestion path; the side effect is that flags before a subcommand are
|
||||||
|
// swallowed. This recovers the genuinely-unknown ones so the caller can name
|
||||||
|
// them in a "did you mean" envelope.
|
||||||
|
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||||
|
var unknown []string
|
||||||
|
for _, a := range flagTokensInArgs(rawArgs) {
|
||||||
|
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||||
|
if name != "" && !flagDefinedInTree(cmd, name) {
|
||||||
|
unknown = append(unknown, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// flagKnownOnGroup reports whether name is a flag defined on cmd itself or
|
||||||
|
// inherited (a global persistent flag like --profile) — i.e. valid on the bare
|
||||||
|
// group and therefore not requiring a subcommand.
|
||||||
|
func flagKnownOnGroup(cmd *cobra.Command, name string) bool {
|
||||||
|
short := len(name) == 1
|
||||||
|
lookup := func(fs *pflag.FlagSet) bool {
|
||||||
|
if short {
|
||||||
|
return fs.ShorthandLookup(name) != nil
|
||||||
|
}
|
||||||
|
return fs.Lookup(name) != nil
|
||||||
|
}
|
||||||
|
return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags())
|
||||||
|
}
|
||||||
|
|
||||||
|
// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on
|
||||||
|
// a subcommand of cmd but not on cmd itself/inherited — flags supplied while
|
||||||
|
// omitting the subcommand they belong to (`im --format json`). Global flags
|
||||||
|
// valid on the bare group (e.g. --profile) are excluded so
|
||||||
|
// `lark-cli --profile p im` still prints help rather than erroring.
|
||||||
|
func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||||
|
var misplaced []string
|
||||||
|
for _, a := range flagTokensInArgs(rawArgs) {
|
||||||
|
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||||
|
if name == "" || flagKnownOnGroup(cmd, name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if flagDefinedInTree(cmd, name) {
|
||||||
|
misplaced = append(misplaced, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return misplaced
|
||||||
|
}
|
||||||
|
|
||||||
|
// flagDefinedInTree reports whether name is defined on cmd, its inherited
|
||||||
|
// (persistent) flags, or any direct subcommand. The subcommand case covers a
|
||||||
|
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
|
||||||
|
// --format is injected on every leaf shortcut, not on the group — so only a
|
||||||
|
// genuinely unknown flag like `sheets --badflag` is reported.
|
||||||
|
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
|
||||||
|
short := len(name) == 1
|
||||||
|
known := func(c *cobra.Command, inherited bool) bool {
|
||||||
|
fs := c.Flags()
|
||||||
|
if inherited {
|
||||||
|
fs = c.InheritedFlags()
|
||||||
|
}
|
||||||
|
if short {
|
||||||
|
return fs.ShorthandLookup(name) != nil
|
||||||
|
}
|
||||||
|
return fs.Lookup(name) != nil
|
||||||
|
}
|
||||||
|
if known(cmd, false) || known(cmd, true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, c := range cmd.Commands() {
|
||||||
|
if known(c, false) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// availableSubcommandNames returns the invokable subcommand names of cmd, split
|
||||||
|
// into current commands and backward-compatibility aliases (those tagged into
|
||||||
|
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
|
||||||
|
// sorted; hidden commands plus help/completion are omitted.
|
||||||
|
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
|
||||||
for _, c := range cmd.Commands() {
|
for _, c := range cmd.Commands() {
|
||||||
if c.Hidden || !c.IsAvailableCommand() {
|
if c.Hidden || !c.IsAvailableCommand() {
|
||||||
continue
|
continue
|
||||||
@@ -361,10 +576,95 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
|
|||||||
if name == "help" || name == "completion" {
|
if name == "help" || name == "completion" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
subs = append(subs, name)
|
if cmdutil.IsDeprecatedCommand(c) {
|
||||||
|
deprecated = append(deprecated, name)
|
||||||
|
} else {
|
||||||
|
available = append(available, name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(subs)
|
sort.Strings(available)
|
||||||
return subs
|
sort.Strings(deprecated)
|
||||||
|
return available, deprecated
|
||||||
|
}
|
||||||
|
|
||||||
|
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||||
|
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
|
||||||
|
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
|
||||||
|
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
|
||||||
|
// --find, where edit distance alone finds nothing). Other flag errors stay
|
||||||
|
// structured but generic.
|
||||||
|
func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||||
|
name, isUnknown := unknownFlagName(ferr)
|
||||||
|
if !isUnknown {
|
||||||
|
return &output.ExitError{
|
||||||
|
Code: output.ExitValidation,
|
||||||
|
Detail: &output.ErrDetail{
|
||||||
|
Type: "flag_error",
|
||||||
|
Message: ferr.Error(),
|
||||||
|
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
valid := visibleFlagNames(c)
|
||||||
|
suggestions := suggest.Closest(name, valid, 3)
|
||||||
|
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
|
||||||
|
if len(suggestions) > 0 {
|
||||||
|
for i := range suggestions {
|
||||||
|
suggestions[i] = "--" + suggestions[i]
|
||||||
|
}
|
||||||
|
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
|
||||||
|
strings.Join(suggestions, ", "), c.CommandPath())
|
||||||
|
}
|
||||||
|
return &output.ExitError{
|
||||||
|
Code: output.ExitValidation,
|
||||||
|
Detail: &output.ErrDetail{
|
||||||
|
Type: "unknown_flag",
|
||||||
|
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
|
||||||
|
Hint: hint,
|
||||||
|
Detail: map[string]any{
|
||||||
|
"unknown": "--" + name,
|
||||||
|
"command_path": c.CommandPath(),
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"valid_flags": valid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
|
||||||
|
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
|
||||||
|
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
|
||||||
|
// those structured but generic — hallucinated flags are essentially always long.
|
||||||
|
//
|
||||||
|
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
|
||||||
|
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
|
||||||
|
// silently fails and unknown flags degrade to a generic flag_error — re-verify
|
||||||
|
// this prefix when bumping cobra.
|
||||||
|
func unknownFlagName(err error) (string, bool) {
|
||||||
|
const p = "unknown flag: --"
|
||||||
|
msg := err.Error()
|
||||||
|
i := strings.Index(msg, p)
|
||||||
|
if i < 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
rest := msg[i+len(p):]
|
||||||
|
if j := strings.IndexAny(rest, " \t"); j >= 0 {
|
||||||
|
rest = rest[:j]
|
||||||
|
}
|
||||||
|
return rest, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
|
||||||
|
// the valid_flags detail).
|
||||||
|
func visibleFlagNames(c *cobra.Command) []string {
|
||||||
|
var names []string
|
||||||
|
c.Flags().VisitAll(func(f *pflag.Flag) {
|
||||||
|
if !f.Hidden {
|
||||||
|
names = append(names, f.Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||||
|
|||||||
@@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
|||||||
OK: false,
|
OK: false,
|
||||||
Identity: "bot",
|
Identity: "bot",
|
||||||
Error: &output.ErrDetail{
|
Error: &output.ErrDetail{
|
||||||
Type: "api_error",
|
Type: "api",
|
||||||
Code: 230002,
|
Code: 230002,
|
||||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
Message: "Bot/User can NOT be out of the chat.",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
internalauth "github.com/larksuite/cli/internal/auth"
|
internalauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/deprecation"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
)
|
)
|
||||||
@@ -268,6 +269,54 @@ func (f *failingWriter) Write(p []byte) (int, error) {
|
|||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
|
||||||
|
// backward-compat alias that fails on a cobra-level required flag (which
|
||||||
|
// short-circuits before RunE) still routes through the structured envelope,
|
||||||
|
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
|
||||||
|
// switches to WriteErrorEnvelope when a deprecation is pending — so the
|
||||||
|
// migration notice is no longer dropped on the plain "Error:" line.
|
||||||
|
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
errOut := &bytes.Buffer{}
|
||||||
|
f.IOStreams.ErrOut = errOut
|
||||||
|
|
||||||
|
deprecation.SetPending(&deprecation.Notice{
|
||||||
|
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
|
||||||
|
})
|
||||||
|
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
|
||||||
|
// nor an *output.ExitError, so it reaches the legacy fallback.
|
||||||
|
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||||
|
|
||||||
|
out := errOut.String()
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||||
|
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
|
||||||
|
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
|
||||||
|
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
|
||||||
|
// fix does not reshape every unrecognized cobra error.
|
||||||
|
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||||
|
deprecation.SetPending(nil)
|
||||||
|
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
errOut := &bytes.Buffer{}
|
||||||
|
f.IOStreams.ErrOut = errOut
|
||||||
|
|
||||||
|
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||||
|
if !strings.HasPrefix(errOut.String(), "Error:") {
|
||||||
|
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
|
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
|
||||||
// stderr write fails mid-envelope, handleRootError still returns the typed
|
// stderr write fails mid-envelope, handleRootError still returns the typed
|
||||||
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
|
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
|||||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||||
|
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||||
if risk == "high-risk-write" {
|
if risk == "high-risk-write" {
|
||||||
|
|||||||
@@ -765,3 +765,22 @@ func TestDetectFileFields(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||||
|
|
||||||
|
var captured *ServiceMethodOptions
|
||||||
|
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||||
|
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||||
|
func(opts *ServiceMethodOptions) error {
|
||||||
|
captured = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("--json should be accepted without error, got: %v", err)
|
||||||
|
}
|
||||||
|
if captured == nil {
|
||||||
|
t.Fatal("expected runF to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
183
cmd/skill/skill.go
Normal file
183
cmd/skill/skill.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package skill implements the `lark-cli skills` command group, which serves
|
||||||
|
// binary-embedded skill content to AI agents. The package is "skill"; the
|
||||||
|
// user-facing verb is "skills".
|
||||||
|
package skill
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/skillcontent"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newReader(f *cmdutil.Factory) (*skillcontent.Reader, error) {
|
||||||
|
if f.SkillContent == nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||||
|
"skill content not embedded in this build")
|
||||||
|
}
|
||||||
|
return skillcontent.New(f.SkillContent), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type readEnvelope struct {
|
||||||
|
Skill string `json:"skill"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Guidance string `json:"guidance,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listEnvelope struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Skills []skillcontent.SkillInfo `json:"skills"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listPathEnvelope struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Entries []skillcontent.DirEntry `json:"entries"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdSkill(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "skills",
|
||||||
|
Short: "Read embedded skill content (list / read)",
|
||||||
|
Long: "Read agent-readable skill content (SKILL.md and reference files) embedded in " +
|
||||||
|
"the CLI binary at build time, so it stays in sync with the CLI version. " +
|
||||||
|
"Machine resources such as assets/ and scripts/ are not embedded.",
|
||||||
|
}
|
||||||
|
// Risk is set on each leaf (GetRisk does not walk parents); the group has none.
|
||||||
|
cmdutil.DisableAuthCheck(cmd)
|
||||||
|
cmd.AddCommand(newListCmd(f), newReadCmd(f))
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newListCmd(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list [name[/path]]",
|
||||||
|
Short: "List skills, or list one layer under a skill path (like ls)",
|
||||||
|
Example: ` lark-cli skills list # all skills: name, description, version
|
||||||
|
lark-cli skills list lark-doc # one layer under a skill (like ls)
|
||||||
|
lark-cli skills list lark-doc/references # one layer under a subdirectory`,
|
||||||
|
Args: cobra.ArbitraryArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) > 1 {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"list takes at most 1 argument: [name[/path]]").
|
||||||
|
WithHint("run 'lark-cli skills list --help'")
|
||||||
|
}
|
||||||
|
r, err := newReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
skills, err := r.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
output.PrintJson(f.IOStreams.Out, listEnvelope{OK: true, Skills: skills, Count: len(skills)})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries, listed, err := r.ListPath(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
output.PrintJson(f.IOStreams.Out, listPathEnvelope{OK: true, Path: listed, Entries: entries, Count: len(entries)})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// --json is a no-op (list is always JSON), accepted only to stay symmetric with read.
|
||||||
|
cmd.Flags().Bool("json", false, "no-op (list output is always JSON)")
|
||||||
|
cmdutil.SetRisk(cmd, "read")
|
||||||
|
cmdutil.DisableAuthCheck(cmd)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newReadCmd(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
var asJSON bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "read <name>[/<path>] [path]",
|
||||||
|
Short: "Print a skill's SKILL.md, or a file under the skill (raw markdown by default)",
|
||||||
|
Example: ` lark-cli skills read lark-doc # the skill's SKILL.md
|
||||||
|
lark-cli skills read lark-doc references/lark-doc-fetch.md # a file under the skill
|
||||||
|
lark-cli skills read lark-doc/references/lark-doc-fetch.md # same, slash form
|
||||||
|
lark-cli skills read lark-doc --json # JSON envelope`,
|
||||||
|
Args: cobra.ArbitraryArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
name, relpath, err := parseReadTarget(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r, err := newReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var content []byte
|
||||||
|
var pathOut string
|
||||||
|
if relpath == "" {
|
||||||
|
content, err = r.ReadSkill(name)
|
||||||
|
pathOut = "SKILL.md"
|
||||||
|
} else {
|
||||||
|
content, pathOut, err = r.ReadReference(name, relpath)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
isMain := pathOut == "SKILL.md"
|
||||||
|
if asJSON {
|
||||||
|
env := readEnvelope{Skill: name, Path: pathOut, Content: string(content)}
|
||||||
|
if isMain {
|
||||||
|
env.Guidance = readGuidance(name)
|
||||||
|
}
|
||||||
|
output.PrintJson(f.IOStreams.Out, env)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Raw stdout stays byte-identical to the file; guidance goes to stderr.
|
||||||
|
if _, err := f.IOStreams.Out.Write(content); err != nil {
|
||||||
|
return errs.NewInternalError(errs.SubtypeFileIO, "failed to write output: %v", err)
|
||||||
|
}
|
||||||
|
if isMain {
|
||||||
|
fmt.Fprintln(f.IOStreams.ErrOut, readGuidance(name))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&asJSON, "json", false, "output as a JSON envelope instead of raw markdown")
|
||||||
|
cmdutil.SetRisk(cmd, "read")
|
||||||
|
cmdutil.DisableAuthCheck(cmd)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseReadTarget maps 1-or-2 positional args to (name, relpath); a lone
|
||||||
|
// "<a>/<b>" splits on the first '/', and relpath "" reads the main SKILL.md.
|
||||||
|
func parseReadTarget(args []string) (name, relpath string, err error) {
|
||||||
|
switch len(args) {
|
||||||
|
case 1:
|
||||||
|
name, relpath = skillcontent.SplitArg(args[0])
|
||||||
|
return name, relpath, nil
|
||||||
|
case 2:
|
||||||
|
return args[0], args[1], nil
|
||||||
|
default:
|
||||||
|
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"read requires 1 or 2 arguments: <name>[/<path>] [path]").
|
||||||
|
WithHint("run 'lark-cli skills read --help'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readGuidance routes cross-skill "../lark-foo/..." references back through
|
||||||
|
// `skills read lark-foo/...`: the path guard rejects a literal "../", so the
|
||||||
|
// relative form must be rewritten.
|
||||||
|
func readGuidance(name string) string {
|
||||||
|
return fmt.Sprintf("> Tip: read this skill's own files (e.g. `references/...`) with "+
|
||||||
|
"`lark-cli skills read %s <relative-path>` to keep them in sync with this CLI version. "+
|
||||||
|
"A reference to another skill (`../lark-foo/...`) uses the same command with the "+
|
||||||
|
"leading `../` removed: `lark-cli skills read lark-foo/...`.", name)
|
||||||
|
}
|
||||||
306
cmd/skill/skill_test.go
Normal file
306
cmd/skill/skill_test.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package skill
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// calFS is the default single-skill content tree for these tests. The embedded
|
||||||
|
// FS is now injected through the Factory (no package global), so tests pass it
|
||||||
|
// explicitly to run() — nothing is shared, so they are safe under -parallel.
|
||||||
|
func calFS() fstest.MapFS {
|
||||||
|
return fstest.MapFS{
|
||||||
|
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Cal\"\nmetadata:\n cliHelp: \"lark-cli calendar --help\"\n---\nbody")},
|
||||||
|
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run executes the skills command tree against the given content FS (may be nil
|
||||||
|
// to exercise the not-embedded path) and returns stdout/stderr/err.
|
||||||
|
func run(t *testing.T, fsys fs.FS, args ...string) (stdout, stderr string, err error) {
|
||||||
|
t.Helper()
|
||||||
|
// Isolate CLI config state so tests never read/write the real config dir
|
||||||
|
// (repo convention).
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, out, errOut, _ := cmdutil.TestFactory(t, nil)
|
||||||
|
f.SkillContent = fsys
|
||||||
|
cmd := NewCmdSkill(f)
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
cmd.SetErr(io.Discard)
|
||||||
|
cmd.SetArgs(args)
|
||||||
|
err = cmd.Execute()
|
||||||
|
return out.String(), errOut.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillList(t *testing.T) {
|
||||||
|
stdout, _, err := run(t, calFS(), "list")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list error: %v", err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Skills []map[string]any `json:"skills"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||||
|
}
|
||||||
|
// "ok" is an explicit success marker (the list envelope is a typed struct;
|
||||||
|
// no automatic _notice attaches).
|
||||||
|
if !got.OK {
|
||||||
|
t.Error("expected ok=true in list envelope")
|
||||||
|
}
|
||||||
|
if got.Count != 1 || len(got.Skills) != 1 {
|
||||||
|
t.Fatalf("count: got %d", got.Count)
|
||||||
|
}
|
||||||
|
if got.Skills[0]["name"] != "lark-calendar" {
|
||||||
|
t.Errorf("name: got %v", got.Skills[0]["name"])
|
||||||
|
}
|
||||||
|
// Top-level list carries version + metadata, not a references list.
|
||||||
|
if _, ok := got.Skills[0]["references"]; ok {
|
||||||
|
t.Error("top-level list must not include references")
|
||||||
|
}
|
||||||
|
if got.Skills[0]["version"] != "1.0.0" {
|
||||||
|
t.Errorf("version: got %v, want 1.0.0", got.Skills[0]["version"])
|
||||||
|
}
|
||||||
|
if _, ok := got.Skills[0]["metadata"]; !ok {
|
||||||
|
t.Error("expected metadata in list entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillListJSONFlagAccepted(t *testing.T) {
|
||||||
|
// `list --json` must be accepted (no-op), not rejected as an unknown flag,
|
||||||
|
// so it stays symmetric with read --json.
|
||||||
|
stdout, _, err := run(t, calFS(), "list", "--json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list --json error: %v", err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||||
|
}
|
||||||
|
if !got.OK || got.Count != 1 {
|
||||||
|
t.Errorf("envelope: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillListPath(t *testing.T) {
|
||||||
|
stdout, _, err := run(t, calFS(), "list", "lark-calendar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list <name> error: %v", err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Entries []struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
} `json:"entries"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||||
|
}
|
||||||
|
if !got.OK || got.Path != "lark-calendar" {
|
||||||
|
t.Errorf("envelope: %+v", got)
|
||||||
|
}
|
||||||
|
// One layer under the skill root: SKILL.md (file) + references (dir).
|
||||||
|
if got.Count != 2 || len(got.Entries) != 2 {
|
||||||
|
t.Fatalf("entries: got %+v", got.Entries)
|
||||||
|
}
|
||||||
|
if got.Entries[0].Path != "lark-calendar/SKILL.md" || got.Entries[0].IsDir {
|
||||||
|
t.Errorf("entry[0]: got %+v", got.Entries[0])
|
||||||
|
}
|
||||||
|
if got.Entries[1].Path != "lark-calendar/references" || !got.Entries[1].IsDir {
|
||||||
|
t.Errorf("entry[1]: got %+v", got.Entries[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillListPathUnknown(t *testing.T) {
|
||||||
|
_, _, err := run(t, calFS(), "list", "no-such-skill")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "unknown skill") {
|
||||||
|
t.Fatalf("expected 'unknown skill' error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillListPathTraversal(t *testing.T) {
|
||||||
|
stdout, _, err := run(t, calFS(), "list", "lark-calendar/../../etc")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid path") {
|
||||||
|
t.Fatalf("expected 'invalid path' error, got %v", err)
|
||||||
|
}
|
||||||
|
if stdout != "" {
|
||||||
|
t.Errorf("stdout must be empty on rejection, got %q", stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillListTooManyArgs(t *testing.T) {
|
||||||
|
_, _, err := run(t, calFS(), "list", "a", "b")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "at most 1 argument") {
|
||||||
|
t.Fatalf("expected 'at most 1 argument' error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSkillListSkipsDirWithoutSKILLmd proves a top-level dir lacking SKILL.md is
|
||||||
|
// omitted from the catalog (no blank entry).
|
||||||
|
func TestSkillListSkipsDirWithoutSKILLmd(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\ndescription: \"Cal\"\n---\nb")},
|
||||||
|
"not-a-skill/readme.txt": {Data: []byte("junk")}, // dir without SKILL.md
|
||||||
|
}
|
||||||
|
stdout, _, err := run(t, fsys, "list")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list error: %v", err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Skills []map[string]any `json:"skills"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||||
|
}
|
||||||
|
if got.Count != 1 || got.Skills[0]["name"] != "lark-calendar" {
|
||||||
|
t.Fatalf("expected only lark-calendar, got %+v", got.Skills)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillReadRaw(t *testing.T) {
|
||||||
|
stdout, stderr, err := run(t, calFS(), "read", "lark-calendar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(stdout, "---\nname: lark-calendar") {
|
||||||
|
t.Errorf("raw output: got %q", stdout)
|
||||||
|
}
|
||||||
|
// Raw stdout is byte-pure SKILL.md — the guidance tip must NOT be appended.
|
||||||
|
if strings.Contains(stdout, "Tip:") {
|
||||||
|
t.Errorf("raw stdout must not carry the guidance tip: got %q", stdout)
|
||||||
|
}
|
||||||
|
// Guidance goes to stderr: own files via `skills read <name> ...`, and
|
||||||
|
// cross-skill refs routed to `skills read <other-skill> ...` (version-
|
||||||
|
// consistent), not "read directly".
|
||||||
|
if !strings.Contains(stderr, "lark-cli skills read lark-calendar <relative-path>") {
|
||||||
|
t.Errorf("expected own-files guidance on stderr: got %q", stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "lark-cli skills read lark-foo/...") {
|
||||||
|
t.Errorf("expected cross-skill refs routed to skills read: got %q", stderr)
|
||||||
|
}
|
||||||
|
if strings.Contains(stderr, "instead of opening them directly") ||
|
||||||
|
strings.Contains(stderr, "read those directly") {
|
||||||
|
t.Errorf("guidance must not steer cross-skill refs to direct reads: got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillReadJSON(t *testing.T) {
|
||||||
|
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "--json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read --json error: %v", err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Skill, Path, Content, Guidance string
|
||||||
|
}
|
||||||
|
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", e)
|
||||||
|
}
|
||||||
|
if got.Skill != "lark-calendar" || got.Path != "SKILL.md" || got.Content == "" {
|
||||||
|
t.Errorf("envelope: %+v", got)
|
||||||
|
}
|
||||||
|
// Guidance is a separate field, not merged into content.
|
||||||
|
if got.Guidance == "" {
|
||||||
|
t.Error("expected guidance field for main SKILL.md")
|
||||||
|
}
|
||||||
|
if strings.Contains(got.Content, "Tip:") {
|
||||||
|
t.Error("guidance must not be merged into content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillReadFile(t *testing.T) {
|
||||||
|
// Both the 2-arg and slash forms read the same file, with no guidance tip.
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"read", "lark-calendar", "references/agenda.md"},
|
||||||
|
{"read", "lark-calendar/references/agenda.md"},
|
||||||
|
} {
|
||||||
|
stdout, stderr, err := run(t, calFS(), args...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %v error: %v", args, err)
|
||||||
|
}
|
||||||
|
if stdout != "# Agenda" {
|
||||||
|
t.Errorf("read %v output: got %q", args, stdout)
|
||||||
|
}
|
||||||
|
// Reference reads carry no guidance on either stream.
|
||||||
|
if strings.Contains(stderr, "Tip:") {
|
||||||
|
t.Errorf("read %v must not emit guidance on stderr: got %q", args, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillReadFileJSON(t *testing.T) {
|
||||||
|
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "references/agenda.md", "--json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read file --json error: %v", err)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Skill, Path, Content, Guidance string
|
||||||
|
}
|
||||||
|
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||||
|
}
|
||||||
|
if got.Skill != "lark-calendar" || got.Path != "references/agenda.md" || got.Content != "# Agenda" {
|
||||||
|
t.Errorf("envelope: %+v", got)
|
||||||
|
}
|
||||||
|
// Reference reads do not carry the guidance tip.
|
||||||
|
if got.Guidance != "" {
|
||||||
|
t.Errorf("reference read must not include guidance, got %q", got.Guidance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillReadUnknown(t *testing.T) {
|
||||||
|
_, _, err := run(t, calFS(), "read", "no-such")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unknown skill") {
|
||||||
|
t.Errorf("err: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillReadMissingArg(t *testing.T) {
|
||||||
|
_, _, err := run(t, calFS(), "read")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "requires 1 or 2 arguments") {
|
||||||
|
t.Fatalf("expected arg error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillReadTraversal(t *testing.T) {
|
||||||
|
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "../../etc/passwd")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected rejection")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid path") {
|
||||||
|
t.Errorf("err: %v", err)
|
||||||
|
}
|
||||||
|
if stdout != "" {
|
||||||
|
t.Errorf("stdout must be empty on rejection, got %q", stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillNilContentFS(t *testing.T) {
|
||||||
|
_, _, err := run(t, nil, "list")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when SkillContent is nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not embedded") {
|
||||||
|
t.Errorf("err: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,6 +73,149 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUnknownFlagTokens(t *testing.T) {
|
||||||
|
_, drive, _ := newGroupTree()
|
||||||
|
// Give a subcommand a flag so a misplaced-but-known flag (the user omitted
|
||||||
|
// the subcommand) is distinguished from a genuinely unknown one.
|
||||||
|
for _, c := range drive.Commands() {
|
||||||
|
if c.Name() == "+search" {
|
||||||
|
c.Flags().String("query", "", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rawArgs []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}},
|
||||||
|
{"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil},
|
||||||
|
{"no flags at all", []string{"drive"}, nil},
|
||||||
|
{"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil},
|
||||||
|
{"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := unknownFlagTokens(drive, tc.rawArgs)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tc.want[i] {
|
||||||
|
t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
|
||||||
|
_, drive, _ := newGroupTree()
|
||||||
|
installUnknownSubcommandGuard(drive.Root())
|
||||||
|
|
||||||
|
// Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows
|
||||||
|
// --badflag, so RunE sees no args; the guard must recover it from
|
||||||
|
// rawInvocationArgs and fail structured rather than print help + exit 0.
|
||||||
|
rawInvocationArgs = []string{"drive", "--badflag"}
|
||||||
|
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||||
|
|
||||||
|
err := drive.RunE(drive, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unknown flag") {
|
||||||
|
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
|
||||||
|
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
|
||||||
|
exitErr, ok := err.(*output.ExitError)
|
||||||
|
if !ok || exitErr.Detail == nil {
|
||||||
|
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
|
||||||
|
}
|
||||||
|
if exitErr.Detail.Type != "unknown_flag" {
|
||||||
|
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||||
|
}
|
||||||
|
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
|
||||||
|
}
|
||||||
|
if detail["unknown"] != "--badflag" {
|
||||||
|
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
|
||||||
|
}
|
||||||
|
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
|
||||||
|
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
|
||||||
|
}
|
||||||
|
for _, key := range []string{"suggestions", "valid_flags"} {
|
||||||
|
if _, present := detail[key]; !present {
|
||||||
|
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) {
|
||||||
|
_, drive, _ := newGroupTree()
|
||||||
|
// --query is defined on the +search subcommand, so it is a *valid* flag that
|
||||||
|
// was placed before the (omitted) subcommand. Unlike an unknown flag, this
|
||||||
|
// must still fail structured (missing_subcommand) rather than fall through to
|
||||||
|
// help + exit 0 — `drive --query x` is a malformed call, not a help request.
|
||||||
|
for _, c := range drive.Commands() {
|
||||||
|
if c.Name() == "+search" {
|
||||||
|
c.Flags().String("query", "", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
installUnknownSubcommandGuard(drive.Root())
|
||||||
|
|
||||||
|
rawInvocationArgs = []string{"drive", "--query", "x"}
|
||||||
|
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||||
|
|
||||||
|
err := drive.RunE(drive, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
|
||||||
|
}
|
||||||
|
var exitErr *output.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||||
|
}
|
||||||
|
if exitErr.Code != output.ExitValidation {
|
||||||
|
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||||
|
}
|
||||||
|
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
|
||||||
|
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
|
||||||
|
}
|
||||||
|
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||||
|
}
|
||||||
|
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
|
||||||
|
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
|
||||||
|
}
|
||||||
|
if detail["command_path"] != "lark-cli drive" {
|
||||||
|
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bare group carrying only a group-valid global flag (e.g. the inherited
|
||||||
|
// --profile) is not missing a subcommand — those flags do not belong to a
|
||||||
|
// subcommand — so it must print help, not fail with missing_subcommand.
|
||||||
|
func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) {
|
||||||
|
_, drive, _ := newGroupTree()
|
||||||
|
drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive
|
||||||
|
installUnknownSubcommandGuard(drive.Root())
|
||||||
|
|
||||||
|
rawInvocationArgs = []string{"--profile", "p", "drive"}
|
||||||
|
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
drive.SetOut(&buf)
|
||||||
|
drive.SetErr(&buf)
|
||||||
|
if err := drive.RunE(drive, nil); err != nil {
|
||||||
|
t.Fatalf("bare group with only a global flag should print help, got error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "drive ops") {
|
||||||
|
t.Errorf("expected help output, got:\n%s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
|
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
|
||||||
_, drive, _ := newGroupTree()
|
_, drive, _ := newGroupTree()
|
||||||
installUnknownSubcommandGuard(drive.Root())
|
installUnknownSubcommandGuard(drive.Root())
|
||||||
@@ -113,11 +257,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
|
|||||||
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
|
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
|
||||||
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
|
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
|
||||||
}
|
}
|
||||||
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
|
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
|
||||||
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
|
// back to pointing at --help; the full machine-readable list lives in
|
||||||
}
|
// detail.available below (which also excludes hidden commands).
|
||||||
if strings.Contains(exitErr.Detail.Hint, "+secret") {
|
if !strings.Contains(exitErr.Detail.Hint, "--help") {
|
||||||
t.Error("hidden commands must not appear in the hint")
|
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||||
@@ -164,7 +308,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
|||||||
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
|
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||||
)
|
)
|
||||||
|
|
||||||
got := availableSubcommandNames(root)
|
got, _ := availableSubcommandNames(root)
|
||||||
want := []string{"alpha", "gamma"}
|
want := []string{"alpha", "gamma"}
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("expected %v, got %v", want, got)
|
t.Fatalf("expected %v, got %v", want, got)
|
||||||
@@ -175,3 +319,61 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
|
||||||
|
root := &cobra.Command{Use: "lark-cli"}
|
||||||
|
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||||
|
root.AddCommand(
|
||||||
|
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||||
|
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||||
|
)
|
||||||
|
|
||||||
|
available, deprecated := availableSubcommandNames(root)
|
||||||
|
if len(available) != 1 || available[0] != "+new-cmd" {
|
||||||
|
t.Errorf("available = %v, want [+new-cmd]", available)
|
||||||
|
}
|
||||||
|
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
|
||||||
|
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unknownSubcommandRunE must split current vs deprecated subcommands into
|
||||||
|
// separate detail buckets, while suggestions still rank across both so a
|
||||||
|
// mistyped legacy alias resolves.
|
||||||
|
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
|
||||||
|
svc := &cobra.Command{Use: "sheets"}
|
||||||
|
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||||
|
svc.AddCommand(
|
||||||
|
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||||
|
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||||
|
)
|
||||||
|
|
||||||
|
err := unknownSubcommandRunE(svc, []string{"+reat"})
|
||||||
|
var exitErr *output.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||||
|
}
|
||||||
|
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
|
||||||
|
t.Errorf("available = %v, want [+cells-get]", available)
|
||||||
|
}
|
||||||
|
deprecated, ok := detail["deprecated"].([]string)
|
||||||
|
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
|
||||||
|
t.Errorf("deprecated = %v, want [+read]", deprecated)
|
||||||
|
}
|
||||||
|
// suggestions rank across both buckets: "+reat" is closest to +read.
|
||||||
|
suggestions, _ := detail["suggestions"].([]string)
|
||||||
|
found := false
|
||||||
|
for _, s := range suggestions {
|
||||||
|
if s == "+read" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,18 +49,29 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
|||||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||||
u.NpmInstallOverride = npmFn
|
u.NpmInstallOverride = npmFn
|
||||||
u.VerifyOverride = func(string) error { return nil }
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
|
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||||
|
return func() *selfupdate.NpmResult {
|
||||||
|
r := &selfupdate.NpmResult{}
|
||||||
|
r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||||
return func(args ...string) *selfupdate.NpmResult {
|
return func(args ...string) *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
switch strings.Join(args, " ") {
|
switch strings.Join(args, " ") {
|
||||||
case "-y skills add https://open.feishu.cn --list":
|
case "-y skills add https://open.feishu.cn --list":
|
||||||
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
|
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
|
||||||
|
case "-y skills ls -g --json":
|
||||||
|
r.Stdout.WriteString(`[{"name":"lark-calendar","path":"/tmp/lark-calendar","scope":"global","agents":["Codex"]},{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`)
|
||||||
case "-y skills ls -g":
|
case "-y skills ls -g":
|
||||||
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
|
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
|
||||||
default:
|
default:
|
||||||
@@ -476,6 +487,10 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
|||||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||||
u.RestoreAvailableOverride = func() bool { return false }
|
u.RestoreAvailableOverride = func() bool { return false }
|
||||||
|
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||||
|
t.Fatal("skills sync should not run when binary verification fails")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||||
t.Fatal("skills sync should not run when binary verification fails")
|
t.Fatal("skills sync should not run when binary verification fails")
|
||||||
return nil
|
return nil
|
||||||
@@ -808,6 +823,11 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||||
u.VerifyOverride = func(string) error { return nil }
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
|
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||||
|
r := &selfupdate.NpmResult{}
|
||||||
|
r.Err = fmt.Errorf("index unavailable")
|
||||||
|
return r
|
||||||
|
}
|
||||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
r.Stderr.WriteString("npx: command not found")
|
r.Stderr.WriteString("npx: command not found")
|
||||||
@@ -860,6 +880,11 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
|||||||
}
|
}
|
||||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||||
u.VerifyOverride = func(string) error { return nil }
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
|
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||||
|
r := &selfupdate.NpmResult{}
|
||||||
|
r.Err = fmt.Errorf("index unavailable")
|
||||||
|
return r
|
||||||
|
}
|
||||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
r.Stderr.WriteString("npx: command not found")
|
r.Stderr.WriteString("npx: command not found")
|
||||||
@@ -1004,6 +1029,7 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
return &selfupdate.Updater{
|
return &selfupdate.Updater{
|
||||||
|
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||||
skillsCalled = true
|
skillsCalled = true
|
||||||
return successfulSkillsCommand()(args...)
|
return successfulSkillsCommand()(args...)
|
||||||
@@ -1042,6 +1068,7 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
return &selfupdate.Updater{
|
return &selfupdate.Updater{
|
||||||
|
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||||
DetectOverride: func() selfupdate.DetectResult {
|
DetectOverride: func() selfupdate.DetectResult {
|
||||||
return selfupdate.DetectResult{
|
return selfupdate.DetectResult{
|
||||||
Method: selfupdate.InstallManual,
|
Method: selfupdate.InstallManual,
|
||||||
@@ -1086,6 +1113,7 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
return &selfupdate.Updater{
|
return &selfupdate.Updater{
|
||||||
|
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||||
DetectOverride: func() selfupdate.DetectResult {
|
DetectOverride: func() selfupdate.DetectResult {
|
||||||
return selfupdate.DetectResult{
|
return selfupdate.DetectResult{
|
||||||
Method: selfupdate.InstallNpm, NpmAvailable: true,
|
Method: selfupdate.InstallNpm, NpmAvailable: true,
|
||||||
@@ -1145,6 +1173,10 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
|||||||
DetectOverride: func() selfupdate.DetectResult {
|
DetectOverride: func() selfupdate.DetectResult {
|
||||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
||||||
},
|
},
|
||||||
|
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
||||||
|
skillsCalled = true
|
||||||
|
return successfulSkillsIndexFetch()()
|
||||||
|
},
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||||
skillsCalled = true
|
skillsCalled = true
|
||||||
return successfulSkillsCommand()(args...)
|
return successfulSkillsCommand()(args...)
|
||||||
@@ -1194,6 +1226,10 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
return &selfupdate.Updater{
|
return &selfupdate.Updater{
|
||||||
|
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
||||||
|
skillsCalled = true
|
||||||
|
return successfulSkillsIndexFetch()()
|
||||||
|
},
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||||
skillsCalled = true
|
skillsCalled = true
|
||||||
return successfulSkillsCommand()(args...)
|
return successfulSkillsCommand()(args...)
|
||||||
|
|||||||
@@ -155,7 +155,30 @@ caller scripts.
|
|||||||
|
|
||||||
New code should not reach for `ErrBare` unless the command is
|
New code should not reach for `ErrBare` unless the command is
|
||||||
genuinely a predicate. Anything carrying recoverable error content
|
genuinely a predicate. Anything carrying recoverable error content
|
||||||
belongs in a typed `*errs.XxxError`.
|
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
|
||||||
|
partial-failure outcome below.
|
||||||
|
|
||||||
|
### Partial failure (batch / multi-status)
|
||||||
|
|
||||||
|
A batch command (e.g. `drive +push` / `+pull` / `+sync`) that processes
|
||||||
|
many items can finish in a third state, neither full success nor a single
|
||||||
|
error: some items succeeded and some failed. Its primary output is the
|
||||||
|
per-item result, so it does **not** belong in a `stderr` error envelope.
|
||||||
|
|
||||||
|
Such a command returns `runtime.OutPartialFailure(data, meta)`, which:
|
||||||
|
|
||||||
|
1. writes the full result to **stdout** as an `ok:false` envelope — the
|
||||||
|
summary and every per-item outcome (succeeded *and* failed) stay
|
||||||
|
machine-readable, exactly as a successful `Out(...)` would carry them,
|
||||||
|
but with `ok` honestly reporting failure; and
|
||||||
|
2. returns `*output.PartialFailureError`, a typed exit signal the
|
||||||
|
dispatcher maps to a non-zero exit code while writing nothing further
|
||||||
|
to `stderr`.
|
||||||
|
|
||||||
|
This is distinct from `ErrBare` (a predicate's one-bit answer) and from a
|
||||||
|
typed `*errs.XxxError` (a `stderr` error envelope): a partial failure is a
|
||||||
|
*result*, reported on stdout, that also failed. Consumers branch on
|
||||||
|
`ok == false` and then read `data.summary` / `data.items[]`.
|
||||||
|
|
||||||
## Consumers
|
## Consumers
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ const (
|
|||||||
|
|
||||||
// CategoryValidation subtypes
|
// CategoryValidation subtypes
|
||||||
const (
|
const (
|
||||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||||
|
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||||
)
|
)
|
||||||
|
|
||||||
// CategoryAuthentication subtypes
|
// CategoryAuthentication subtypes
|
||||||
@@ -79,6 +80,7 @@ const (
|
|||||||
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
|
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
|
||||||
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
|
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
|
||||||
SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read)
|
SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read)
|
||||||
|
SubtypeExternalTool Subtype = "external_tool" // an external tool the CLI shells out to (git, npx) failed at runtime; the tool output is in the message
|
||||||
SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save)
|
SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save)
|
||||||
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
|
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,8 +61,22 @@ type TypedError interface {
|
|||||||
// it is intentionally not serialized.
|
// it is intentionally not serialized.
|
||||||
type ValidationError struct {
|
type ValidationError struct {
|
||||||
Problem
|
Problem
|
||||||
Param string `json:"param,omitempty"`
|
Param string `json:"param,omitempty"`
|
||||||
Cause error `json:"-"`
|
Params []InvalidParam `json:"params,omitempty"`
|
||||||
|
Cause error `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidParam is one structured validation diagnostic: the parameter that
|
||||||
|
// failed (Name) and why (Reason). It mirrors an RFC 7807 "invalid-params"
|
||||||
|
// item (RFC 7807 §3.1 extension members).
|
||||||
|
//
|
||||||
|
// The wire key on ValidationError is "params" rather than "invalid_params"
|
||||||
|
// because the enclosing envelope already carries type:"validation", so the
|
||||||
|
// "invalid" qualifier would be redundant on the wire. The Go type keeps the
|
||||||
|
// InvalidParam prefix because, at package level, the name must self-describe.
|
||||||
|
type InvalidParam struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
|
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
|
||||||
@@ -122,6 +136,11 @@ func (e *ValidationError) WithParam(param string) *ValidationError {
|
|||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *ValidationError) WithParams(params ...InvalidParam) *ValidationError {
|
||||||
|
e.Params = append(e.Params, params...)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
func (e *ValidationError) WithCause(cause error) *ValidationError {
|
func (e *ValidationError) WithCause(cause error) *ValidationError {
|
||||||
e.Cause = cause
|
e.Cause = cause
|
||||||
return e
|
return e
|
||||||
|
|||||||
@@ -558,6 +558,71 @@ func TestTypedError_UnwrapSymmetry(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestValidationError_WithParams covers the structured-validation extension:
|
||||||
|
// WithParams appends InvalidParam items, the scalar Param setter is unaffected,
|
||||||
|
// and the wire shape nests {name, reason} under "params" (omitted when empty).
|
||||||
|
func TestValidationError_WithParams(t *testing.T) {
|
||||||
|
t.Run("appends and exposes fields", func(t *testing.T) {
|
||||||
|
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
|
||||||
|
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
|
||||||
|
if len(e.Params) != 1 {
|
||||||
|
t.Fatalf("len(Params) = %d, want 1", len(e.Params))
|
||||||
|
}
|
||||||
|
if e.Params[0].Name != "a.md" {
|
||||||
|
t.Errorf("Params[0].Name = %q, want %q", e.Params[0].Name, "a.md")
|
||||||
|
}
|
||||||
|
if e.Params[0].Reason != "duplicate" {
|
||||||
|
t.Errorf("Params[0].Reason = %q, want %q", e.Params[0].Reason, "duplicate")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("appends across multiple calls and returns receiver", func(t *testing.T) {
|
||||||
|
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
|
||||||
|
returned := e.WithParams(errs.InvalidParam{Name: "a.md", Reason: "dup"})
|
||||||
|
if returned != e {
|
||||||
|
t.Errorf("WithParams returned different pointer; want same as receiver")
|
||||||
|
}
|
||||||
|
e.WithParams(
|
||||||
|
errs.InvalidParam{Name: "b.md", Reason: "dup"},
|
||||||
|
errs.InvalidParam{Name: "c.md", Reason: "dup"},
|
||||||
|
)
|
||||||
|
if len(e.Params) != 3 {
|
||||||
|
t.Fatalf("len(Params) = %d after two calls, want 3", len(e.Params))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wire shape nests name and reason under params", func(t *testing.T) {
|
||||||
|
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
|
||||||
|
WithParam("--rel-path").
|
||||||
|
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
|
||||||
|
b, err := json.Marshal(e)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
got := string(b)
|
||||||
|
for _, want := range []string{
|
||||||
|
`"type":"validation"`,
|
||||||
|
`"param":"--rel-path"`,
|
||||||
|
`"params":[{"name":"a.md","reason":"duplicate"}]`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("missing %q in %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty Params omitted from wire", func(t *testing.T) {
|
||||||
|
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
|
||||||
|
b, err := json.Marshal(e)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(b), `"params"`) {
|
||||||
|
t.Errorf("empty Params should be omitted from wire; got %s", b)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuilderSetter_DefensiveCopy(t *testing.T) {
|
func TestBuilderSetter_DefensiveCopy(t *testing.T) {
|
||||||
t.Run("WithMissingScopes clones input", func(t *testing.T) {
|
t.Run("WithMissingScopes clones input", func(t *testing.T) {
|
||||||
scopes := []string{"docx:document", "im:message:send"}
|
scopes := []string{"docx:document", "im:message:send"}
|
||||||
|
|||||||
@@ -5,18 +5,19 @@ package minutes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/event"
|
"github.com/larksuite/cli/internal/event"
|
||||||
)
|
)
|
||||||
|
|
||||||
const cleanupTimeout = 5 * time.Second
|
const cleanupTimeout = 5 * time.Second
|
||||||
|
|
||||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||||
if rt == nil {
|
if rt == nil {
|
||||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||||
|
"runtime API client is required for pre-consume subscription")
|
||||||
}
|
}
|
||||||
|
|
||||||
body := map[string]string{"event_type": eventType}
|
body := map[string]string{"event_type": eventType}
|
||||||
@@ -24,10 +25,13 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return func() {
|
return func() error {
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/larksuite/cli/events/im"
|
"github.com/larksuite/cli/events/im"
|
||||||
"github.com/larksuite/cli/events/minutes"
|
"github.com/larksuite/cli/events/minutes"
|
||||||
"github.com/larksuite/cli/events/vc"
|
"github.com/larksuite/cli/events/vc"
|
||||||
|
"github.com/larksuite/cli/events/whiteboard"
|
||||||
"github.com/larksuite/cli/internal/event"
|
"github.com/larksuite/cli/internal/event"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ func init() {
|
|||||||
im.Keys(),
|
im.Keys(),
|
||||||
minutes.Keys(),
|
minutes.Keys(),
|
||||||
vc.Keys(),
|
vc.Keys(),
|
||||||
|
whiteboard.Keys(),
|
||||||
}
|
}
|
||||||
for _, keys := range all {
|
for _, keys := range all {
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
|
|||||||
35
events/vc/note_detail_retry_test.go
Normal file
35
events/vc/note_detail_retry_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package vc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isLarkCode must match the API code on typed errs.* errors — the consume
|
||||||
|
// runtime classifies OAPI failures via errclass.BuildAPIError, so the
|
||||||
|
// not-found retry in fillVCNoteGeneratedDetails depends on this reading
|
||||||
|
// Problem.Code rather than the legacy envelope shape.
|
||||||
|
func TestIsLarkCode_MatchesTypedAPIErrorCode(t *testing.T) {
|
||||||
|
typedNotFound := errs.NewAPIError(errs.SubtypeNotFound, "note not ready").
|
||||||
|
WithCode(vcNoteDetailNotFoundCode)
|
||||||
|
if !isLarkCode(typedNotFound, vcNoteDetailNotFoundCode) {
|
||||||
|
t.Fatal("typed API error carrying the not-found code must match (retry path)")
|
||||||
|
}
|
||||||
|
if isLarkCode(typedNotFound, 99999) {
|
||||||
|
t.Error("a different expected code must not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
otherTyped := errs.NewAPIError(errs.SubtypeServerError, "boom").WithCode(500)
|
||||||
|
if isLarkCode(otherTyped, vcNoteDetailNotFoundCode) {
|
||||||
|
t.Error("typed error with another code must not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLarkCode(errors.New("plain failure"), vcNoteDetailNotFoundCode) {
|
||||||
|
t.Error("untyped error must not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,11 @@ package vc
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/event"
|
"github.com/larksuite/cli/internal/event"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -148,9 +147,8 @@ func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VC
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isLarkCode(err error, code int) bool {
|
func isLarkCode(err error, code int) bool {
|
||||||
var exitErr *output.ExitError
|
if p, ok := errs.ProblemOf(err); ok {
|
||||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
return p.Code == code
|
||||||
return exitErr.Detail.Code == code
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,19 @@ package vc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/event"
|
"github.com/larksuite/cli/internal/event"
|
||||||
)
|
)
|
||||||
|
|
||||||
const cleanupTimeout = 5 * time.Second
|
const cleanupTimeout = 5 * time.Second
|
||||||
|
|
||||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||||
if rt == nil {
|
if rt == nil {
|
||||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||||
|
"runtime API client is required for pre-consume subscription")
|
||||||
}
|
}
|
||||||
|
|
||||||
body := map[string]string{"event_type": eventType}
|
body := map[string]string{"event_type": eventType}
|
||||||
@@ -24,10 +25,13 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return func() {
|
return func() error {
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
events/vc/recording_ended.go
Normal file
84
events/vc/recording_ended.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package vc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1.
|
||||||
|
type VCRecordingEndedOutput struct {
|
||||||
|
Type string `json:"type" desc:"Event type; always vc.recording.recording_ended_v1"`
|
||||||
|
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||||
|
EventTime string `json:"event_time,omitempty" desc:"Time when the recording ended and uploaded successfully, in RFC3339 / ISO 8601 with the current system timezone"`
|
||||||
|
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||||
|
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingEndedEnvelope struct {
|
||||||
|
Header struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
CreateTime string `json:"create_time"`
|
||||||
|
} `json:"header"`
|
||||||
|
Event recordingEndedEvent `json:"event"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingEndedEvent struct {
|
||||||
|
UniqueKey string `json:"unique_key"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||||
|
envelope, ok := parseRecordingEndedEnvelope(raw)
|
||||||
|
if !ok {
|
||||||
|
return raw.Payload, nil
|
||||||
|
}
|
||||||
|
if !isRecordingEndedBeanEvent(envelope) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
out := &VCRecordingEndedOutput{
|
||||||
|
Type: recordingEndedEventType(envelope, raw),
|
||||||
|
EventID: envelope.Header.EventID,
|
||||||
|
EventTime: recordingEndedEventTime(envelope.Header.CreateTime),
|
||||||
|
UniqueKey: envelope.Event.UniqueKey,
|
||||||
|
Source: envelope.Event.Source,
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRecordingEndedEnvelope(raw *event.RawEvent) (*recordingEndedEnvelope, bool) {
|
||||||
|
var envelope recordingEndedEnvelope
|
||||||
|
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &envelope, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRecordingEndedBeanEvent(envelope *recordingEndedEnvelope) bool {
|
||||||
|
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordingEndedEventType(envelope *recordingEndedEnvelope, raw *event.RawEvent) string {
|
||||||
|
if envelope != nil && envelope.Header.EventType != "" {
|
||||||
|
return envelope.Header.EventType
|
||||||
|
}
|
||||||
|
return raw.EventType
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordingEndedEventTime(raw string) string {
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||||
|
}
|
||||||
84
events/vc/recording_started.go
Normal file
84
events/vc/recording_started.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package vc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1.
|
||||||
|
type VCRecordingStartedOutput struct {
|
||||||
|
Type string `json:"type" desc:"Event type; always vc.recording.recording_started_v1"`
|
||||||
|
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||||
|
EventTime string `json:"event_time,omitempty" desc:"Recording start time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||||
|
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||||
|
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingStartedEnvelope struct {
|
||||||
|
Header struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
CreateTime string `json:"create_time"`
|
||||||
|
} `json:"header"`
|
||||||
|
Event recordingStartedEvent `json:"event"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingStartedEvent struct {
|
||||||
|
UniqueKey string `json:"unique_key"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||||
|
envelope, ok := parseRecordingStartedEnvelope(raw)
|
||||||
|
if !ok {
|
||||||
|
return raw.Payload, nil
|
||||||
|
}
|
||||||
|
if !isRecordingStartedBeanEvent(envelope) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
out := &VCRecordingStartedOutput{
|
||||||
|
Type: recordingStartedEventType(envelope, raw),
|
||||||
|
EventID: envelope.Header.EventID,
|
||||||
|
EventTime: recordingStartedEventTime(envelope.Header.CreateTime),
|
||||||
|
UniqueKey: envelope.Event.UniqueKey,
|
||||||
|
Source: envelope.Event.Source,
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRecordingStartedEnvelope(raw *event.RawEvent) (*recordingStartedEnvelope, bool) {
|
||||||
|
var envelope recordingStartedEnvelope
|
||||||
|
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &envelope, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRecordingStartedBeanEvent(envelope *recordingStartedEnvelope) bool {
|
||||||
|
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordingStartedEventType(envelope *recordingStartedEnvelope, raw *event.RawEvent) string {
|
||||||
|
if envelope != nil && envelope.Header.EventType != "" {
|
||||||
|
return envelope.Header.EventType
|
||||||
|
}
|
||||||
|
return raw.EventType
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordingStartedEventTime(raw string) string {
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||||
|
}
|
||||||
468
events/vc/recording_test.go
Normal file
468
events/vc/recording_test.go
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package vc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVCKeys_RecordingEventsRegistered(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
eventType string
|
||||||
|
}{
|
||||||
|
{eventTypeRecordingStarted},
|
||||||
|
{eventTypeRecordingTranscriptGenerated},
|
||||||
|
{eventTypeRecordingEnded},
|
||||||
|
} {
|
||||||
|
t.Run(tc.eventType, func(t *testing.T) {
|
||||||
|
def, ok := event.Lookup(tc.eventType)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
||||||
|
}
|
||||||
|
if def.Schema.Custom == nil {
|
||||||
|
t.Error("Processed key must set Schema.Custom")
|
||||||
|
}
|
||||||
|
if def.Schema.Native != nil {
|
||||||
|
t.Error("Processed key must not set Schema.Native")
|
||||||
|
}
|
||||||
|
if def.Process == nil {
|
||||||
|
t.Error("Process must not be nil for processed key")
|
||||||
|
}
|
||||||
|
if def.PreConsume == nil {
|
||||||
|
t.Error("PreConsume must not be nil for processed key")
|
||||||
|
}
|
||||||
|
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:recording:read" {
|
||||||
|
t.Errorf("Scopes = %v", def.Scopes)
|
||||||
|
}
|
||||||
|
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||||
|
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||||
|
}
|
||||||
|
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
|
||||||
|
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
|
||||||
|
}
|
||||||
|
if !strings.Contains(def.Description, "recording_bean") {
|
||||||
|
t.Errorf("Description should document recording_bean source, got %q", def.Description)
|
||||||
|
}
|
||||||
|
if !strings.Contains(def.Description, "connected to Feishu software") {
|
||||||
|
t.Errorf("Description should document Feishu software connection requirement, got %q", def.Description)
|
||||||
|
}
|
||||||
|
if strings.Contains(def.Description, "future") || strings.Contains(def.Description, "software_recording") {
|
||||||
|
t.Errorf("Description should not mention future sources, got %q", def.Description)
|
||||||
|
}
|
||||||
|
if tc.eventType == eventTypeRecordingEnded && (strings.Contains(def.Description, "object_type") || strings.Contains(def.Description, "object_id")) {
|
||||||
|
t.Errorf("ended Description should not document object metadata, got %q", def.Description)
|
||||||
|
}
|
||||||
|
wantSchemaType := reflect.TypeOf(VCRecordingStartedOutput{})
|
||||||
|
switch tc.eventType {
|
||||||
|
case eventTypeRecordingTranscriptGenerated:
|
||||||
|
wantSchemaType = reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})
|
||||||
|
case eventTypeRecordingEnded:
|
||||||
|
wantSchemaType = reflect.TypeOf(VCRecordingEndedOutput{})
|
||||||
|
}
|
||||||
|
if def.Schema.Custom.Type != wantSchemaType {
|
||||||
|
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, wantSchemaType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessVCRecordingStarted(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
out := runRecordingProcess[VCRecordingStartedOutput](t, eventTypeRecordingStarted, processVCRecordingStarted, `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_rec_start_001",
|
||||||
|
"event_type": "vc.recording.recording_started_v1",
|
||||||
|
"create_time": "1761782400000"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"unique_key": "recording_001",
|
||||||
|
"source": "recording_bean"
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
if out.Type != eventTypeRecordingStarted {
|
||||||
|
t.Errorf("Type = %q", out.Type)
|
||||||
|
}
|
||||||
|
if out.EventID != "ev_rec_start_001" || out.EventTime != recordingTestEventTime(1761782400000) {
|
||||||
|
t.Errorf("EventID/EventTime = %q/%q", out.EventID, out.EventTime)
|
||||||
|
}
|
||||||
|
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||||
|
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessVCRecordingTranscriptGenerated(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
got := runRecordingProcessRaw(t, eventTypeRecordingTranscriptGenerated, processVCRecordingTranscriptGenerated, `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_rec_transcript_001",
|
||||||
|
"event_type": "vc.recording.recording_transcript_generated_v1",
|
||||||
|
"create_time": "1761782400100"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"unique_key": "recording_001",
|
||||||
|
"source": "recording_bean",
|
||||||
|
"transcript_items": [
|
||||||
|
{
|
||||||
|
"speaker": {
|
||||||
|
"id": {
|
||||||
|
"open_id": "ou_0f8bf7acdf2ae69553ecbdbfbbd10a53",
|
||||||
|
"union_id": "on_bc03f16d781bff4178a5d11e48eb1867",
|
||||||
|
"user_id": null
|
||||||
|
},
|
||||||
|
"user_type": 100,
|
||||||
|
"user_role": 1,
|
||||||
|
"user_name": "Alice"
|
||||||
|
},
|
||||||
|
"text": "hello world",
|
||||||
|
"language": "en_us",
|
||||||
|
"start_time_ms": "1761782399000",
|
||||||
|
"end_time_ms": "1761782400000",
|
||||||
|
"sentence_id": "987654321"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"speaker": {
|
||||||
|
"user_name": "Bob"
|
||||||
|
},
|
||||||
|
"text": "second sentence",
|
||||||
|
"language": "en_us",
|
||||||
|
"start_time_ms": "1761782401000",
|
||||||
|
"end_time_ms": "1761782402000",
|
||||||
|
"sentence_id": "987654322"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("Process output is nil")
|
||||||
|
}
|
||||||
|
var out VCRecordingTranscriptGeneratedOutput
|
||||||
|
if err := json.Unmarshal(got, &out); err != nil {
|
||||||
|
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Type != eventTypeRecordingTranscriptGenerated {
|
||||||
|
t.Errorf("Type = %q", out.Type)
|
||||||
|
}
|
||||||
|
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||||
|
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||||
|
}
|
||||||
|
if out.EventTime != recordingTestEventTime(1761782400100) {
|
||||||
|
t.Errorf("EventTime = %q", out.EventTime)
|
||||||
|
}
|
||||||
|
if len(out.TranscriptItems) != 2 {
|
||||||
|
t.Fatalf("TranscriptItems len = %d, want 2", len(out.TranscriptItems))
|
||||||
|
}
|
||||||
|
item := out.TranscriptItems[0]
|
||||||
|
if item.SpeakerName != "Alice" || item.Text != "hello world" {
|
||||||
|
t.Errorf("Transcript speaker/text = %q/%q", item.SpeakerName, item.Text)
|
||||||
|
}
|
||||||
|
if item.StartTime != recordingTestEventTime(1761782399000) || item.EndTime != recordingTestEventTime(1761782400000) {
|
||||||
|
t.Errorf("Transcript timing = %q/%q", item.StartTime, item.EndTime)
|
||||||
|
}
|
||||||
|
if item.SentenceID != "987654321" {
|
||||||
|
t.Errorf("SentenceID = %q, want 987654321", item.SentenceID)
|
||||||
|
}
|
||||||
|
if out.TranscriptItems[1].SpeakerName != "Bob" || out.TranscriptItems[1].SentenceID != "987654322" {
|
||||||
|
t.Errorf("second transcript item = %+v", out.TranscriptItems[1])
|
||||||
|
}
|
||||||
|
itemJSON, err := json.Marshal(item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal transcript item: %v", err)
|
||||||
|
}
|
||||||
|
var itemFields map[string]any
|
||||||
|
if err := json.Unmarshal(itemJSON, &itemFields); err != nil {
|
||||||
|
t.Fatalf("unmarshal transcript item JSON: %v", err)
|
||||||
|
}
|
||||||
|
wantItemFields := map[string]bool{
|
||||||
|
"speaker_name": true,
|
||||||
|
"text": true,
|
||||||
|
"start_time": true,
|
||||||
|
"end_time": true,
|
||||||
|
"sentence_id": true,
|
||||||
|
}
|
||||||
|
for gotField := range itemFields {
|
||||||
|
if !wantItemFields[gotField] {
|
||||||
|
t.Errorf("Transcript item should not contain field %q, got %s", gotField, string(itemJSON))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for wantField := range wantItemFields {
|
||||||
|
if _, ok := itemFields[wantField]; !ok {
|
||||||
|
t.Errorf("Transcript item missing field %q, got %s", wantField, string(itemJSON))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unexpected := range []string{
|
||||||
|
`"seq_id"`,
|
||||||
|
`"speaker"`,
|
||||||
|
`"user_open_id"`,
|
||||||
|
`"user_type"`,
|
||||||
|
`"user_role"`,
|
||||||
|
`"language"`,
|
||||||
|
`"start_time_ms"`,
|
||||||
|
`"end_time_ms"`,
|
||||||
|
`"sequence_id"`,
|
||||||
|
`"transcript_item"`,
|
||||||
|
} {
|
||||||
|
if strings.Contains(string(got), unexpected) {
|
||||||
|
t.Errorf("Transcript output should not contain %s, got %s", unexpected, string(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(got), `"sentence_id":"987654321"`) {
|
||||||
|
t.Errorf("Transcript output should contain sentence_id, got %s", string(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessVCRecordingEnded(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
out := runRecordingProcess[VCRecordingEndedOutput](t, eventTypeRecordingEnded, processVCRecordingEnded, `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_rec_end_001",
|
||||||
|
"event_type": "vc.recording.recording_ended_v1",
|
||||||
|
"create_time": "1761782400200"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"unique_key": "recording_001",
|
||||||
|
"source": "recording_bean",
|
||||||
|
"object_type": "minutes",
|
||||||
|
"object_id": "minute_token_001"
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
if out.Type != eventTypeRecordingEnded {
|
||||||
|
t.Errorf("Type = %q", out.Type)
|
||||||
|
}
|
||||||
|
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||||
|
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||||
|
}
|
||||||
|
if out.EventTime != recordingTestEventTime(1761782400200) {
|
||||||
|
t.Errorf("EventTime = %q", out.EventTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessVCRecordingEnded_DropsObjectMetadata(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
got := runRecordingProcessRaw(t, eventTypeRecordingEnded, processVCRecordingEnded, `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_rec_end_001",
|
||||||
|
"event_type": "vc.recording.recording_ended_v1",
|
||||||
|
"create_time": "1761782400200"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"unique_key": "recording_001",
|
||||||
|
"source": "recording_bean",
|
||||||
|
"object_type": "minutes",
|
||||||
|
"object_id": "minute_token_001"
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
if strings.Contains(string(got), "object_type") || strings.Contains(string(got), "object_id") {
|
||||||
|
t.Fatalf("ended output should drop object metadata, got %s", string(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessVCRecording_DropsTimestampField(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
got := runRecordingProcessRaw(t, eventTypeRecordingStarted, processVCRecordingStarted, `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"event_id": "ev_rec_start_001",
|
||||||
|
"event_type": "vc.recording.recording_started_v1",
|
||||||
|
"create_time": "1761782400000"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"unique_key": "recording_001",
|
||||||
|
"source": "recording_bean"
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
if strings.Contains(string(got), `"timestamp"`) {
|
||||||
|
t.Fatalf("recording output should use event_time instead of timestamp, got %s", string(got))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(got), `"event_time":"`+recordingTestEventTime(1761782400000)+`"`) {
|
||||||
|
t.Fatalf("recording output should include ISO 8601 event_time, got %s", string(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
eventType string
|
||||||
|
process event.ProcessFunc
|
||||||
|
payload string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "started",
|
||||||
|
eventType: eventTypeRecordingStarted,
|
||||||
|
process: processVCRecordingStarted,
|
||||||
|
payload: `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {"event_id": "ev_rec_start_001", "event_type": "vc.recording.recording_started_v1"},
|
||||||
|
"event": {"unique_key": "recording_001", "source": "software_recording"}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "transcript",
|
||||||
|
eventType: eventTypeRecordingTranscriptGenerated,
|
||||||
|
process: processVCRecordingTranscriptGenerated,
|
||||||
|
payload: `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {"event_id": "ev_rec_transcript_001", "event_type": "vc.recording.recording_transcript_generated_v1"},
|
||||||
|
"event": {"unique_key": "recording_001", "source": "software_recording", "transcript_items": []}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ended",
|
||||||
|
eventType: eventTypeRecordingEnded,
|
||||||
|
process: processVCRecordingEnded,
|
||||||
|
payload: `{
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {"event_id": "ev_rec_end_001", "event_type": "vc.recording.recording_ended_v1"},
|
||||||
|
"event": {"unique_key": "recording_001", "source": "software_recording"}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := runRecordingProcessRaw(t, tc.eventType, tc.process, tc.payload)
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("non-recording_bean event should be filtered, got %s", string(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
eventType string
|
||||||
|
process event.ProcessFunc
|
||||||
|
}{
|
||||||
|
{name: "started", eventType: eventTypeRecordingStarted, process: processVCRecordingStarted},
|
||||||
|
{name: "transcript", eventType: eventTypeRecordingTranscriptGenerated, process: processVCRecordingTranscriptGenerated},
|
||||||
|
{name: "ended", eventType: eventTypeRecordingEnded, process: processVCRecordingEnded},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
raw := &event.RawEvent{
|
||||||
|
EventType: tc.eventType,
|
||||||
|
Payload: json.RawMessage(`not json`),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
got, err := tc.process(context.Background(), nil, raw, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != "not json" {
|
||||||
|
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
eventType string
|
||||||
|
}{
|
||||||
|
{eventTypeRecordingStarted},
|
||||||
|
{eventTypeRecordingTranscriptGenerated},
|
||||||
|
{eventTypeRecordingEnded},
|
||||||
|
} {
|
||||||
|
t.Run(tc.eventType, func(t *testing.T) {
|
||||||
|
def, ok := event.Lookup(tc.eventType)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
||||||
|
}
|
||||||
|
|
||||||
|
type call struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body any
|
||||||
|
}
|
||||||
|
var calls []call
|
||||||
|
rt := &stubAPIClient{
|
||||||
|
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||||
|
calls = append(calls, call{method: method, path: path, body: body})
|
||||||
|
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PreConsume error: %v", err)
|
||||||
|
}
|
||||||
|
if cleanup == nil {
|
||||||
|
t.Fatal("cleanup must not be nil")
|
||||||
|
}
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
||||||
|
}
|
||||||
|
if calls[0].method != "POST" || calls[0].path != pathRecordingSubscribe {
|
||||||
|
t.Fatalf("subscribe call = %+v", calls[0])
|
||||||
|
}
|
||||||
|
assertSubscriptionRequest(t, calls[0].body, tc.eventType)
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
if len(calls) != 2 {
|
||||||
|
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||||
|
}
|
||||||
|
if calls[1].method != "POST" || calls[1].path != pathRecordingUnsubscribe {
|
||||||
|
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||||
|
}
|
||||||
|
assertSubscriptionRequest(t, calls[1].body, tc.eventType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRecordingProcess[T any](t *testing.T, eventType string, process event.ProcessFunc, payload string) T {
|
||||||
|
t.Helper()
|
||||||
|
got := runRecordingProcessRaw(t, eventType, process, payload)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("Process output is nil")
|
||||||
|
}
|
||||||
|
var out T
|
||||||
|
if err := json.Unmarshal(got, &out); err != nil {
|
||||||
|
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRecordingProcessRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
raw := &event.RawEvent{
|
||||||
|
EventType: eventType,
|
||||||
|
Payload: json.RawMessage(payload),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
got, err := process(context.Background(), nil, raw, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process error: %v", err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordingTestEventTime(millis int64) string {
|
||||||
|
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||||
|
}
|
||||||
163
events/vc/recording_transcript_generated.go
Normal file
163
events/vc/recording_transcript_generated.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package vc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VCRecordingTranscriptItemOutput is one flattened transcript item for recording events.
|
||||||
|
type VCRecordingTranscriptItemOutput struct {
|
||||||
|
SpeakerName string `json:"speaker_name,omitempty" desc:"Speaker display name"`
|
||||||
|
Text string `json:"text,omitempty" desc:"Transcript text"`
|
||||||
|
StartTime string `json:"start_time,omitempty" desc:"Transcript item start time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||||
|
EndTime string `json:"end_time,omitempty" desc:"Transcript item end time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||||
|
SentenceID string `json:"sentence_id,omitempty" desc:"Transcript sentence ID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VCRecordingTranscriptGeneratedOutput is the flattened shape for vc.recording.recording_transcript_generated_v1.
|
||||||
|
type VCRecordingTranscriptGeneratedOutput struct {
|
||||||
|
Type string `json:"type" desc:"Event type; always vc.recording.recording_transcript_generated_v1"`
|
||||||
|
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||||
|
EventTime string `json:"event_time,omitempty" desc:"Time when this batch of transcript items was generated, in RFC3339 / ISO 8601 with the current system timezone"`
|
||||||
|
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||||
|
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||||
|
TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingTranscriptGeneratedEnvelope struct {
|
||||||
|
Header struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
CreateTime string `json:"create_time"`
|
||||||
|
} `json:"header"`
|
||||||
|
Event recordingTranscriptGeneratedEvent `json:"event"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingTranscriptGeneratedEvent struct {
|
||||||
|
UniqueKey string `json:"unique_key"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
TranscriptItems []recordingTranscriptGeneratedItemIn `json:"transcript_items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingTranscriptGeneratedItemIn struct {
|
||||||
|
Speaker *recordingTranscriptGeneratedSpeakerIn `json:"speaker"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
StartTimeMs recordingTranscriptGeneratedString `json:"start_time_ms"`
|
||||||
|
EndTimeMs recordingTranscriptGeneratedString `json:"end_time_ms"`
|
||||||
|
SentenceID string `json:"sentence_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingTranscriptGeneratedSpeakerIn struct {
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingTranscriptGeneratedString string
|
||||||
|
|
||||||
|
func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||||
|
envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw)
|
||||||
|
if !ok {
|
||||||
|
return raw.Payload, nil
|
||||||
|
}
|
||||||
|
if !isRecordingTranscriptGeneratedBeanEvent(envelope) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
out := &VCRecordingTranscriptGeneratedOutput{
|
||||||
|
Type: recordingTranscriptGeneratedEventType(envelope, raw),
|
||||||
|
EventID: envelope.Header.EventID,
|
||||||
|
EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime),
|
||||||
|
UniqueKey: envelope.Event.UniqueKey,
|
||||||
|
Source: envelope.Event.Source,
|
||||||
|
TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems),
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRecordingTranscriptGeneratedEnvelope(raw *event.RawEvent) (*recordingTranscriptGeneratedEnvelope, bool) {
|
||||||
|
var envelope recordingTranscriptGeneratedEnvelope
|
||||||
|
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &envelope, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGeneratedEnvelope) bool {
|
||||||
|
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordingTranscriptGeneratedEventType(envelope *recordingTranscriptGeneratedEnvelope, raw *event.RawEvent) string {
|
||||||
|
if envelope != nil && envelope.Header.EventType != "" {
|
||||||
|
return envelope.Header.EventType
|
||||||
|
}
|
||||||
|
return raw.EventType
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordingTranscriptGeneratedEventTime(raw string) string {
|
||||||
|
return recordingTranscriptGeneratedMillisToLocalRFC3339(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordingTranscriptGeneratedMillisToLocalRFC3339(raw string) string {
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordingTranscriptItems(items []recordingTranscriptGeneratedItemIn) []VCRecordingTranscriptItemOutput {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]VCRecordingTranscriptItemOutput, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
out = append(out, recordingTranscriptItem(item))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordingTranscriptItemOutput {
|
||||||
|
return VCRecordingTranscriptItemOutput{
|
||||||
|
SpeakerName: recordingSpeakerName(item.Speaker),
|
||||||
|
Text: item.Text,
|
||||||
|
StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()),
|
||||||
|
EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()),
|
||||||
|
SentenceID: item.SentenceID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordingSpeakerName(speaker *recordingTranscriptGeneratedSpeakerIn) string {
|
||||||
|
if speaker == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return speaker.UserName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *recordingTranscriptGeneratedString) UnmarshalJSON(data []byte) error {
|
||||||
|
if string(data) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var str string
|
||||||
|
if err := json.Unmarshal(data, &str); err == nil {
|
||||||
|
*s = recordingTranscriptGeneratedString(str)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var num json.Number
|
||||||
|
if err := json.Unmarshal(data, &num); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*s = recordingTranscriptGeneratedString(num.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s recordingTranscriptGeneratedString) String() string {
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
@@ -11,13 +11,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||||
|
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
|
||||||
|
eventTypeRecordingTranscriptGenerated = "vc.recording.recording_transcript_generated_v1"
|
||||||
|
eventTypeRecordingEnded = "vc.recording.recording_ended_v1"
|
||||||
|
|
||||||
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
||||||
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
|
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
|
||||||
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
|
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
|
||||||
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
|
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
|
||||||
|
pathRecordingSubscribe = "/open-apis/vc/v1/recordings/subscription"
|
||||||
|
pathRecordingUnsubscribe = "/open-apis/vc/v1/recordings/unsubscription"
|
||||||
|
|
||||||
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
|
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
|
||||||
)
|
)
|
||||||
@@ -57,5 +62,53 @@ func Keys() []event.KeyDefinition {
|
|||||||
},
|
},
|
||||||
RequiredConsoleEvents: []string{eventTypeNoteGenerated},
|
RequiredConsoleEvents: []string{eventTypeNoteGenerated},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Key: eventTypeRecordingStarted,
|
||||||
|
DisplayName: "Recording started",
|
||||||
|
Description: "Triggered when a recording_bean recording starts; only generated when connected to Feishu software.",
|
||||||
|
EventType: eventTypeRecordingStarted,
|
||||||
|
Schema: event.SchemaDef{
|
||||||
|
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})},
|
||||||
|
},
|
||||||
|
Process: processVCRecordingStarted,
|
||||||
|
PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||||
|
Scopes: []string{"vc:recording:read"},
|
||||||
|
AuthTypes: []string{
|
||||||
|
"user",
|
||||||
|
},
|
||||||
|
RequiredConsoleEvents: []string{eventTypeRecordingStarted},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: eventTypeRecordingTranscriptGenerated,
|
||||||
|
DisplayName: "Recording transcript generated",
|
||||||
|
Description: "Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.",
|
||||||
|
EventType: eventTypeRecordingTranscriptGenerated,
|
||||||
|
Schema: event.SchemaDef{
|
||||||
|
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})},
|
||||||
|
},
|
||||||
|
Process: processVCRecordingTranscriptGenerated,
|
||||||
|
PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||||
|
Scopes: []string{"vc:recording:read"},
|
||||||
|
AuthTypes: []string{
|
||||||
|
"user",
|
||||||
|
},
|
||||||
|
RequiredConsoleEvents: []string{eventTypeRecordingTranscriptGenerated},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: eventTypeRecordingEnded,
|
||||||
|
DisplayName: "Recording ended",
|
||||||
|
Description: "Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.",
|
||||||
|
EventType: eventTypeRecordingEnded,
|
||||||
|
Schema: event.SchemaDef{
|
||||||
|
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})},
|
||||||
|
},
|
||||||
|
Process: processVCRecordingEnded,
|
||||||
|
PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||||
|
Scopes: []string{"vc:recording:read"},
|
||||||
|
AuthTypes: []string{
|
||||||
|
"user",
|
||||||
|
},
|
||||||
|
RequiredConsoleEvents: []string{eventTypeRecordingEnded},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
events/whiteboard/native.go
Normal file
23
events/whiteboard/native.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package whiteboard
|
||||||
|
|
||||||
|
// BoardWhiteboardUpdatedV1Data is the flattened whiteboard updated source payload.
|
||||||
|
type BoardWhiteboardUpdatedV1Data struct {
|
||||||
|
// WhiteboardID is the id of the whiteboard whose content was updated.
|
||||||
|
WhiteboardID string `json:"whiteboard_id"`
|
||||||
|
// OperatorIDs lists the operators that produced this update batch.
|
||||||
|
OperatorIDs []OperatorID `json:"operator_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperatorID identifies an operator that produced the whiteboard update,
|
||||||
|
// expressed in the three Lark identity formats.
|
||||||
|
type OperatorID struct {
|
||||||
|
// OpenID is the operator's open_id within the current app.
|
||||||
|
OpenID string `json:"open_id"`
|
||||||
|
// UnionID is the operator's union_id across apps under the same ISV.
|
||||||
|
UnionID string `json:"union_id"`
|
||||||
|
// UserID is the operator's user_id within the tenant.
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
56
events/whiteboard/preconsume.go
Normal file
56
events/whiteboard/preconsume.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package whiteboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cleanupTimeout bounds how long the unsubscribe call has to finish during
|
||||||
|
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
|
||||||
|
const cleanupTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI
|
||||||
|
// and returns a cleanup that invokes the matching unsubscribe.
|
||||||
|
//
|
||||||
|
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
|
||||||
|
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
|
||||||
|
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||||
|
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
|
||||||
|
if rt == nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||||
|
"runtime API client is required for pre-consume subscription")
|
||||||
|
}
|
||||||
|
whiteboardID := params["whiteboard_id"]
|
||||||
|
if whiteboardID == "" {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"param whiteboard_id is required for %s", eventType).
|
||||||
|
WithParam("--param").
|
||||||
|
WithHint("pass it as --param whiteboard_id=<id>; run `lark-cli event schema %s` for details", eventType)
|
||||||
|
}
|
||||||
|
encoded := validate.EncodePathSegment(whiteboardID)
|
||||||
|
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
|
||||||
|
unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded)
|
||||||
|
|
||||||
|
body := map[string]string{"event_type": eventType}
|
||||||
|
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() error {
|
||||||
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
212
events/whiteboard/preconsume_test.go
Normal file
212
events/whiteboard/preconsume_test.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package whiteboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordedCall captures a single APIClient invocation for assertion.
|
||||||
|
type recordedCall struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeAPIClient is a minimal event.APIClient stub that records calls and
|
||||||
|
// can be configured to fail when the request path matches errOnPath.
|
||||||
|
type fakeAPIClient struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []recordedCall
|
||||||
|
errOnPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallAPI records the invocation and optionally returns a simulated error
|
||||||
|
// when the path contains the configured errOnPath substring.
|
||||||
|
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
|
||||||
|
if f.errOnPath != "" && strings.Contains(path, f.errOnPath) {
|
||||||
|
return nil, errors.New("simulated subscribe failure")
|
||||||
|
}
|
||||||
|
return json.RawMessage(`{}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID verifies that the
|
||||||
|
// PreConsume hook fails fast with an actionable error when whiteboard_id
|
||||||
|
// is absent from the params map.
|
||||||
|
func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||||
|
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error when whiteboard_id missing")
|
||||||
|
}
|
||||||
|
if cleanup != nil {
|
||||||
|
t.Fatalf("expected nil cleanup on error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "whiteboard_id") {
|
||||||
|
t.Fatalf("error should mention whiteboard_id, got: %v", err)
|
||||||
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||||
|
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
||||||
|
}
|
||||||
|
if ve.Hint == "" {
|
||||||
|
t.Error("missing whiteboard_id should carry a hint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
|
||||||
|
// returns an error when the runtime APIClient dependency is missing.
|
||||||
|
func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||||
|
_, err := pc(context.Background(), nil, map[string]string{"whiteboard_id": "wb1"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error when runtime client is nil")
|
||||||
|
}
|
||||||
|
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryInternal {
|
||||||
|
t.Errorf("nil-runtime invariant should be a typed internal error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
|
||||||
|
// failed subscribe call surfaces the error and skips registering a cleanup,
|
||||||
|
// so no spurious unsubscribe is invoked.
|
||||||
|
func TestWhiteboardSubscriptionPreConsume_SubscribeError(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||||
|
rt := &fakeAPIClient{errOnPath: "/subscribe"}
|
||||||
|
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error from subscribe call")
|
||||||
|
}
|
||||||
|
if cleanup != nil {
|
||||||
|
t.Fatalf("expected nil cleanup when subscribe fails")
|
||||||
|
}
|
||||||
|
// only the failed subscribe call should have been made; no unsubscribe.
|
||||||
|
if len(rt.calls) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 call (subscribe), got %d", len(rt.calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup verifies the full
|
||||||
|
// happy-path: subscribe is called once with the correct method/path/body,
|
||||||
|
// and the returned cleanup invokes the matching unsubscribe.
|
||||||
|
func TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||||
|
rt := &fakeAPIClient{}
|
||||||
|
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cleanup == nil {
|
||||||
|
t.Fatalf("expected non-nil cleanup")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rt.calls) != 1 {
|
||||||
|
t.Fatalf("expected 1 call after subscribe, got %d", len(rt.calls))
|
||||||
|
}
|
||||||
|
got := rt.calls[0]
|
||||||
|
if got.method != "POST" {
|
||||||
|
t.Errorf("subscribe method: got %q, want POST", got.method)
|
||||||
|
}
|
||||||
|
wantSubPath := "/open-apis/board/v1/whiteboards/wb1/subscribe"
|
||||||
|
if got.path != wantSubPath {
|
||||||
|
t.Errorf("subscribe path: got %q, want %q", got.path, wantSubPath)
|
||||||
|
}
|
||||||
|
body, _ := got.body.(map[string]string)
|
||||||
|
if body["event_type"] != eventTypeWhiteboardUpdated {
|
||||||
|
t.Errorf("subscribe body event_type: got %q, want %q", body["event_type"], eventTypeWhiteboardUpdated)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
if len(rt.calls) != 2 {
|
||||||
|
t.Fatalf("expected 2 calls after cleanup, got %d", len(rt.calls))
|
||||||
|
}
|
||||||
|
got2 := rt.calls[1]
|
||||||
|
if got2.method != "POST" {
|
||||||
|
t.Errorf("unsubscribe method: got %q, want POST", got2.method)
|
||||||
|
}
|
||||||
|
wantUnsubPath := "/open-apis/board/v1/whiteboards/wb1/unsubscribe"
|
||||||
|
if got2.path != wantUnsubPath {
|
||||||
|
t.Errorf("unsubscribe path: got %q, want %q", got2.path, wantUnsubPath)
|
||||||
|
}
|
||||||
|
body2, _ := got2.body.(map[string]string)
|
||||||
|
if body2["event_type"] != eventTypeWhiteboardUpdated {
|
||||||
|
t.Errorf("unsubscribe body event_type: got %q, want %q", body2["event_type"], eventTypeWhiteboardUpdated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded verifies that
|
||||||
|
// whiteboard_id values containing reserved URL characters are properly
|
||||||
|
// path-segment encoded so they cannot escape into adjacent path segments.
|
||||||
|
func TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||||
|
rt := &fakeAPIClient{}
|
||||||
|
// 含特殊字符的 whiteboard_id 应被 path-segment 编码,避免越界到其他 path 段。
|
||||||
|
_, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb/1?evil"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(rt.calls) != 1 {
|
||||||
|
t.Fatalf("expected 1 call, got %d", len(rt.calls))
|
||||||
|
}
|
||||||
|
if strings.Contains(rt.calls[0].path, "wb/1?evil") {
|
||||||
|
t.Errorf("whiteboard_id was not encoded; path: %s", rt.calls[0].path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWhiteboardUpdatedV1HasPreConsume ensures the registered EventKey for
|
||||||
|
// board.whiteboard.updated_v1 wires the PreConsume hook and declares the
|
||||||
|
// required whiteboard_id parameter.
|
||||||
|
func TestWhiteboardUpdatedV1HasPreConsume(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
keys := Keys()
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.Key == eventTypeWhiteboardUpdated {
|
||||||
|
if k.PreConsume == nil {
|
||||||
|
t.Fatalf("EventKey %s should have PreConsume hook", eventTypeWhiteboardUpdated)
|
||||||
|
}
|
||||||
|
if len(k.Params) == 0 {
|
||||||
|
t.Fatalf("EventKey %s should declare whiteboard_id param", eventTypeWhiteboardUpdated)
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, p := range k.Params {
|
||||||
|
if p.Name == "whiteboard_id" && p.Required {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("EventKey %s must declare required whiteboard_id param", eventTypeWhiteboardUpdated)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("EventKey %s not registered", eventTypeWhiteboardUpdated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 event.APIClient 接口与本测试 mock 一致。
|
||||||
|
var _ event.APIClient = (*fakeAPIClient)(nil)
|
||||||
48
events/whiteboard/register.go
Normal file
48
events/whiteboard/register.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package whiteboard registers Board-domain EventKeys.
|
||||||
|
package whiteboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/event"
|
||||||
|
"github.com/larksuite/cli/internal/event/schemas"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eventTypeWhiteboardUpdated is the OAPI event type for whiteboard content updates.
|
||||||
|
const eventTypeWhiteboardUpdated = "board.whiteboard.updated_v1"
|
||||||
|
|
||||||
|
// Keys returns all Board-domain EventKey definitions.
|
||||||
|
func Keys() []event.KeyDefinition {
|
||||||
|
return []event.KeyDefinition{
|
||||||
|
{
|
||||||
|
Key: eventTypeWhiteboardUpdated,
|
||||||
|
DisplayName: "Whiteboard updated",
|
||||||
|
Description: "Pushed when the whiteboard content is updated.",
|
||||||
|
EventType: eventTypeWhiteboardUpdated,
|
||||||
|
Params: []event.ParamDef{
|
||||||
|
{
|
||||||
|
Name: "whiteboard_id",
|
||||||
|
Type: event.ParamString,
|
||||||
|
Required: true,
|
||||||
|
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Schema: event.SchemaDef{
|
||||||
|
Native: &event.SchemaSpec{Type: reflect.TypeOf(BoardWhiteboardUpdatedV1Data{})},
|
||||||
|
FieldOverrides: map[string]schemas.FieldMeta{
|
||||||
|
"/event/whiteboard_id": {Kind: "whiteboard_id", Description: "whiteboard id to subscribe"},
|
||||||
|
"/event/operator_ids/*/open_id": {Kind: "open_id"},
|
||||||
|
"/event/operator_ids/*/union_id": {Kind: "union_id"},
|
||||||
|
"/event/operator_ids/*/user_id": {Kind: "user_id"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated),
|
||||||
|
Scopes: []string{"board:whiteboard:node:read"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
RequiredConsoleEvents: []string{eventTypeWhiteboardUpdated},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,11 @@
|
|||||||
//go:build authsidecar
|
//go:build authsidecar
|
||||||
|
|
||||||
// Package sidecar provides a transport interceptor for the auth sidecar
|
// Package sidecar provides a transport interceptor for the auth sidecar
|
||||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an http:// or https://
|
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
|
||||||
// URL), all outgoing requests are rewritten to the sidecar address. The
|
// outgoing requests are rewritten to the sidecar address. The interceptor
|
||||||
// interceptor strips placeholder credentials, injects proxy headers, and
|
// strips placeholder credentials, injects proxy headers, and signs each
|
||||||
// signs each request with HMAC-SHA256. No custom DialContext is needed —
|
// request with HMAC-SHA256. No custom DialContext is needed — Go's
|
||||||
// Go's standard http.Transport connects to the sidecar via HTTP, or via
|
// standard http.Transport connects to the sidecar via plain HTTP.
|
||||||
// HTTPS (TLS) when the sidecar address is an https:// URL.
|
|
||||||
package sidecar
|
package sidecar
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -47,17 +46,15 @@ func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor
|
|||||||
}
|
}
|
||||||
key := os.Getenv(envvars.CliProxyKey)
|
key := os.Getenv(envvars.CliProxyKey)
|
||||||
return &Interceptor{
|
return &Interceptor{
|
||||||
key: []byte(key),
|
key: []byte(key),
|
||||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||||
sidecarScheme: sidecar.ProxyScheme(proxyAddr),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interceptor rewrites requests for the sidecar proxy.
|
// Interceptor rewrites requests for the sidecar proxy.
|
||||||
type Interceptor struct {
|
type Interceptor struct {
|
||||||
key []byte // HMAC signing key
|
key []byte // HMAC signing key
|
||||||
sidecarHost string // sidecar host[:port] for URL rewriting
|
sidecarHost string // sidecar host:port for URL rewriting
|
||||||
sidecarScheme string // "http" (same-host) or "https" (remote TLS sidecar)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
||||||
@@ -133,13 +130,8 @@ func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response,
|
|||||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||||
|
|
||||||
// 5. Rewrite URL to route through sidecar. Scheme follows the configured
|
// 5. Rewrite URL to route through sidecar
|
||||||
// proxy address: https for a remote (TLS) sidecar, http for a same-host one.
|
req.URL.Scheme = "http"
|
||||||
scheme := i.sidecarScheme
|
|
||||||
if scheme == "" {
|
|
||||||
scheme = "http"
|
|
||||||
}
|
|
||||||
req.URL.Scheme = scheme
|
|
||||||
req.URL.Host = i.sidecarHost
|
req.URL.Host = i.sidecarHost
|
||||||
|
|
||||||
return nil // no post-hook needed
|
return nil // no post-hook needed
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ package sidecar
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/envvars"
|
|
||||||
"github.com/larksuite/cli/sidecar"
|
"github.com/larksuite/cli/sidecar"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -99,54 +97,6 @@ func TestInterceptor_PreRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInterceptor_PreRoundTrip_HTTPS verifies that a remote (TLS) sidecar
|
|
||||||
// rewrites the request to https://<remote-host>, while still preserving the
|
|
||||||
// original target and signing the request.
|
|
||||||
func TestInterceptor_PreRoundTrip_HTTPS(t *testing.T) {
|
|
||||||
key := []byte("test-key-for-hmac-signing-32byte!")
|
|
||||||
interceptor := &Interceptor{key: key, sidecarHost: "sidecar.mycorp.com", sidecarScheme: "https"}
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/im/v1/chats", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
|
||||||
|
|
||||||
interceptor.PreRoundTrip(req)
|
|
||||||
|
|
||||||
if req.URL.Scheme != "https" {
|
|
||||||
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "https")
|
|
||||||
}
|
|
||||||
if req.URL.Host != "sidecar.mycorp.com" {
|
|
||||||
t.Errorf("host = %q, want %q", req.URL.Host, "sidecar.mycorp.com")
|
|
||||||
}
|
|
||||||
// Original target still preserved for the sidecar to forward upstream.
|
|
||||||
if target := req.Header.Get(sidecar.HeaderProxyTarget); target != "https://open.feishu.cn" {
|
|
||||||
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
|
|
||||||
}
|
|
||||||
// Request is still signed.
|
|
||||||
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
|
|
||||||
t.Error("signature header should be set")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestResolveInterceptor_HTTPSScheme pins the end-to-end env→scheme path: a
|
|
||||||
// (mixed-case) https proxy address must produce an interceptor that rewrites to
|
|
||||||
// https, never silently downgrading a remote sidecar to plaintext http.
|
|
||||||
func TestResolveInterceptor_HTTPSScheme(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAuthProxy, "HTTPS://sidecar.mycorp.com") // uppercase on purpose
|
|
||||||
t.Setenv(envvars.CliProxyKey, "key")
|
|
||||||
|
|
||||||
ic := (&Provider{}).ResolveInterceptor(context.Background())
|
|
||||||
si, ok := ic.(*Interceptor)
|
|
||||||
if !ok || si == nil {
|
|
||||||
t.Fatalf("expected *Interceptor, got %T", ic)
|
|
||||||
}
|
|
||||||
if si.sidecarScheme != "https" {
|
|
||||||
t.Errorf("sidecarScheme = %q, want %q (uppercase HTTPS must not downgrade)", si.sidecarScheme, "https")
|
|
||||||
}
|
|
||||||
if si.sidecarHost != "sidecar.mycorp.com" {
|
|
||||||
t.Errorf("sidecarHost = %q, want %q", si.sidecarHost, "sidecar.mycorp.com")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInterceptor_BotIdentity(t *testing.T) {
|
func TestInterceptor_BotIdentity(t *testing.T) {
|
||||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||||
|
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -14,7 +14,7 @@ require (
|
|||||||
github.com/sergi/go-diff v1.4.0
|
github.com/sergi/go-diff v1.4.0
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/smartystreets/goconvey v1.8.1
|
github.com/smartystreets/goconvey v1.8.1
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
|
||||||
github.com/spf13/pflag v1.0.9
|
github.com/spf13/pflag v1.0.9
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type DeviceFlowResult struct {
|
|||||||
// OAuthEndpoints contains the OAuth endpoint URLs.
|
// OAuthEndpoints contains the OAuth endpoint URLs.
|
||||||
type OAuthEndpoints struct {
|
type OAuthEndpoints struct {
|
||||||
DeviceAuthorization string
|
DeviceAuthorization string
|
||||||
|
Revoke string
|
||||||
Token string
|
Token string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
|
|||||||
ep := core.ResolveEndpoints(brand)
|
ep := core.ResolveEndpoints(brand)
|
||||||
return OAuthEndpoints{
|
return OAuthEndpoints{
|
||||||
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
|
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
|
||||||
|
Revoke: ep.Accounts + PathOAuthRevoke,
|
||||||
Token: ep.Open + PathOAuthTokenV2,
|
Token: ep.Open + PathOAuthTokenV2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
|
|||||||
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
|
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
|
||||||
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
||||||
}
|
}
|
||||||
|
if ep.Revoke != "https://accounts.feishu.cn/oauth/v1/revoke" {
|
||||||
|
t.Errorf("Revoke = %q", ep.Revoke)
|
||||||
|
}
|
||||||
if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
|
if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
|
||||||
t.Errorf("Token = %q", ep.Token)
|
t.Errorf("Token = %q", ep.Token)
|
||||||
}
|
}
|
||||||
@@ -42,6 +45,9 @@ func TestResolveOAuthEndpoints_Lark(t *testing.T) {
|
|||||||
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
|
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
|
||||||
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
||||||
}
|
}
|
||||||
|
if ep.Revoke != "https://accounts.larksuite.com/oauth/v1/revoke" {
|
||||||
|
t.Errorf("Revoke = %q", ep.Revoke)
|
||||||
|
}
|
||||||
if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
|
if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
|
||||||
t.Errorf("Token = %q", ep.Token)
|
t.Errorf("Token = %q", ep.Token)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ package auth
|
|||||||
const (
|
const (
|
||||||
// PathDeviceAuthorization is the endpoint for device authorization.
|
// PathDeviceAuthorization is the endpoint for device authorization.
|
||||||
PathDeviceAuthorization = "/oauth/v1/device_authorization"
|
PathDeviceAuthorization = "/oauth/v1/device_authorization"
|
||||||
|
// PathOAuthRevoke is the endpoint for revoking an OAuth token.
|
||||||
|
PathOAuthRevoke = "/oauth/v1/revoke"
|
||||||
// PathAppRegistration is the endpoint for application registration.
|
// PathAppRegistration is the endpoint for application registration.
|
||||||
PathAppRegistration = "/oauth/v1/app/registration"
|
PathAppRegistration = "/oauth/v1/app/registration"
|
||||||
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).
|
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).
|
||||||
|
|||||||
131
internal/auth/revoke.go
Normal file
131
internal/auth/revoke.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RevokeToken revokes a previously issued OAuth token.
|
||||||
|
func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error {
|
||||||
|
endpoints := ResolveOAuthEndpoints(brand)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("client_id", appId)
|
||||||
|
form.Set("client_secret", appSecret)
|
||||||
|
form.Set("token", token)
|
||||||
|
if tokenTypeHint != "" {
|
||||||
|
form.Set("token_type_hint", tokenTypeHint)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, endpoints.Revoke, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return errs.NewInternalError(errs.SubtypeUnknown, "token revoke request creation failed: %v", err).WithCause(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
logHTTPResponse(resp)
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return errs.NewInternalError(errs.SubtypeInvalidResponse, "token revoke read error: %v", err).WithCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return revokeHTTPStatusError(resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var data map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &data); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := getInt(data, "code", 0); code != 0 {
|
||||||
|
msg := getStr(data, "msg")
|
||||||
|
if msg == "" {
|
||||||
|
msg = getStr(data, "message")
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = "unknown error"
|
||||||
|
}
|
||||||
|
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed [%d]: %s", code, msg).
|
||||||
|
WithCode(code).
|
||||||
|
WithCause(errors.New(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
if errStr := getStr(data, "error"); errStr != "" {
|
||||||
|
msg := getStr(data, "error_description")
|
||||||
|
if msg == "" {
|
||||||
|
msg = errStr
|
||||||
|
}
|
||||||
|
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed: %s", msg).
|
||||||
|
WithCause(errors.New(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeHTTPStatusError(status int, body []byte) error {
|
||||||
|
msg := formatOAuthErrorBody(body)
|
||||||
|
cause := errors.New(strings.TrimSpace(string(body)))
|
||||||
|
if strings.TrimSpace(string(body)) == "" {
|
||||||
|
cause = errors.New(msg)
|
||||||
|
}
|
||||||
|
if status >= http.StatusInternalServerError {
|
||||||
|
return errs.NewNetworkError(errs.SubtypeNetworkServer, "token revoke failed: HTTP %d: %s", status, msg).
|
||||||
|
WithCode(status).
|
||||||
|
WithRetryable().
|
||||||
|
WithCause(cause)
|
||||||
|
}
|
||||||
|
subtype := errs.SubtypeUnknown
|
||||||
|
if status == http.StatusNotFound {
|
||||||
|
subtype = errs.SubtypeNotFound
|
||||||
|
}
|
||||||
|
return errs.NewAPIError(subtype, "token revoke failed: HTTP %d: %s", status, msg).
|
||||||
|
WithCode(status).
|
||||||
|
WithCause(cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatOAuthErrorBody(body []byte) string {
|
||||||
|
trimmed := strings.TrimSpace(string(body))
|
||||||
|
if trimmed == "" {
|
||||||
|
return "empty response"
|
||||||
|
}
|
||||||
|
|
||||||
|
var data map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &data); err != nil {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg := getStr(data, "error_description"); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
if msg := getStr(data, "msg"); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
if msg := getStr(data, "message"); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
if msg := getStr(data, "error"); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
207
internal/auth/revoke_test.go
Normal file
207
internal/auth/revoke_test.go
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
|
)
|
||||||
|
|
||||||
|
type revokeRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (fn revokeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return fn(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
type errReadCloser struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r errReadCloser) Read(_ []byte) (int, error) {
|
||||||
|
return 0, r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r errReadCloser) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeToken_PostsExpectedForm(t *testing.T) {
|
||||||
|
reg := &httpmock.Registry{}
|
||||||
|
t.Cleanup(func() { reg.Verify(t) })
|
||||||
|
|
||||||
|
stub := &httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: PathOAuthRevoke,
|
||||||
|
Body: map[string]interface{}{"code": 0},
|
||||||
|
BodyFilter: func(body []byte) bool {
|
||||||
|
values, err := url.ParseQuery(string(body))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return values.Get("client_id") == "cli_a" &&
|
||||||
|
values.Get("client_secret") == "secret_b" &&
|
||||||
|
values.Get("token") == "user-access-token" &&
|
||||||
|
values.Get("token_type_hint") == "access_token"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
reg.Register(stub)
|
||||||
|
|
||||||
|
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RevokeToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := stub.CapturedHeaders.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
|
||||||
|
t.Fatalf("Content-Type = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeToken_DoFailureReturnsTypedNetworkError(t *testing.T) {
|
||||||
|
sentinel := errors.New("transport down")
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return nil, sentinel
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T", err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||||
|
t.Fatalf("problem = %#v, want network/transport", p)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sentinel) {
|
||||||
|
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeToken_ReportsHTTPError(t *testing.T) {
|
||||||
|
reg := &httpmock.Registry{}
|
||||||
|
t.Cleanup(func() { reg.Verify(t) })
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: PathOAuthRevoke,
|
||||||
|
Status: 400,
|
||||||
|
Body: map[string]interface{}{"error": "invalid_token"},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T", err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryAPI || p.Code != 400 {
|
||||||
|
t.Fatalf("problem = %#v, want api error with HTTP 400", p)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid_token") {
|
||||||
|
t.Fatalf("expected invalid_token error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeToken_ReportsOAuthCodeErrorAsTypedAPIError(t *testing.T) {
|
||||||
|
reg := &httpmock.Registry{}
|
||||||
|
t.Cleanup(func() { reg.Verify(t) })
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: PathOAuthRevoke,
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 12345,
|
||||||
|
"msg": "invalid revoke state",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T", err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryAPI || p.Code != 12345 {
|
||||||
|
t.Fatalf("problem = %#v, want api error with code 12345", p)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid revoke state") {
|
||||||
|
t.Fatalf("expected oauth error message, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeToken_ReportsOAuthErrorFieldAsTypedAPIError(t *testing.T) {
|
||||||
|
reg := &httpmock.Registry{}
|
||||||
|
t.Cleanup(func() { reg.Verify(t) })
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: PathOAuthRevoke,
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"error": "invalid_token",
|
||||||
|
"error_description": "token already expired",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T", err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryAPI {
|
||||||
|
t.Fatalf("problem = %#v, want api error", p)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "token already expired") {
|
||||||
|
t.Fatalf("expected oauth error_description, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeToken_ReadFailureReturnsTypedInternalError(t *testing.T) {
|
||||||
|
sentinel := errors.New("read failed")
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: errReadCloser{err: sentinel},
|
||||||
|
Header: make(http.Header),
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T", err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||||
|
t.Fatalf("problem = %#v, want internal/invalid_response", p)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sentinel) {
|
||||||
|
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "token revoke read error") {
|
||||||
|
t.Fatalf("expected read error message, got %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := err.(*errs.InternalError); !ok {
|
||||||
|
t.Fatalf("expected *errs.InternalError, got %T", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ package cmdpolicy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/larksuite/cli/extension/platform"
|
"github.com/larksuite/cli/extension/platform"
|
||||||
|
"github.com/larksuite/cli/internal/suggest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// suggestRisk returns the closest valid Risk literal by edit distance
|
// suggestRisk returns the closest valid Risk literal by edit distance
|
||||||
@@ -20,9 +21,9 @@ func suggestRisk(bad string) string {
|
|||||||
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
|
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
|
||||||
}
|
}
|
||||||
best := string(candidates[0])
|
best := string(candidates[0])
|
||||||
bestDist := levenshtein(lowered, best)
|
bestDist := suggest.Levenshtein(lowered, best)
|
||||||
for _, c := range candidates[1:] {
|
for _, c := range candidates[1:] {
|
||||||
if d := levenshtein(lowered, string(c)); d < bestDist {
|
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist {
|
||||||
bestDist, best = d, string(c)
|
bestDist, best = d, string(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,47 +41,3 @@ func toLower(s string) string {
|
|||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// levenshtein computes the classic edit distance between two strings.
|
|
||||||
// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set
|
|
||||||
// makes raw performance irrelevant — clarity beats trickiness here.
|
|
||||||
func levenshtein(a, b string) int {
|
|
||||||
if len(a) == 0 {
|
|
||||||
return len(b)
|
|
||||||
}
|
|
||||||
if len(b) == 0 {
|
|
||||||
return len(a)
|
|
||||||
}
|
|
||||||
prev := make([]int, len(b)+1)
|
|
||||||
curr := make([]int, len(b)+1)
|
|
||||||
for j := 0; j <= len(b); j++ {
|
|
||||||
prev[j] = j
|
|
||||||
}
|
|
||||||
for i := 1; i <= len(a); i++ {
|
|
||||||
curr[0] = i
|
|
||||||
for j := 1; j <= len(b); j++ {
|
|
||||||
cost := 1
|
|
||||||
if a[i-1] == b[j-1] {
|
|
||||||
cost = 0
|
|
||||||
}
|
|
||||||
curr[j] = min3(
|
|
||||||
prev[j]+1, // deletion
|
|
||||||
curr[j-1]+1, // insertion
|
|
||||||
prev[j-1]+cost, // substitution
|
|
||||||
)
|
|
||||||
}
|
|
||||||
prev, curr = curr, prev
|
|
||||||
}
|
|
||||||
return prev[len(b)]
|
|
||||||
}
|
|
||||||
|
|
||||||
func min3(a, b, c int) int {
|
|
||||||
m := a
|
|
||||||
if b < m {
|
|
||||||
m = b
|
|
||||||
}
|
|
||||||
if c < m {
|
|
||||||
m = c
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,23 +29,3 @@ func TestSuggestRisk(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLevenshtein(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
a, b string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{"", "", 0},
|
|
||||||
{"", "abc", 3},
|
|
||||||
{"abc", "", 3},
|
|
||||||
{"abc", "abc", 0},
|
|
||||||
{"wrtie", "write", 2},
|
|
||||||
{"kitten", "sitting", 3},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
got := levenshtein(c.a, c.b)
|
|
||||||
if got != c.want {
|
|
||||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ type Factory struct {
|
|||||||
Credential *credential.CredentialProvider
|
Credential *credential.CredentialProvider
|
||||||
|
|
||||||
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
|
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
|
||||||
|
|
||||||
|
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||||
|
|||||||
18
internal/cmdutil/groups.go
Normal file
18
internal/cmdutil/groups.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmdutil
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility
|
||||||
|
// command — one kept alive for users whose skill predates a refactor. Service
|
||||||
|
// registration assigns it (e.g. the sheets pre-refactor aliases); both --help
|
||||||
|
// rendering and unknown-subcommand suggestions read it to separate these
|
||||||
|
// aliases from the current commands.
|
||||||
|
const DeprecatedGroupID = "deprecated"
|
||||||
|
|
||||||
|
// IsDeprecatedCommand reports whether c was tagged into the deprecated group.
|
||||||
|
func IsDeprecatedCommand(c *cobra.Command) bool {
|
||||||
|
return c != nil && c.GroupID == DeprecatedGroupID
|
||||||
|
}
|
||||||
@@ -22,6 +22,12 @@ func ParseBrand(value string) LarkBrand {
|
|||||||
return BrandFeishu
|
return BrandFeishu
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuthTokenV3Path is the unified OAuth 2.0 Token Endpoint path on the accounts
|
||||||
|
// domain. It serves every grant type (client_credentials for TAT,
|
||||||
|
// authorization_code / device_code / refresh_token for UAT) and replaces the
|
||||||
|
// legacy per-token endpoints (e.g. /open-apis/auth/v3/tenant_access_token/internal).
|
||||||
|
const OAuthTokenV3Path = "/oauth/v3/token"
|
||||||
|
|
||||||
// Endpoints holds resolved endpoint URLs for different Lark services.
|
// Endpoints holds resolved endpoint URLs for different Lark services.
|
||||||
type Endpoints struct {
|
type Endpoints struct {
|
||||||
Open string // e.g. "https://open.feishu.cn"
|
Open string // e.g. "https://open.feishu.cn"
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
|
|||||||
if ep.Open != "https://open.feishu.cn" {
|
if ep.Open != "https://open.feishu.cn" {
|
||||||
t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open)
|
t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open)
|
||||||
}
|
}
|
||||||
|
// The unified OAuth v3 Token Endpoint mints TAT on the accounts domain;
|
||||||
|
// pin the default-brand host so a stray non-production domain revert is caught.
|
||||||
|
if ep.Accounts != "https://accounts.feishu.cn" {
|
||||||
|
t.Errorf("Accounts = %q, want accounts.feishu.cn for empty brand", ep.Accounts)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveOpenBaseURL(t *testing.T) {
|
func TestResolveOpenBaseURL(t *testing.T) {
|
||||||
|
|||||||
@@ -4,9 +4,7 @@
|
|||||||
package credential
|
package credential
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -21,33 +19,44 @@ import (
|
|||||||
extcred "github.com/larksuite/cli/extension/credential"
|
extcred "github.com/larksuite/cli/extension/credential"
|
||||||
)
|
)
|
||||||
|
|
||||||
// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the
|
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the
|
||||||
// canonical typed error. The TAT mint endpoint reports invalid credentials
|
// unified Token Endpoint into the canonical typed errs.* error. The v3 endpoint
|
||||||
// with two distinct codes:
|
// reports failures using the OAuth 2.0 model — an `error` string plus an
|
||||||
|
// optional numeric `code` — instead of the legacy `{code, msg}` shape.
|
||||||
//
|
//
|
||||||
// - 10003: bad app_id format or non-existent app_id ("invalid param")
|
// invalid_client / unauthorized_client mean the configured app_id/app_secret
|
||||||
// - 10014: invalid app_secret ("app secret invalid")
|
// cannot mint a token; from the user's perspective that is the same actionable
|
||||||
//
|
// CategoryConfig/InvalidClient failure the legacy 10003/10014 codes produced.
|
||||||
// Both surface as CategoryConfig/InvalidClient from the user's perspective —
|
// Every other deterministic error falls through to BuildAPIError, which still
|
||||||
// the configured credentials cannot mint a tenant access token. 10014 is
|
// yields a typed error so probe callers (errs.IsTyped) surface it rather than
|
||||||
// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543).
|
// swallowing it. Transient/server-side failures (5xx / server_error) are
|
||||||
// 10003 is NOT globally mapped because in other Lark endpoints it carries
|
// filtered out by FetchTAT before this is called, so they stay untyped.
|
||||||
// unrelated semantics (e.g. task API uses 10003 for permission denied), so
|
func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) error {
|
||||||
// the override stays local to this TAT call site instead of leaking into the
|
msg := errDesc
|
||||||
// shared codemeta table.
|
if msg == "" {
|
||||||
func classifyTATResponseCode(code int, msg, brand, appID string) error {
|
msg = oauthErr
|
||||||
if code == 10003 {
|
}
|
||||||
|
switch oauthErr {
|
||||||
|
case "invalid_client", "unauthorized_client":
|
||||||
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
|
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
|
||||||
WithCode(code).
|
WithCode(code).
|
||||||
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
|
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
|
||||||
}
|
}
|
||||||
return errclass.BuildAPIError(map[string]any{
|
if err := errclass.BuildAPIError(map[string]any{
|
||||||
"code": code,
|
"code": code,
|
||||||
"msg": msg,
|
"msg": msg,
|
||||||
}, errclass.ClassifyContext{
|
}, errclass.ClassifyContext{
|
||||||
Brand: brand,
|
Brand: brand,
|
||||||
AppID: appID,
|
AppID: appID,
|
||||||
})
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// BuildAPIError returns nil for code 0 (Feishu's success convention), but this
|
||||||
|
// function is only reached once FetchTAT has ruled out success — a non-credential
|
||||||
|
// OAuth error (e.g. invalid_scope) can arrive with code 0 and is still a
|
||||||
|
// deterministic rejection. Back it with a typed APIError so callers never receive
|
||||||
|
// the ("", nil) "empty token, no error" pair.
|
||||||
|
return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg).WithCode(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultAccountProvider resolves account from config.json via keychain.
|
// DefaultAccountProvider resolves account from config.json via keychain.
|
||||||
@@ -148,8 +157,8 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er
|
|||||||
return &TokenResult{Token: token, Scopes: scopes}, nil
|
return &TokenResult{Token: token, Scopes: scopes}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveTAT resolves a tenant access token. Result is cached after first call.
|
// resolveTAT resolves a tenant access token. The result is cached after the first
|
||||||
// NOTE: Uses sync.Once — only the context from the first call is used.
|
// call via sync.Once — only the context from the first call is used.
|
||||||
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
|
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||||
p.tatOnce.Do(func() {
|
p.tatOnce.Do(func() {
|
||||||
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
|
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
|
||||||
@@ -166,42 +175,9 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ep := core.ResolveEndpoints(acct.Brand)
|
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
|
||||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
|
||||||
|
|
||||||
body, err := json.Marshal(map[string]string{
|
|
||||||
"app_id": acct.AppID,
|
|
||||||
"app_secret": acct.AppSecret,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to marshal TAT request: %w", err)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
return &TokenResult{Token: token}, nil
|
||||||
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Msg string `json:"msg"`
|
|
||||||
TenantAccessToken string `json:"tenant_access_token"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
|
|
||||||
}
|
|
||||||
if result.Code != 0 {
|
|
||||||
return nil, classifyTATResponseCode(result.Code, result.Msg, string(acct.Brand), acct.AppID)
|
|
||||||
}
|
|
||||||
return &TokenResult{Token: result.TenantAccessToken}, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,18 +19,16 @@ func TestDefaultAccountProvider_Implements(t *testing.T) {
|
|||||||
var _ DefaultAccountResolver = &DefaultAccountProvider{}
|
var _ DefaultAccountResolver = &DefaultAccountProvider{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT
|
// TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient pins that the
|
||||||
// endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient.
|
// unified Token Endpoint's OAuth2 invalid_client error surfaces as
|
||||||
// Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint,
|
// CategoryConfig/InvalidClient — the configured app_id/app_secret cannot mint a
|
||||||
// which from the user's perspective is the same actionable failure as 10014
|
// tenant access token, the same actionable failure the legacy 10003/10014 codes
|
||||||
// ("app secret invalid") — both mean the configured credentials cannot mint a
|
// produced. The numeric code is intentionally not asserted: the v3 endpoint may
|
||||||
// tenant access token. The global codemeta intentionally does not map 10003
|
// return invalid_client with no Lark code (code defaults to 0).
|
||||||
// because in other Lark endpoints 10003 carries unrelated semantics (e.g. task
|
func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T) {
|
||||||
// API uses it for permission denied), so the override is local to this site.
|
err := classifyTATResponseCode(0, "invalid_client", "client authentication failed", "feishu", "cli_app_x")
|
||||||
func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
|
|
||||||
err := classifyTATResponseCode(10003, "invalid param", "feishu", "cli_app_x")
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected non-nil error for code=10003")
|
t.Fatal("expected non-nil error for invalid_client")
|
||||||
}
|
}
|
||||||
var cfgErr *errs.ConfigError
|
var cfgErr *errs.ConfigError
|
||||||
if !errors.As(err, &cfgErr) {
|
if !errors.As(err, &cfgErr) {
|
||||||
@@ -42,22 +40,16 @@ func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
|
|||||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||||
}
|
}
|
||||||
if cfgErr.Code != 10003 {
|
|
||||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
|
||||||
}
|
|
||||||
if cfgErr.Hint == "" {
|
if cfgErr.Hint == "" {
|
||||||
t.Error("Hint must be non-empty so the user gets a recovery action")
|
t.Error("Hint must be non-empty so the user gets a recovery action")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still
|
// TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient pins that
|
||||||
// goes through the global BuildAPIError path (codemeta entry) so the override
|
// unauthorized_client is treated as the same credential failure as
|
||||||
// for 10003 does not regress the existing mapping.
|
// invalid_client.
|
||||||
func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
|
func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testing.T) {
|
||||||
err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x")
|
err := classifyTATResponseCode(0, "unauthorized_client", "client not authorized", "feishu", "cli_app_x")
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected non-nil error for code=10014")
|
|
||||||
}
|
|
||||||
var cfgErr *errs.ConfigError
|
var cfgErr *errs.ConfigError
|
||||||
if !errors.As(err, &cfgErr) {
|
if !errors.As(err, &cfgErr) {
|
||||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||||
@@ -65,21 +57,38 @@ func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
|
|||||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||||
}
|
}
|
||||||
if cfgErr.Code != 10014 {
|
|
||||||
t.Errorf("Code = %d, want 10014", cfgErr.Code)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside
|
// TestClassifyTATResponseCode_OtherErrorFallsThrough pins that OAuth errors
|
||||||
// the credential set fall through to the generic BuildAPIError fallback
|
// outside the credential set fall through to the generic BuildAPIError fallback
|
||||||
// (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional.
|
// — still typed, but not a ConfigError. The mapping is narrow and intentional.
|
||||||
func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) {
|
func TestClassifyTATResponseCode_OtherErrorFallsThrough(t *testing.T) {
|
||||||
err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x")
|
err := classifyTATResponseCode(20068, "invalid_scope", "unauthorized scope", "feishu", "cli_app_x")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected non-nil error for unmapped code")
|
t.Fatal("expected non-nil error for invalid_scope")
|
||||||
}
|
}
|
||||||
var cfgErr *errs.ConfigError
|
var cfgErr *errs.ConfigError
|
||||||
if errors.As(err, &cfgErr) {
|
if errors.As(err, &cfgErr) {
|
||||||
t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err)
|
t.Fatalf("invalid_scope must not be classified as ConfigError, got %T", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped pins the code-0
|
||||||
|
// backstop: a non-credential OAuth error (e.g. invalid_scope) that arrives with no
|
||||||
|
// numeric code (code 0) must still produce a non-nil typed error. BuildAPIError
|
||||||
|
// returns nil for code 0 (Feishu's success convention); without the backstop,
|
||||||
|
// FetchTAT would surface this deterministic rejection as ("", nil) — an empty token
|
||||||
|
// with no error.
|
||||||
|
func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
|
||||||
|
err := classifyTATResponseCode(0, "invalid_scope", "the requested scope is not granted", "feishu", "cli_app_x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-nil error for code-0 invalid_scope (must not be swallowed as success)")
|
||||||
|
}
|
||||||
|
if !errs.IsTyped(err) {
|
||||||
|
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
var cfgErr *errs.ConfigError
|
||||||
|
if errors.As(err, &cfgErr) {
|
||||||
|
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
internal/credential/tat_fetch.go
Normal file
102
internal/credential/tat_fetch.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package credential
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
|
||||||
|
// unified OAuth 2.0 Token Endpoint ({accounts}/oauth/v3/token) using the
|
||||||
|
// client_credentials grant with client_secret_post authentication. It does not
|
||||||
|
// read configuration or keychain, so callers that already hold plaintext
|
||||||
|
// credentials (e.g. the post-`config init` probe) can validate them without a
|
||||||
|
// second keychain round-trip.
|
||||||
|
//
|
||||||
|
// A deterministic client-side rejection (e.g. invalid_client) returns the
|
||||||
|
// canonical typed error from classifyTATResponseCode — the SAME classification
|
||||||
|
// doResolveTAT (and thus every token-resolving command) produces, so callers
|
||||||
|
// see one consistent envelope. Transport failures, unreadable/unparseable
|
||||||
|
// bodies, and transient server-side failures (5xx / server_error) are returned
|
||||||
|
// raw (untyped), leaving them ambiguous; a caller can use errs.IsTyped to tell a
|
||||||
|
// deterministic credential rejection apart from upstream/transport noise.
|
||||||
|
//
|
||||||
|
// The caller owns the context timeout.
|
||||||
|
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
|
||||||
|
ep := core.ResolveEndpoints(brand)
|
||||||
|
endpoint := ep.Accounts + core.OAuthTokenV3Path
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("grant_type", "client_credentials")
|
||||||
|
form.Set("client_id", appID)
|
||||||
|
form.Set("client_secret", appSecret)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read TAT response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorDescription string `json:"error_description"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
// An unparseable body is ambiguous (covers non-JSON error pages and
|
||||||
|
// truncated payloads); stay untyped so probe callers treat it as noise.
|
||||||
|
return "", fmt.Errorf("failed to parse TAT response (HTTP %d): %w", resp.StatusCode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Code == 0 && result.AccessToken != "" {
|
||||||
|
return result.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient/server-side failures stay untyped so probe callers stay silent and
|
||||||
|
// retryers can back off; only deterministic client rejections are typed. Covers
|
||||||
|
// 5xx, HTTP 429 rate-limit, and the OAuth transient error strings (server_error,
|
||||||
|
// temporarily_unavailable, slow_down) — matching the legacy "non-2xx is noise"
|
||||||
|
// behavior so a rate-limited probe is not surfaced as a hard credential error.
|
||||||
|
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests ||
|
||||||
|
result.Error == "server_error" || result.Error == "temporarily_unavailable" ||
|
||||||
|
result.Error == "slow_down" {
|
||||||
|
return "", fmt.Errorf("TAT endpoint transient failure (HTTP %d, code=%d, error=%q): %s",
|
||||||
|
resp.StatusCode, result.Code, result.Error, result.ErrorDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 2xx with neither token nor error is a malformed success — ambiguous, untyped.
|
||||||
|
if result.Code == 0 && result.Error == "" {
|
||||||
|
return "", fmt.Errorf("TAT response missing access_token (HTTP %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the OAuth error_description; fall back to the legacy Lark `msg` so a
|
||||||
|
// gateway-level {code, msg} response (carrying no OAuth fields) still yields a
|
||||||
|
// non-empty typed message instead of a bare "API error: [code]".
|
||||||
|
desc := result.ErrorDescription
|
||||||
|
if desc == "" {
|
||||||
|
desc = result.Msg
|
||||||
|
}
|
||||||
|
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
|
||||||
|
}
|
||||||
309
internal/credential/tat_fetch_test.go
Normal file
309
internal/credential/tat_fetch_test.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package credential
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubRoundTripper lets us assert request shape and return canned responses.
|
||||||
|
type stubRoundTripper struct {
|
||||||
|
gotReq *http.Request
|
||||||
|
gotBody string
|
||||||
|
respCode int
|
||||||
|
respBody string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
s.gotReq = req
|
||||||
|
if req.Body != nil {
|
||||||
|
b, _ := io.ReadAll(req.Body)
|
||||||
|
s.gotBody = string(b)
|
||||||
|
}
|
||||||
|
if s.err != nil {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: s.respCode,
|
||||||
|
Body: io.NopCloser(strings.NewReader(s.respBody)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTAT_Success(t *testing.T) {
|
||||||
|
rt := &stubRoundTripper{
|
||||||
|
respCode: 200,
|
||||||
|
respBody: `{"code":0,"access_token":"t-abc","token_type":"Bearer","expires_in":7200}`,
|
||||||
|
}
|
||||||
|
hc := &http.Client{Transport: rt}
|
||||||
|
|
||||||
|
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if token != "t-abc" {
|
||||||
|
t.Errorf("token = %q, want t-abc", token)
|
||||||
|
}
|
||||||
|
if rt.gotReq.URL.String() != "https://accounts.feishu.cn/oauth/v3/token" {
|
||||||
|
t.Errorf("url = %s", rt.gotReq.URL.String())
|
||||||
|
}
|
||||||
|
if ct := rt.gotReq.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", ct)
|
||||||
|
}
|
||||||
|
// client_secret_post: grant_type + client_id + client_secret in the form body.
|
||||||
|
for _, want := range []string{"grant_type=client_credentials", "client_id=cli_app", "client_secret=secret_x"} {
|
||||||
|
if !strings.Contains(rt.gotBody, want) {
|
||||||
|
t.Errorf("request body missing %q: %s", want, rt.gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalid_client (wrong app_id/app_secret on the client_credentials grant) is a
|
||||||
|
// deterministic client-side rejection that FetchTAT routes to
|
||||||
|
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
|
||||||
|
// typed error doResolveTAT (and thus every token-resolving command) returns.
|
||||||
|
// The v3 endpoint reports it as HTTP 400 with the OAuth2 error body (wrong
|
||||||
|
// secret → code 20002, unknown app → code 20048).
|
||||||
|
func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) {
|
||||||
|
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`}
|
||||||
|
hc := &http.Client{Transport: rt}
|
||||||
|
|
||||||
|
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid_client")
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
t.Errorf("token = %q, want empty", token)
|
||||||
|
}
|
||||||
|
var cfgErr *errs.ConfigError
|
||||||
|
if !errors.As(err, &cfgErr) {
|
||||||
|
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
|
||||||
|
}
|
||||||
|
if cfgErr.Category != errs.CategoryConfig {
|
||||||
|
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
|
||||||
|
}
|
||||||
|
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||||
|
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other deterministic client-side OAuth error (e.g. invalid_scope) still
|
||||||
|
// yields a typed error (errs.IsTyped) via BuildAPIError — so a probe caller
|
||||||
|
// surfaces it rather than silently swallowing it — but is NOT classified as a
|
||||||
|
// credential (invalid_client) problem.
|
||||||
|
func TestFetchTAT_OtherClientError_Typed(t *testing.T) {
|
||||||
|
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`}
|
||||||
|
hc := &http.Client{Transport: rt}
|
||||||
|
|
||||||
|
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid_scope")
|
||||||
|
}
|
||||||
|
if !errs.IsTyped(err) {
|
||||||
|
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
var cfgErr *errs.ConfigError
|
||||||
|
if errors.As(err, &cfgErr) {
|
||||||
|
t.Errorf("invalid_scope must not be classified as ConfigError/InvalidClient, got %T", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deterministic OAuth error that arrives WITHOUT a numeric code (code defaults to
|
||||||
|
// 0) must still surface as a non-nil typed error — never the ("", nil) success pair.
|
||||||
|
// Guards the code-0 backstop in classifyTATResponseCode: BuildAPIError returns nil
|
||||||
|
// for code 0, which would otherwise swallow this rejection into an empty-token success.
|
||||||
|
func TestFetchTAT_OtherClientError_CodeZero_Typed(t *testing.T) {
|
||||||
|
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_scope","error_description":"the requested scope is not granted"}`}
|
||||||
|
hc := &http.Client{Transport: rt}
|
||||||
|
|
||||||
|
tok, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-nil error for code-0 invalid_scope (must not return empty token + nil error)")
|
||||||
|
}
|
||||||
|
if tok != "" {
|
||||||
|
t.Errorf("token = %q, want empty", tok)
|
||||||
|
}
|
||||||
|
if !errs.IsTyped(err) {
|
||||||
|
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A gateway-style {code, msg} error (no OAuth error / error_description fields)
|
||||||
|
// must still surface its msg on the typed error, not degrade to a generic
|
||||||
|
// "API error: [code]". Guards the legacy-msg fallback in FetchTAT.
|
||||||
|
func TestFetchTAT_LarkStyleMsg_FallsBackOnTypedError(t *testing.T) {
|
||||||
|
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":99999,"msg":"app ticket invalid"}`}
|
||||||
|
hc := &http.Client{Transport: rt}
|
||||||
|
|
||||||
|
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for {code, msg} response")
|
||||||
|
}
|
||||||
|
if !errs.IsTyped(err) {
|
||||||
|
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "app ticket invalid") {
|
||||||
|
t.Errorf("typed error must carry the Lark msg, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient server-side failures (5xx / server_error) are NOT deterministic
|
||||||
|
// credential rejections — they must stay UNTYPED so a probe caller treats them
|
||||||
|
// as upstream noise and stays silent (and retryers can back off).
|
||||||
|
func TestFetchTAT_ServerError_Untyped(t *testing.T) {
|
||||||
|
rt := &stubRoundTripper{respCode: 500, respBody: `{"code":20050,"error":"server_error","error_description":"please retry"}`}
|
||||||
|
hc := &http.Client{Transport: rt}
|
||||||
|
|
||||||
|
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for server_error")
|
||||||
|
}
|
||||||
|
if errs.IsTyped(err) {
|
||||||
|
t.Errorf("server_error must be UNTYPED (transient), got typed %T %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limiting is transient, not a deterministic credential rejection — an HTTP
|
||||||
|
// 429 (even with a parseable OAuth body) and the OAuth slow_down error must both
|
||||||
|
// stay UNTYPED so a rate-limited probe stays silent and retryers can back off.
|
||||||
|
func TestFetchTAT_RateLimit_Untyped(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
code int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"http 429", 429, `{"code":99991400,"error":"too_many_requests","error_description":"rate limit exceeded"}`},
|
||||||
|
{"oauth slow_down", 200, `{"error":"slow_down","error_description":"polling too fast"}`},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
rt := &stubRoundTripper{respCode: tc.code, respBody: tc.body}
|
||||||
|
hc := &http.Client{Transport: rt}
|
||||||
|
|
||||||
|
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for rate-limit")
|
||||||
|
}
|
||||||
|
if errs.IsTyped(err) {
|
||||||
|
t.Errorf("rate-limit must be UNTYPED (transient), got typed %T %v", err, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-2xx HTTP with a non-JSON body is ambiguous (not a structured OAuth
|
||||||
|
// rejection) — it must stay UNTYPED so a probe caller treats it as upstream
|
||||||
|
// noise and stays silent.
|
||||||
|
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
|
||||||
|
for _, code := range []int{401, 403, 500, 503} {
|
||||||
|
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
|
||||||
|
hc := &http.Client{Transport: rt}
|
||||||
|
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("HTTP %d: expected error", code)
|
||||||
|
}
|
||||||
|
if errs.IsTyped(err) {
|
||||||
|
t.Errorf("HTTP %d: must be UNTYPED (ambiguous), got typed %T %v", code, err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTAT_TransportError_Untyped(t *testing.T) {
|
||||||
|
sentinel := errors.New("network down")
|
||||||
|
rt := &stubRoundTripper{err: sentinel}
|
||||||
|
hc := &http.Client{Transport: rt}
|
||||||
|
|
||||||
|
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if errs.IsTyped(err) {
|
||||||
|
t.Errorf("transport error must be UNTYPED, got typed %T", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sentinel) {
|
||||||
|
t.Errorf("error chain missing sentinel: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTAT_ParseError_Untyped(t *testing.T) {
|
||||||
|
rt := &stubRoundTripper{respCode: 200, respBody: `not json`}
|
||||||
|
hc := &http.Client{Transport: rt}
|
||||||
|
|
||||||
|
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected parse error")
|
||||||
|
}
|
||||||
|
if errs.IsTyped(err) {
|
||||||
|
t.Errorf("parse error must be UNTYPED, got typed %T", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTAT_BrandRouting(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
brand core.LarkBrand
|
||||||
|
wantURL string
|
||||||
|
}{
|
||||||
|
{core.BrandFeishu, "https://accounts.feishu.cn/oauth/v3/token"},
|
||||||
|
{core.BrandLark, "https://accounts.larksuite.com/oauth/v3/token"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(string(tc.brand), func(t *testing.T) {
|
||||||
|
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"access_token":"t","token_type":"Bearer"}`}
|
||||||
|
hc := &http.Client{Transport: rt}
|
||||||
|
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := rt.gotReq.URL.String(); got != tc.wantURL {
|
||||||
|
t.Errorf("url = %s, want %s", got, tc.wantURL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTAT_ContextCanceled(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
<-r.Context().Done()
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
rt := &urlRewriteRT{base: srv.URL}
|
||||||
|
hc := &http.Client{Transport: rt}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // pre-canceled
|
||||||
|
|
||||||
|
_, err := FetchTAT(ctx, hc, core.BrandFeishu, "a", "b")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for canceled context")
|
||||||
|
}
|
||||||
|
if errs.IsTyped(err) {
|
||||||
|
t.Errorf("canceled context must be UNTYPED, got typed %T", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("error chain missing context.Canceled: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlRewriteRT forwards requests to a fixed base URL (test server).
|
||||||
|
type urlRewriteRT struct{ base string }
|
||||||
|
|
||||||
|
func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
newURL := r.base + req.URL.Path
|
||||||
|
req2, err := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req2.Header = req.Header
|
||||||
|
return http.DefaultTransport.RoundTrip(req2)
|
||||||
|
}
|
||||||
57
internal/deprecation/deprecation.go
Normal file
57
internal/deprecation/deprecation.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package deprecation carries a process-level notice that the command currently
|
||||||
|
// being executed is a backward-compatibility alias, kept alive for users whose
|
||||||
|
// skill predates a refactor. The notice is surfaced in JSON output envelopes via
|
||||||
|
// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck.
|
||||||
|
//
|
||||||
|
// A CLI process runs exactly one shortcut, so a single process-level slot is
|
||||||
|
// sufficient: the command's Execute records the notice before producing output,
|
||||||
|
// and the output layer reads it back when building the envelope.
|
||||||
|
package deprecation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notice describes a deprecated command alias and the current command that
|
||||||
|
// replaces it. Replacement and Skill are optional.
|
||||||
|
type Notice struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Replacement string `json:"replacement,omitempty"`
|
||||||
|
Skill string `json:"skill,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message returns a single-line, AI-agent-parseable description of the alias
|
||||||
|
// plus the canonical fix (update the skill). Mirrors the style of
|
||||||
|
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
|
||||||
|
func (n *Notice) Message() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(n.Command)
|
||||||
|
b.WriteString(" is a pre-refactor compatibility alias")
|
||||||
|
if n.Replacement != "" {
|
||||||
|
b.WriteString("; use ")
|
||||||
|
b.WriteString(n.Replacement)
|
||||||
|
b.WriteString(" instead")
|
||||||
|
}
|
||||||
|
if n.Skill != "" {
|
||||||
|
b.WriteString("; update your ")
|
||||||
|
b.WriteString(n.Skill)
|
||||||
|
b.WriteString(" skill, run: lark-cli update")
|
||||||
|
} else {
|
||||||
|
b.WriteString("; update your skill, run: lark-cli update")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pending stores the latest deprecation notice for the current process.
|
||||||
|
var pending atomic.Pointer[Notice]
|
||||||
|
|
||||||
|
// SetPending stores the notice for consumption by output decorators.
|
||||||
|
// Pass nil to clear.
|
||||||
|
func SetPending(n *Notice) { pending.Store(n) }
|
||||||
|
|
||||||
|
// GetPending returns the pending deprecation notice, or nil.
|
||||||
|
func GetPending() *Notice { return pending.Load() }
|
||||||
58
internal/deprecation/deprecation_test.go
Normal file
58
internal/deprecation/deprecation_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package deprecation
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNoticeMessage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
notice Notice
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "replacement and skill",
|
||||||
|
notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"},
|
||||||
|
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no replacement",
|
||||||
|
notice: Notice{Command: "+read", Skill: "lark-sheets"},
|
||||||
|
want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no skill",
|
||||||
|
notice: Notice{Command: "+read", Replacement: "+cells-get"},
|
||||||
|
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.notice.Message(); got != tt.want {
|
||||||
|
t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetGetPending(t *testing.T) {
|
||||||
|
t.Cleanup(func() { SetPending(nil) })
|
||||||
|
|
||||||
|
SetPending(nil)
|
||||||
|
if got := GetPending(); got != nil {
|
||||||
|
t.Fatalf("expected nil pending after clear, got %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"}
|
||||||
|
SetPending(n)
|
||||||
|
got := GetPending()
|
||||||
|
if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" {
|
||||||
|
t.Fatalf("GetPending() = %#v, want %#v", got, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
SetPending(nil)
|
||||||
|
if GetPending() != nil {
|
||||||
|
t.Fatal("expected nil after clearing")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ const (
|
|||||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||||
|
|
||||||
// Sidecar proxy (auth proxy mode)
|
// Sidecar proxy (auth proxy mode)
|
||||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar address http(s)://host[:port]; plaintext http is same-host only, a remote sidecar must use https. e.g. "http://127.0.0.1:16384" or "https://sidecar.mycorp.com"
|
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
|
||||||
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
||||||
|
|
||||||
// Content safety scanning mode
|
// Content safety scanning mode
|
||||||
|
|||||||
@@ -92,6 +92,18 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
|||||||
base.Troubleshooter = ts
|
base.Troubleshooter = ts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Upstream-provided field-level reasons (resp.error.details[].value). Lark
|
||||||
|
// returns these as free-text reason strings with no machine-readable field
|
||||||
|
// name (verified for code 190014:
|
||||||
|
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}),
|
||||||
|
// so they are lifted into Problem.Hint — the sanctioned free-text recovery
|
||||||
|
// prompt — rather than fabricated structured params. Lifted before the
|
||||||
|
// category switch so any classified arm inherits it; the CategoryAPI arm
|
||||||
|
// below prefers this server detail over the context-free APIHint default.
|
||||||
|
detailHint := liftErrorDetailValues(resp)
|
||||||
|
if detailHint != "" {
|
||||||
|
base.Hint = detailHint
|
||||||
|
}
|
||||||
|
|
||||||
switch meta.Category {
|
switch meta.Category {
|
||||||
case errs.CategoryAuthorization:
|
case errs.CategoryAuthorization:
|
||||||
@@ -129,6 +141,11 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
|||||||
Action: action,
|
Action: action,
|
||||||
}
|
}
|
||||||
case errs.CategoryAPI:
|
case errs.CategoryAPI:
|
||||||
|
// A server-supplied detail (lifted into base.Hint above) wins over the
|
||||||
|
// context-free APIHint default; only fall back to APIHint when absent.
|
||||||
|
if base.Hint == "" {
|
||||||
|
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
||||||
|
}
|
||||||
return &errs.APIError{Problem: base}
|
return &errs.APIError{Problem: base}
|
||||||
default:
|
default:
|
||||||
// Fail closed: an unrecognized Category routes to InternalError
|
// Fail closed: an unrecognized Category routes to InternalError
|
||||||
@@ -213,6 +230,10 @@ func stringFromAny(v any) string {
|
|||||||
// per-subtype recovery hint before returning it, so the wire envelope
|
// per-subtype recovery hint before returning it, so the wire envelope
|
||||||
// emitted via BuildAPIError always carries a hint for known config subtypes.
|
// emitted via BuildAPIError always carries a hint for known config subtypes.
|
||||||
func buildConfigError(p errs.Problem) *errs.ConfigError {
|
func buildConfigError(p errs.Problem) *errs.ConfigError {
|
||||||
|
// Config categories have authoritative recovery guidance, so the curated
|
||||||
|
// ConfigHint deliberately overrides any server detail lifted into p.Hint
|
||||||
|
// (the opposite precedence from the CategoryAPI arm, where the lifted
|
||||||
|
// detail wins).
|
||||||
p.Hint = ConfigHint(p.Subtype)
|
p.Hint = ConfigHint(p.Subtype)
|
||||||
return &errs.ConfigError{Problem: p}
|
return &errs.ConfigError{Problem: p}
|
||||||
}
|
}
|
||||||
@@ -231,6 +252,24 @@ func ConfigHint(subtype errs.Subtype) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
|
||||||
|
// emitted via BuildAPIError, for API subtypes whose recovery is context-free.
|
||||||
|
// Context-specific guidance (e.g. a command's flags, an API's own quota) is
|
||||||
|
// layered on by the caller after BuildAPIError returns and overrides this.
|
||||||
|
func APIHint(subtype errs.Subtype) string {
|
||||||
|
switch subtype {
|
||||||
|
case errs.SubtypeConflict:
|
||||||
|
return "retry later and avoid concurrent duplicate requests on the same resource"
|
||||||
|
case errs.SubtypeCrossTenant:
|
||||||
|
return "operate on source and target within the same tenant and region/unit"
|
||||||
|
case errs.SubtypeCrossBrand:
|
||||||
|
return "operate on source and target within the same brand environment"
|
||||||
|
case errs.SubtypeQuotaExceeded:
|
||||||
|
return "reduce the request volume or free quota, then retry after the relevant quota resets"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
||||||
missing := extractMissingScopes(resp)
|
missing := extractMissingScopes(resp)
|
||||||
identity := cc.Identity
|
identity := cc.Identity
|
||||||
@@ -239,6 +278,10 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex
|
|||||||
}
|
}
|
||||||
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
|
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
|
||||||
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
|
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
|
||||||
|
// Permission categories have authoritative recovery guidance (scopes to
|
||||||
|
// grant, console URL), so the curated PermissionHint deliberately overrides
|
||||||
|
// any server detail lifted into p.Hint (the opposite precedence from the
|
||||||
|
// CategoryAPI arm, where the lifted detail wins).
|
||||||
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
|
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
|
||||||
permErr := &errs.PermissionError{
|
permErr := &errs.PermissionError{
|
||||||
Problem: p,
|
Problem: p,
|
||||||
@@ -347,6 +390,32 @@ func PermissionHint(missing []string, identity string, subtype errs.Subtype, con
|
|||||||
return "check the calling identity has the required scope"
|
return "check the calling identity has the required scope"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason
|
||||||
|
// strings and joins them with "; ". Returns "" when the structure is absent or
|
||||||
|
// carries no non-empty value. The shape (verified for code 190014) is
|
||||||
|
// {"error":{"details":[{"value":"<reason>"}]}}.
|
||||||
|
func liftErrorDetailValues(resp map[string]any) string {
|
||||||
|
errBlock, ok := resp["error"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
details, ok := errBlock["details"].([]any)
|
||||||
|
if !ok || len(details) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var values []string
|
||||||
|
for _, d := range details {
|
||||||
|
m, ok := d.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, _ := m["value"].(string); v != "" {
|
||||||
|
values = append(values, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(values, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
||||||
// Returns nil when the structure is absent.
|
// Returns nil when the structure is absent.
|
||||||
func extractMissingScopes(resp map[string]any) []string {
|
func extractMissingScopes(resp map[string]any) []string {
|
||||||
|
|||||||
@@ -220,6 +220,111 @@ func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBuildAPIError_DetailsLiftedToHintOnAPIArm pins that BuildAPIError lifts
|
||||||
|
// resp.error.details[].value into Problem.Hint when the response routes to the
|
||||||
|
// catch-all CategoryAPI arm. The real Lark shape (verified for code 190014) is
|
||||||
|
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}
|
||||||
|
// — only a human-readable reason string, no machine-readable field name. It is
|
||||||
|
// lifted into Hint (sanctioned free-text recovery prompt) rather than fabricated
|
||||||
|
// structured params.
|
||||||
|
func TestBuildAPIError_DetailsLiftedToHintOnAPIArm(t *testing.T) {
|
||||||
|
resp := map[string]any{
|
||||||
|
"code": 190014,
|
||||||
|
"msg": "invalid params",
|
||||||
|
"error": map[string]any{
|
||||||
|
"details": []any{
|
||||||
|
map[string]any{"value": "end_time should be later than start_time"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ProblemOf returned !ok")
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Hint, "end_time should be later than start_time") {
|
||||||
|
t.Errorf("Hint = %q, want it to contain the server detail value", p.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAPIError_MultipleDetailsJoinedIntoHint pins that multiple non-empty
|
||||||
|
// detail values are joined with "; " into a single Hint, and empty values are
|
||||||
|
// skipped.
|
||||||
|
func TestBuildAPIError_MultipleDetailsJoinedIntoHint(t *testing.T) {
|
||||||
|
resp := map[string]any{
|
||||||
|
"code": 190014,
|
||||||
|
"msg": "invalid params",
|
||||||
|
"error": map[string]any{
|
||||||
|
"details": []any{
|
||||||
|
map[string]any{"value": "first reason"},
|
||||||
|
map[string]any{"value": ""},
|
||||||
|
map[string]any{"value": "second reason"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ProblemOf returned !ok")
|
||||||
|
}
|
||||||
|
if p.Hint != "first reason; second reason" {
|
||||||
|
t.Errorf("Hint = %q, want %q", p.Hint, "first reason; second reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAPIError_DetailsSkipsNonMapEntries pins that malformed entries in
|
||||||
|
// the details array (not a JSON object) are skipped rather than panicking, and
|
||||||
|
// well-formed siblings still surface in the Hint.
|
||||||
|
func TestBuildAPIError_DetailsSkipsNonMapEntries(t *testing.T) {
|
||||||
|
resp := map[string]any{
|
||||||
|
"code": 190014,
|
||||||
|
"msg": "invalid params",
|
||||||
|
"error": map[string]any{
|
||||||
|
"details": []any{
|
||||||
|
"i am a bare string, not an object",
|
||||||
|
map[string]any{"value": "the real reason"},
|
||||||
|
42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ProblemOf returned !ok")
|
||||||
|
}
|
||||||
|
if p.Hint != "the real reason" {
|
||||||
|
t.Errorf("Hint = %q, want %q", p.Hint, "the real reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildAPIError_DetailsMalformedShapesNoHint pins that a missing error
|
||||||
|
// block, a non-array details field, and an empty details array all leave the
|
||||||
|
// Hint untouched (no lifted detail) instead of erroring.
|
||||||
|
func TestBuildAPIError_DetailsMalformedShapesNoHint(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
resp map[string]any
|
||||||
|
}{
|
||||||
|
{"no error block", map[string]any{"code": 190014, "msg": "invalid params"}},
|
||||||
|
{"details not array", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": "nope"}}},
|
||||||
|
{"empty details", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{}}}},
|
||||||
|
{"detail values all empty", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{map[string]any{"value": ""}}}}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ProblemOf returned !ok")
|
||||||
|
}
|
||||||
|
// With no liftable detail, the Hint must not echo a server detail.
|
||||||
|
if strings.Contains(p.Hint, "nope") {
|
||||||
|
t.Errorf("Hint should not lift a non-array details field, got %q", p.Hint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
|
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
|
||||||
// when the upstream response omits it — wire envelope must omit the field.
|
// when the upstream response omits it — wire envelope must omit the field.
|
||||||
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {
|
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ var codeMeta = map[int]CodeMeta{
|
|||||||
|
|
||||||
// CategoryConfig
|
// CategoryConfig
|
||||||
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
|
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
|
||||||
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543)
|
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client)
|
||||||
|
|
||||||
// CategoryPolicy
|
// CategoryPolicy
|
||||||
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},
|
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},
|
||||||
|
|||||||
16
internal/errclass/codemeta_calendar.go
Normal file
16
internal/errclass/codemeta_calendar.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package errclass
|
||||||
|
|
||||||
|
import "github.com/larksuite/cli/errs"
|
||||||
|
|
||||||
|
// calendarCodeMeta holds calendar-service Lark code → CodeMeta mappings.
|
||||||
|
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||||
|
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||||
|
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||||
|
var calendarCodeMeta = map[int]CodeMeta{
|
||||||
|
190014: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid params (carries a field-level detail lifted into Hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { mergeCodeMeta(calendarCodeMeta, "calendar") }
|
||||||
39
internal/errclass/codemeta_calendar_test.go
Normal file
39
internal/errclass/codemeta_calendar_test.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package errclass
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLookupCodeMeta_CalendarCodes pins each calendar-service code registered
|
||||||
|
// via the codemeta_calendar.go init() merge to its expected
|
||||||
|
// Category/Subtype/Retryable.
|
||||||
|
func TestLookupCodeMeta_CalendarCodes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
code int
|
||||||
|
wantCat errs.Category
|
||||||
|
wantSubtype errs.Subtype
|
||||||
|
wantRetry bool
|
||||||
|
}{
|
||||||
|
// 190014: calendar "invalid params" with a field-level detail
|
||||||
|
// (error.details[].value) lifted into Hint by BuildAPIError.
|
||||||
|
{190014, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||||
|
meta, ok := LookupCodeMeta(tc.code)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("code %d not registered in codeMeta", tc.code)
|
||||||
|
}
|
||||||
|
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
|
||||||
|
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||||
|
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user