Compare commits

..

35 Commits

Author SHA1 Message Date
liangshuo-1
7eb0ba3257 chore(release): bump version to v1.0.21 (#698)
Change-Id: If34453af159d394a7bfaca9d41641f570b373974
2026-04-28 21:35:31 +08:00
feng zhi hao
af2398d636 feat(mail): add email template management + --template-id on compose (#642)
- `mail +template-create` / `mail +template-update` — manage personal
    email templates (name, subject, body, recipients, attachments,
    inline images).
  - `--template-id` on `+send` / `+draft-create` / `+reply` /
    `+reply-all` / `+forward` — apply a saved template when composing.
    Recipients / subject / body / attachments merge into the draft;
    explicit user flags take precedence.
2026-04-28 21:14:01 +08:00
liangshuo-1
138bf36bb3 chore: changelog for v1.0.21 (#697)
Change-Id: I680e93f7ae7dcb1942d13c766881b8ca6ecc5765
2026-04-28 20:51:18 +08:00
chenxingtong-bytedance
0bbd0f2c7d feat(im): add recovery hint for cross-identity message resources (#652)
Change-Id: I8a43486333638271f0fbbcffca81a60c9f9d2060
2026-04-28 20:16:39 +08:00
evandance
fc9f9c1f26 feat(contact +search-user): add search filters and richer profile fields (#648)
* feat(contact +search-user): add search filters and richer profile fields

- Filter results by chat history, employment status, tenant boundary,
  or enterprise email presence; keyword is now optional so filter-only
  queries ("list all my external contacts") work end-to-end.
- Each result now carries multilingual names, contact email, activation
  state, whether you've chatted with them, tenant context, user
  signature, and a hit-highlight line that surfaces the matched segment
  and the user's department path.
- Always-empty legacy columns and fields the new backend no longer
  returns are dropped.
- Also fixes the contact +get-user skill doc, which previously
  instructed callers to pass --table (a flag that never existed); now
  correctly documents --format table and the full --format enum.

* refactor(lark-contact): clean up search-user code, tighten skill docs

- contact_search_user.go / _test.go: simplify and clarify
- SKILL.md: focus description on user-facing trigger scenarios;
  rework decision table; trim notes to load-bearing constraints
- references/lark-contact-search-user.md: add flag table covering
  all four bool filters; add multi-filter examples; clean up
  output field contract (drop server <h> tag implementation detail)
- references/lark-contact-get-user.md: trim to two real use cases
  (self via user identity; full profile of others via bot identity);
  point user-mode-by-id users to +search-user instead
- .golangci.yml: replace package-level deny on net/http with a
  symbol-level forbidigo rule. Constants (http.MethodPost,
  http.StatusOK) and helpers (http.StatusText) were never the
  intent; only Client / NewRequest / Get / Post / Do etc. are now
  blocked in shortcuts/, matching the rule's actual purpose

Change-Id: Ic42043d3f4c1b675800e48229c7ba2e970da26fe

* fix(contact +search-user): align query limit and reject empty user-ids

API rejects queries longer than 50 characters; local cap was 64 runes,
producing confusing "passed local validation but server-rejected"
behaviour. Lower the cap to 50 and rename the constant accordingly.

Also reject --user-ids inputs that parse to zero entries (",,,",
" , , ", ","): SplitCSV silently dropped empty segments, so the
shortcut sent an empty body to the API and returned indeterminate
results.

Change-Id: Ib34fe897023e175bf4c657273bdb49a33d2f083b

---------

Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-04-28 20:03:07 +08:00
caojie0621
fc22e9a04b feat(common): backfill resource URL when create APIs omit it (#680)
Add BuildResourceURL helper and wire it into doc/sheets/drive/base/wiki
create paths so callers always receive a clickable link, even when the
backend response (MCP degraded path or upstream OpenAPI) returns an
empty URL field. The fallback uses the brand-standard host
(www.feishu.cn / www.larksuite.com), which redirects to the tenant
domain.

Affected entries:
- docs +create v1 / v2
- sheets +create
- drive +create-folder / +import / +upload (newly exposes url)
- wiki +node-create (newly exposes url)

drive +create-shortcut is intentionally skipped because the URL form
depends on the underlying file kind, which the shortcut payload does
not carry.
2026-04-28 18:20:35 +08:00
sang-neo03
9ba0d15161 Feat/risk tiering (#633)
* feat(risk): implement confirmation for high-risk write operations

* feat(risk): streamline confirmation for high-risk write operations

* feat(risk): document approval protocol for high-risk write operations

* feat(risk): refine confirmation protocol for high-risk write operations

* feat(risk): remove redundant variable declaration in risk test

* feat(risk): add 'Yes' flag to various test cases for confirmation
2026-04-28 18:15:56 +08:00
syh-cpdsss
b8d0f96265 fix: readme statistics (#691)
Change-Id: I1c54eabe3af260e1817acbc898408ec9ed557586
2026-04-28 16:45:18 +08:00
syh-cpdsss
2e4cfb4921 feat: okr progress records (#574) 2026-04-28 15:56:07 +08:00
hugang-lark
23066c8eee feat: enhance calendar event search and room finding (#679)
* feat: room find with multi room name

* fix: support --format for calendar +create

* feat: search event

* docs: clarify recurring calendar instance handling

Change-Id: I15ff863fc5de4890b6b3f3d984946b5a60eaef07

* refactor: unit test

---------

Co-authored-by: calendar-assistant <calendar-assistant@users.noreply.github.com>
2026-04-28 15:45:24 +08:00
tuxedomm
c09b03f854 fix(cmdutil): default flag completions to disabled (#688)
The previous default (atomic.Bool zero-value = enabled) meant any
*cobra.Command built without first calling configureFlagCompletions
leaked into cobra's package-global flagCompletionFunctions map. Bench
runs (scripts/bench_build) showed hundreds of KB and thousands of
objects retained per Build call.

Flip the semantics so the zero-value matches the safe default:
- Rename internal var to flagCompletionsEnabled (zero = disabled).
- Rename public API to SetFlagCompletionsEnabled / FlagCompletionsEnabled.
- Update call sites in cmd/root.go and scripts/bench_build/main.go.
- Add cmd.TestBuild_DefaultNoCompletionLeak: asserts that, with no
  setter call at all, repeated cmd.Build invocations stay under 50 KB
  and 500 objects per build (observed: ~0.7 KB, 3 objs/build). This
  closes the gap that let the wrong default ship — every previous
  test explicitly Set the switch before exercising it.

Change-Id: Ifefb04af5fd45eea9676a344a64ad071b6a4cd1a
2026-04-28 12:31:35 +08:00
liuxinyanglxy
4d4508dfd7 feat(event): add event subscription & consume system (#654)
* feat(event): add event subscription & consume system with orphan bus detection

Introduces end-to-end Feishu event consumption via a new `lark-cli event`
command family. Users can subscribe to and consume real-time events
(IM messages, chat/member lifecycle, reactions, ...) in a forked bus
daemon architecture with orphan detection, reflected + overrideable JSON
schemas, and AI-friendly `--json` / `--jq` output.

Commands
--------
- `event list [--json]`      list subscribable EventKeys
- `event schema <key>`       Parameters + Output Schema + auth info
- `event consume <key>`      foreground blocking consume; SIGINT/SIGTERM
                             /stdin-EOF shutdown; `--max-events` /
                             `--timeout` bounded; `--jq` projection;
                             `--output-dir` spool; `--param` KV inputs
- `event status [--fail-on-orphan] [--json]`   bus daemon health
- `event stop [--all] [--force] [--json]`      stop bus daemon(s)
- `event _bus` (hidden)      forked daemon entrypoint

Architecture
------------
- Bus daemon (internal/event/bus): per-AppID forked process that holds
  the Feishu long-poll connection and fans events out to 1..N local
  consumers over an IPC socket. Drop-oldest backpressure, TOCTOU-safe
  cleanup via AcquireCleanupLock, idle-timeout self-shutdown, graceful
  SIGTERM.
- Consume client (internal/event/consume): fork+dial the daemon,
  handshake, remote preflight (HTTP /open-apis/event/v1/connection),
  JQ projection, sequence-gap detection, health probe. Bounded
  execution (`--max-events` / `--timeout`) for AI/script usage.
- Wire protocol (internal/event/protocol): newline-delimited JSON
  frames with 1 MB size cap and 5 s write deadlines. Hello / HelloAck /
  PreShutdownCheck / Shutdown / StatusQuery control messages.
- Orphan detection (internal/event/busdiscover): OS process-table scan
  (ps on Unix, PowerShell on Windows) with two-gate cmdline filter
  (lark-cli + event _bus) that naturally rejects pid-reused unrelated
  processes.
- Transport (internal/event/transport): Unix socket on darwin/linux,
  Windows named pipe on windows.
- Schema system (internal/event, internal/event/schemas): SchemaDef with
  mutually-exclusive Native (framework wraps V2 envelope) or Custom
  (zero-touch) specs. Reflection reads `desc` / `enum` / `kind` struct
  tags, with array elements diving into `items`. FieldOverrides overlay
  engine addresses paths via JSON Pointer (including `/*` array
  wildcard) and runs post-reflect, post-envelope. Lint guards orphan
  override paths.
- IM events (events/im): 11 keys — receive / read / recalled, chat and
  member lifecycle, reactions — all with per-field open_id / union_id /
  user_id / chat_id / message_id / timestamp_ms format annotations.

Robustness
----------
- Bus idle-timer race fix: re-check live conn count under lock before
  honoring the tick; Stop+drain before Reset per timer contract.
- Protocol frame cap: replace `br.ReadBytes('\n')` with `ReadFrame` that
  rejects frames > MaxFrameBytes (1 MB). Closes a DoS path where any
  local peer could grow the reader's buffer unbounded.
- Control-message writes gated by WriteTimeout (5 s) so a wedged peer
  kernel buffer can't stall writers indefinitely.
- Consume signal goroutine: `signal.Stop` + `ctx.Done` select, no leak
  across repeated invocations in the same process.
- JQ pre-flight compile so bad expressions fail before the bus fork and
  any server-side PreConsume side effects.
- `f.NewAPIClient`'s `*core.ConfigError` now passes through unwrapped
  so the actionable "run lark-cli config init" hint reaches the user.

Subprocess / AI contract
------------------------
- `event consume` emits `[event] ready event_key=<key>` on stderr once
  the bus handshake completes and events will flow. Parent processes
  block-read stderr until this line before reading stdout — no `sleep`
  fallback needed.
- All list-like commands have `--json` for structured consumption.
- Skill docs in `skills/lark-event/` (SKILL.md + references/) brief AI
  agents on the command surface, JQ against Output Schema, bounded
  execution, and subprocess lifecycle.

Testing
-------
Unit tests across bus/hub, consume loop, protocol codec, dedup,
registry, transport (Unix + Windows), schema reflection, field
overrides, pointer resolver. Integration tests cover fork startup,
shutdown, orphan detection, probe, stdin EOF, preflight, bounded
execution, and Windows busdiscover PowerShell compatibility.

Change-Id: Ib69d6d8409b33b99790081e273d4b5b01b7dbf80

* fix(event): address CodeRabbit findings + lift patch coverage above 60%

CodeRabbit comments (PR #654)
-----------------------------
1. bus/dedup: IsDuplicate dropped legitimate (post-TTL) events after
   cleanupExpired fired. The run-every-1000-inserts cleanup removed
   TTL-expired IDs from the `seen` map but left them in the ring;
   IsDuplicate's ring-scan fallback then rediscovered them and falsely
   reported "duplicate", and bus.Publish silently dropped the event.
   Removed the ring-scan branch — `seen` is the sole authority, the ring
   only bounds map size via overflow eviction. New regression test
   TestDedupFilter_TTLExpiryAfterCleanupRunRespected exercises the 10-
   insert + cleanup path and guards the fix.

2. consume/remote_preflight: the decoder only read `data.online_instance_
   cnt`. A non-zero business code with no data payload decoded to 0 and
   callers treated it as "verified zero", forking a local bus that would
   duplicate events. Added Code / Msg fields and promoted code != 0 into
   an error so the caller distinguishes verified-zero from check-failed.

3. cmd/event/stop: swapped os.ReadDir / os.Stat to vfs.ReadDir / vfs.Stat
   in discoverAppIDs per project guideline (enables test mocking). New
   TestDiscoverAppIDs_* lifts discoverAppIDs from 0% to 100%.

4. cmd/event/appmeta_err: narrowed authURLPattern from
   feishu.cn|feishu.net|larksuite.com|larkoffice.com to the two hosts
   consoleScopeGrantURL actually produces. Kept the allowlist pinned to
   ResolveEndpoints' output with a comment flagging the synchrony.

5. cmd/event/list: moved "No EventKeys registered." and "Use 'event
   schema <key>' for details." hints to stderr so `event list | jq`
   style pipelines don't ingest them as data.

6. cmd/event/schema: runSchema is a RunE entry point; swapped the bare
   fmt.Errorf on resolveSchemaJSON failure to output.Errorf so AI
   agents parse a structured error envelope.

Coverage bumps (patch ~50% -> ~60%)
-----------------------------------
internal/event/consume/loop_test.go: loop.go was 0% at patch time.
New tests cover consumeLoop end-to-end via net.Pipe (events -> sink,
max-events -> ctx.Done -> PreShutdownCheck/Ack), seq-gap warning,
jq filtering + early compile failure, isTerminalSinkError classifier.
Takes consumeLoop from 0% to ~74%.

internal/event/protocol/messages_test.go: all NewXxx constructors,
Encode/Decode roundtrip per message type, EncodeWithDeadline deadline
enforcement, ReadFrame MaxFrameBytes rejection + EOF propagation.
Takes protocol from 28% to ~86%.

Also bundles small UX polish:
- cmd/event/consume: --output-dir flag doc flags path-traversal behavior;
  jq-validation failures now re-wrap with an event-specific hint
  pointing at `event schema` for payload shape.
- internal/event/consume.validateParams: error now names the EventKey
  and lists valid param names inline so AI callers recover without a
  second `event schema` round-trip.
- skills/lark-event: description expanded to mention
  listener/subscribe/consume synonyms + the IM scope set explicitly;
  lark-event-im reference polished; obsolete lark-event-subscribe
  reference removed.

Verified with go test -race -timeout 120s across ./cmd/event/...,
./events/..., ./internal/event/...; gofmt clean; go vet clean.

Change-Id: I3837b8645ea1d7529c9a8fd4c2bbfa965ae1b519

* test(event): cover format helpers + cobra factories

Adds cmd/event/format_helpers_test.go covering the pure output helpers
and factory wire-ups that RunE-level tests would need a live bus to
exercise:

- writeStopJSON: shape assertions + nil → [] (scripts expecting
  .results | length must not see null).
- writeStopText: stdout vs stderr routing — stopped / no-bus lines to
  stdout, refused / errored lines to stderr.
- busState.String: all three discriminator values.
- humanizeDuration: each bucket boundary (seconds / minutes / hours / days).
- writeStatusText: covers stateNotRunning / stateRunning (with consumer
  table) / stateOrphan (with kill hint).
- writeStatusJSON: orphan entry carries suggested_action + issue;
  running entry must NOT carry those fields (hint-leak guard for
  scripts that key on issue != "").
- exitForOrphan: flag-off never errors; flag-on errors iff any orphan
  is present, with ExitValidation code.
- NewCmdConsume / NewCmdStatus / NewCmdStop / NewCmdList / NewCmdBus:
  flag registration + RunE presence, so review catches flag-name drift.
  NewCmdBus check also pins Hidden=true.

Lifts cmd/event coverage 51.7% → 61.1%; aggregate event-package
coverage crosses the 60% codecov patch threshold (62% locally).

Change-Id: I9ecf3d905a8f9607b9441ee8a61e746496e2be63

* fix(event): address lint + deadcode CI failures

4 golangci-lint findings + 1 deadcode finding flagged on PR #654.

lint
----
1. cmd/event/stop.go:86 (ineffassign): `targets := []string{}` is
   overwritten by both branches of the `if o.all` below, so the empty-
   slice initializer is dead. Switched to `var targets []string`.
2. cmd/event/consume.go nilerr: the user-identity scope preflight
   swallows a non-nil ResolveToken error and returns nil. This is
   intentional — a missing/expired user token must not block consume;
   the bus handshake will surface the real auth error with actionable
   hints. Added `//nolint:nilerr` with a 4-line comment pinning the
   reasoning.
3. events/im/message_receive.go:62 nilerr: malformed JSON payload
   returns the original bytes + nil so consumers still see the event
   (the WARN breadcrumb lives in the outer loop). Added
   `//nolint:nilerr` with a one-line comment.
4. internal/event/schemas/fromtype_test.go:26 unused: `unexportedStr`
   is a reflection-test fixture — its presence (not value) exercises
   the FromType skip-unexported path verified at the "unexported
   field should not be in schema" assertion. Added `//nolint:unused`
   and a 4-line comment pointing at the guarded assertion.

deadcode
--------
5. internal/event/testutil/testutil.go: NewTCPFake has no callers in
   the repo. Removed the constructor plus the `inner == nil` TCP-mode
   branches from Listen / Dial / Cleanup. FakeTransport now only
   supports the wrapped-overlay mode (NewWrappedFake), which is the
   one every existing test uses. Doc comment simplified accordingly.

Verified locally: go test -race -timeout 120s across ./cmd/event/...,
./events/..., ./internal/event/... all green; gofmt clean; go vet
clean.

Change-Id: Ie8a2270827a0bde6b8159ab70aaf5c1e9ca7d5b9

* fix(event): drop stale enum + simplify protocol test type helper

- events/im/message_receive.go: dropped the `enum` tag on
  ImMessageReceiveOutput.MessageType. convertlib registers many more
  message types than the old 11-item list (video / location /
  calendar / todo / vote / hongbao / merge_forward / folder / ...),
  so a partial enum would tell AI consumers that valid values like
  "video" are invalid and produce false-negative JQ filters.

- internal/event/protocol/messages_test.go: collapsed the
  typeOf → reflectTypeName → stringType chain in
  TestEncode_DecodeRoundtripAllTypes to a single fmt.Sprintf("%T", v).
  The hand-maintained type switch silently returned "<unknown>" for
  any new message type, which would have let future Decode bugs slip
  past the roundtrip assertion. Also removed a dead `cases` table at
  the top of TestConstructors_PinTypeField left over from an earlier
  refactor.

Change-Id: I831e96f8417e80637596030d652a559de0d33122

* docs(event): polish skill docs + rename root_path_hint to jq_root_path

- skills/lark-event/SKILL.md, lark-event-im.md: translated to English,
  reorganized around a top-level "Core commands" table, scenario
  recipes tightened.
- cmd/event/schema.go: renamed the writeSchemaJSON hint field
  RootPathHint / "root_path_hint" -> JQRootPath / "jq_root_path" to
  make its purpose (a jq path prefix) obvious at the call site; no
  external consumer depends on the old name yet.

Change-Id: I00c14061ca33caedc0975bfeadc4b26d3dcd314d

* chore(event): strip excessive comments

Change-Id: I8f44f36f5dbdba3ef95dfc67069dc796232f91ec

* fix(event): dedup self-eviction race + protocol oversized-frame test

dedup: in IsDuplicate, the ring-slot eviction step deleted seen[id] even
when ring[pos] equalled the freshly-recorded id (post-TTL reinsertion
landing on its own historical slot). Net result: ring still held id but
seen did not, so the next IsDuplicate(id) returned false and the
duplicate was delivered. Skip the delete when old == eventID. New
TestDedupFilter_SelfEvictionPreservesFreshEntry pins the invariant by
pre-loading the ring slot and asserting the second call still reports
duplicate.

protocol: TestReadFrame_RejectsOversized used strings.Contains feeding
t.Logf, so any non-nil error passed — including a future regression
that returned io.ErrUnexpectedEOF while silently keeping the buffer
unbounded. Promoted MaxFrameBytes overflow to a sentinel
ErrFrameTooLarge and the test now asserts via errors.Is.

Change-Id: I50281dad392152b0ca083fd30c38eb0695e63bd3

* docs(event): clarify .content shape per message_type + add sender filter recipe

Change-Id: I619fd15c1a362e42e6602fd3e3316bbc75eddc5e

* fix(event): replace cmdline-regex bus discovery with PID file + close concurrent fork race

Bus discovery previously walked the OS process table and parsed `--profile cli_*` from
cmdline; the regex rejected any non-cli_ profile name (D-03a). Replace with per-AppID
bus.pid + bus.alive.lock under events/<AppID>/, probed via try-lock. AppID round-trips
through the directory name, so the profile-vs-AppID confusion is gone by construction.

Also fix B-07 (two consumers each fork an independent bus, halving event delivery):
- forkBus holds bus.fork.lock until child is dial-able, not just until cmd.Start
- bus daemon takes alive.lock before binding the socket; cleanup-TOCTOU race can no
  longer leave two listeners on different inodes

status.go renders an orphan with PID=0 distinctly (live bus but pid file unreadable)
so we never print "Action: kill 0".

Change-Id: I3bf0a6cf1d91fb274ac5a6df83d66896aafb291f

* style(event): gofmt bus.go

Trailing blank line introduced when appending acquireAliveLock helper.

Change-Id: I4ae1b4a4363dc6c89dcbd6a170f4563117490ba3

* fix(event): swap os.Remove/Rename for vfs.* and silence forbidigo on internal diagnostics

golangci-lint forbidigo blocks os.* in internal/. Switch the pid-file write to vfs.Remove/vfs.Rename and add a nolint marker on the two stderr diagnostics in busdiscover, matching the existing pattern in consume/*.

Change-Id: Ia6768be62aefeb8ca40f991d3130a78ef2ec0ea5

* fix(event): cross-platform --all + clean SIGPIPE shutdown for consume

- stop --all: replace bus.sock-file probe with busdiscover lock-based
  scan; previously skipped Windows entirely (named-pipe transport, no
  socket on disk) and misidentified Unix stale sockets as live. Same
  win for `event status` (shares discoverAppIDs).

- consume: ignore SIGPIPE so a closed stdout pipe (e.g. `... | head -n 1`)
  surfaces as EPIPE error and reaches the existing isTerminalSinkError
  cleanup path (log "output pipe closed", lastForKey query, hub
  unregister), instead of being killed by Go's default fd 1/2 SIGPIPE
  handler with exit 141 and zero deferred cleanup.
  Build-tagged: real on unix, no-op on windows (no SIGPIPE there).

Change-Id: I453b19f05c489fd9d5c1a9ba3bdc35e127c15b83

* docs(event): translate IM EventKey descriptions and field tags to English

Aligns with the rest of the codebase (titles, struct names, README) which
are already in English. Surfaces in `event list` / `event schema` and is
also consumed by AI agents.

- events/im/message_receive.go: 11 desc tags on ImMessageReceiveOutput
- events/im/native.go: 10 description fields on Native EventKeys
- events/im/register.go: im.message.receive_v1 Description

Change-Id: I6f46950b4793f137e0129c1f06019a3419195443

* docs(event): drop misleading AuthTypes[0] auto-default claim

The KeyDefinition comment and SKILL.md flag table both stated that
`--as auto` resolves to `AuthTypes[0]`. It does not — ResolveAs goes
through global rules (config default_as / credential hint / `bot`
fallback) without consulting the EventKey. AuthTypes is only used by
CheckIdentity as a post-resolve whitelist.

Reword the field comment to plain whitelist semantics and have SKILL.md
defer `--as` documentation to lark-shared.

Change-Id: Ia5d3d3790aed05813a0fa72d6b43518224e2055b

* revert(comments): restore original comments on 3rd-party files

e61482a stripped comments across 105 files. Restore the four files
authored by others (cmd/build.go, shortcuts/common/{types,runner}.go,
shortcuts/event/subscribe.go) to their pre-strip state so unrelated
documentation isn't churned in this PR.

Change-Id: Ie2527b06bfaf5b3861b0b9dff1e19bbfe7dde456
2026-04-28 11:19:02 +08:00
ethan-zhx
05d8137c7d feat(drive): extend +add-comment to support slides targets (#674)
Change-Id: Id87ecce098d87f7db82389a73f3134b66fcd4814
2026-04-28 10:25:32 +08:00
liangshuo-1
17a85d319d fix(e2e/wiki): pass obj_type when deleting wiki nodes in cleanup (#687)
* fix(e2e/wiki): pass obj_type when deleting wiki nodes in cleanup

The wiki node DELETE endpoint now rejects requests without obj_type
(API error 99992402: "obj_type is required"), causing TestWiki_NodeWorkflow
cleanup to fail on every run. Forward the obj_type from the create/copy
response into the delete query params so cleanup succeeds.

* fix(e2e/wiki): delete cleanup wiki nodes via drive v1 endpoint

The wiki v2 DELETE /spaces/{space_id}/nodes/{node_token} endpoint is
undocumented and rejects requests with `obj_type is required` even when
obj_type is forwarded as a query parameter (see actions run #25005966144).

Switch cleanup to the documented path: delete the underlying drive file
via DELETE /drive/v1/files/{obj_token}?type=<obj_type>, which removes the
backing document and the wiki node in one call.

Change-Id: Ieb93b1f92ea758d8b80bcfdd4f20b2be8f35a0bd

* fix(e2e/wiki): pass obj_type to wiki delete in body, not query

Previous attempts:
- query (?obj_type=docx) → API still rejects with 99992402 obj_type
  required (the wiki delete-node endpoint reads it from the body, not
  the query string).
- drive v1 fallback → bot identity does not have drive write scope and
  returns 1061004 forbidden, so we cannot reuse drive's delete API for
  the cleanup helpers.
2026-04-28 00:53:40 +08:00
ethan-zhx
a16eb24ba9 feat(slides):lark slides fonts (#681)
Change-Id: Ic59709f1720b1d9142b3c11ea373015557628af0
2026-04-27 20:48:46 +08:00
liangshuo-1
f6f242ed57 chore(release): v1.0.20 (#682)
Change-Id: I1fdfa09633bfbe385a191a95b605e1dbcf011768
2026-04-27 20:15:38 +08:00
zhicong666-bytedance
7124b18baa docs(skills): clarify minutes routing semantics (#591) 2026-04-27 20:06:29 +08:00
calendar-assistant
78d92de6af feat: add calendar update shortcut (#678)
Change-Id: Ie2d4bde6cd28bbf4d7946db38c5c9be13edc6ba9
2026-04-27 19:27:20 +08:00
fangshuyu-768
8ec95a4e39 docs(lark-drive): add missing import command examples (#669)
Add example commands for file types declared in the supported-conversions
table but absent from the command examples section: .docx/.doc, .txt,
.html, .xls -> sheet, and .csv -> sheet.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:25:30 +08:00
sang-neo03
fe9dc4ce6a fix(strict-mode): reject explicit --as instead of silently overriding it (#673)
* fix(strict-mode): reject explicit --as instead of silently overriding it

ResolveAs checked strict mode before the --as flag, so `--as bot` under strict=user
  was silently rewritten to user. Reorder so explicit --as is returned as-is and CheckStrictMode rejects the conflict (exit=2). Implicit paths (--as auto / unset) are still forced by
   strict mode.

* fix(strict-mode): fix CI
2026-04-27 15:18:35 +08:00
Schumi Lin
1e2144ee08 docs(readme): add Project (Meegle) to Features table (#660)
Project management for Lark/Feishu is provided by the standalone
meegle-cli (https://github.com/larksuite/meegle-cli), which requires a
separate install. Surface it in the Features table so users can
discover the capability without expecting it to ship inside lark-cli.
2026-04-27 01:37:26 +08:00
zkh-bytedance
20fba1e601 chore(whiteboard): Manual disable edge case for svg compatible (#661) 2026-04-25 21:36:40 +08:00
arnold9672
97f817d088 feat(im): add at-chatter-ids filter to +messages-search (#612)
Add --at-chatter-ids flag to shortcuts/im/im_messages_search.go that
passes filter.at_chatter_ids to the search API, restricting results to
messages that @mention any of the given user open_ids. Messages that
2026-04-25 20:05:14 +08:00
sang-neo03
ddf6f0cb7d feat(pagination): preserve pagination state on truncation and natural… (#659)
* feat(pagination): preserve pagination state on truncation and natural end

* feat(pagination): drop page_token from merged output to reflect aggregate view
2026-04-25 17:54:52 +08:00
shifengjuan-dev
834a899e2b feat(lark-im): add chat.members.bots to skill docs (#616)
- Add chat.members.bots entry under chat.members API resources
- Add chat.members.bots -> im:chat.members:read scope mapping

Change-Id: I57039a9a8649d794bbda84a1e41fae9cc31d570a
2026-04-25 16:23:03 +08:00
liujinkun2025
aa48d70d7a feat(drive): add +search shortcut with flat filter flags (#658)
Expose doc_wiki/search v2 under the drive domain via explicit flags
(--query, --edited-since, --commented-since, --opened-since,
--created-since, --mine, --creator-ids, --doc-types, --folder-tokens,
--space-ids, ...) instead of a nested JSON filter, so natural-language
queries from AI agents map 1:1 to discrete flags.

Time handling:
- my_edit_time and my_comment_time are snapped to the hour (floor/ceil)
  with a stderr notice, since those fields are aggregated at hour
  granularity server-side. create_time passes through as-is.
- open_time has a server-side 3-month cap per request. When
  --opened-since / --opened-until span exceeds 90 days, the CLI narrows
  the request to the most recent 90-day slice and emits a stderr notice
  listing every remaining slice's --opened-* values so the agent can
  re-invoke for older ranges. Spans over 365 days are rejected up front
  to bound runaway slicing.

Flag ergonomics:
- --doc-types accepts mixed case; values are normalized to upper case
  before validation and before being sent to the server.
- --sort default is translated to the server enum DEFAULT_TYPE (every
  other sort value upper-cases 1:1).

Error hints:
- Lark code 99992351 (referenced open_id outside the app's contact
  visibility) is enriched with a +search-specific hint that
  distinguishes API scope from contact visibility and points at
  --creator-ids / --sharer-ids as the likely source.

Skill docs:
- new reference at skills/lark-drive/references/lark-drive-search.md,
  including the open_time slicing protocol and the paginate-within-
  slice-before-switching agent playbook.
- lark-drive/SKILL.md routes resource-discovery to drive +search.
- lark-doc/SKILL.md and lark-doc-search.md mark docs +search as
  deprecated and point users at drive +search.

Change-Id: I36d620045809b448446d4fdbdfa923b05794da19
2026-04-25 16:22:35 +08:00
chanthuang
2e7a11a8e8 feat(mail): support sharing emails to IM chats (#637)
* feat(mail): add +share-to-chat shortcut to share emails as IM cards

Two-step API (create share token → send card) wrapped in a single
shortcut. Supports message-id/thread-id, five receive-id-type variants
(chat_id, open_id, user_id, union_id, email), and dry-run mode.

Change-Id: Ic7b8c01c0d25fef262f35be92555f1fd019bd679
Co-Authored-By: AI

* fix(mail): regenerate SKILL.md from skill-template instead of manual edit

Add missing safety rule 8 (draft link rule) to skill-template/domains/mail.md
so it survives regeneration. SKILL.md is now produced by `make gen-skills`
in the registry repo rather than hand-edited.

Change-Id: I9cf3605deae8b6de2042e40819fedc304967e78e
Co-Authored-By: AI

* fix(mail): add docstrings and use real validation path in tests

- Add Go doc comments to exported symbols for docstring coverage
- Rewrite tests to exercise MailShareToChat.Validate via RuntimeContext
  instead of duplicating validation logic
- Replace hand-rolled containsStr with strings.Contains
- Add httpmock stubs for execute and error path tests

Change-Id: Ic781494f61e9e844224185844bce7b0c48e8e200
Co-Authored-By: AI

* test(mail): add dry-run E2E test for +share-to-chat

Validate request shape (method, URL, mailbox path) under --dry-run
with fake credentials. Covers message-id, thread-id, and custom
mailbox variants.

Change-Id: Iae87bf141cbe4f312d3e9b1fca4ba175052c5c35
Co-Authored-By: AI

* fix(mail): include request body and params in dry-run output

DryRun now mirrors Execute: the share-token POST shows message_id or
thread_id, and the send POST shows receive_id_type and receive_id.
E2E test updated to assert these fields. Also fix strconv.Itoa usage.

Change-Id: I00f8770fd5a12b7354986c5e5077f97cfe5d6653

* style(mail): gofmt dry-run test file

Change-Id: I47dc6a9a47252dcfb7853737f88dfdaef65a0ae7

* test(mail): assert exact API call count in dry-run test

Change-Id: I9f4a1a183b55d03f5248eb4adddfddb08037ca95
2026-04-24 21:11:48 +08:00
liangshuo-1
5d129314c0 chore(release): v1.0.19 (#656)
Change-Id: I551f756deb8e244cf9b4ba47720ef299195859ec
2026-04-24 19:58:53 +08:00
MaxHuang22
7d0ceb5d58 feat: block auth/config when external credential provider is active (#627)
* feat(credential): add ActiveExtensionProviderName to detect external providers

Change-Id: Ie17a4b714e5eca17ae574ac188d570721790107d

* feat(cmdutil): add RequireBuiltinCredentialProvider guard for external credential providers

Change-Id: I8f2ea0af6fe6506b29beb69264b04c21c0f75da1

* feat(config): block all config subcommands when external credential provider is active

Change-Id: If215cb8f0a53cc92d623dd3d842e4465124af2be

* feat(auth): block all auth subcommands when external credential provider is active

Change-Id: Ia61184fb2daeb6a7a38d122c647b7cb67eaf8b1f

* fix(auth,config): silence usage in PersistentPreRunE to match root command behaviour

Change-Id: I6d4b3c7d9d9c7b10fc2482fdc80252bf051771ee

* test(auth,config,credential): address CodeRabbit review comments

- Use cmd.Find() to assert SilenceUsage on matched subcommand (not parent)
- Add TestRequireBuiltinCredentialProvider_PropagatesProviderError for error path
- Add 'external' fallback sentinel in ActiveExtensionProviderName

Change-Id: Iba35779ad2ed9807556264ba23db7096541e2bf3
2026-04-24 18:45:31 +08:00
zkh-bytedance
fd4c35b10e feat(whiteboard): pin whiteboard-cli to v0.2.10 in lark-whiteboard skill (#649) 2026-04-24 15:27:36 +08:00
xzcong0820
d92f0a2204 feat(mail): add read receipt support (--request-receipt, +send-receipt, +decline-receipt)
End-to-end RFC 3798 Message Disposition Notification support, covering
  both sides of the receipt flow — requesting a receipt when composing, and                                                                                                                                             
  responding to one (send or decline) when reading.                                                                                                                                                                     
  
  Request side (compose)                                                                                                                                                                                                
  - New --request-receipt flag on +send / +reply / +reply-all / +forward /
    +draft-create / +draft-edit. When set, the outgoing EML carries a                                                                                                                                                   
    Disposition-Notification-To header (RFC 3798) addressed to the resolved
    sender. Recipient mail clients may prompt the user, auto-send a receipt,                                                                                                                                            
    or silently ignore — delivery is not guaranteed.                                                                                                                                                                    
  - requireSenderForRequestReceipt gates the flag against a controlled
    sender address resolved BEFORE the orig.headTo fallback in +reply /                                                                                                                                                 
    +reply-all / +forward, so the DNT cannot silently land on someone else
    in CC / shared-mailbox flows.                                                                                                                                                                                       
                                                                                                                                                                                                                        
  Response side                                                                                                                                                                                                         
  - +send-receipt: build a system-templated reply for messages carrying the                                                                                                                                             
    READ_RECEIPT_REQUEST label (-607). Subject / recipient / sent / read
    time layout matches the Lark client; body is non-customizable — receipt                                                                                                                                             
    bodies are system templates by industry convention; free-form notes
    belong in +reply. Risk:"high-risk-write" + --yes required.                                                                                                                                                          
  - +decline-receipt: clear READ_RECEIPT_REQUEST without sending anything
    (mirrors the client's "不发送" / "Don't send" button). Idempotent on                                                                                                                                                
    re-run; Risk:"write" — no --yes needed.                       
                                                                                                                                                                                                                        
  Read-path hints                                                                                                                                                                                                       
  - +message / +messages / +thread emit a stderr hint when surfacing a                                                                                                                                                  
    mail carrying READ_RECEIPT_REQUEST, exposing BOTH response paths                                                                                                                                                    
    (+send-receipt --yes / +decline-receipt) so agents present a real                                                                                                                                                   
    choice to the user instead of silently auto-sending.
                                                                                                                                                                                                                        
  Guard rails                                                     
  - +send / +reply / +reply-all / +forward stay draft-by-default and
    require --confirm-send to send, gated by a dynamic scope check for                                                                                                                                                  
    mail:user_mailbox.message:send (absent from the default scope set so
    draft-only flows don't need the sensitive permission).                                                                                                                                                              
  - All header-bound user input (sender / display name / recipient /                                                                                                                                                    
    subject) goes through CR/LF rejection plus Bidi / zero-width / line-                                                                                                                                                
    separator guards, mirroring emlbuilder.validateHeaderValue, to block                                                                                                                                                
    header injection and visual spoofing.                                                                                                                                                                               
  - Hint output strips terminal control characters (CR, LF) from any
    untrusted field embedded into the user-visible suggestion.                                                                                                                                                          
                                                                                                                                                                                                                        
  Backend coupling                                                                                                                                                                                                      
  - Outgoing receipt EML carries the private header                                                                                                                                                                     
    X-Lark-Read-Receipt-Mail: 1. The data-access backend parses it into
    BodyExtra.IsReadReceiptMail; DraftSend then applies READ_RECEIPT_SENT                                                                                                                                               
    (-608) and clears READ_RECEIPT_REQUEST (-607) from the original                                                                                                                                                     
    message, closing the client-side banner.                                                                                                                                                                            
  - en receipts require backend TCC SubjectPrefixListForAdvancedSearch to                                                                                                                                               
    include "Read Receipt:" for conversation-view aggregation; zh prefix                                                                                                                                                
    ("已读回执:") is already configured.                                                                                                                                                                               
                                                                                                                                                                                                                        
  Docs: new reference pages for +send-receipt / +decline-receipt;                                                                                                                                                       
  --request-receipt noted on each compose-side reference; SKILL.md
  workflow (section 9) describes the full privacy-safe decision tree on                                                                                                                                                 
  both sides.                                                                                                                                                                                                           
                                                                                                                                                                                                                        
  Tests cover emlbuilder DispositionNotificationTo / IsReadReceiptMail                                                                                                                                                  
  helpers, receiptMetaLabels (zh / en), buildReceiptSubject, text and HTML
  body generators (with HTML escaping and Bidi guards), header-injection                                                                                                                                                
  defenses, sender-resolution gating (CC-only / shared-mailbox regression),
  hint emission paths, and the full +send-receipt / +decline-receipt happy                                                                                                                                              
  + idempotent paths via httpmock.
2026-04-24 14:26:17 +08:00
YangJunzhou-01
6f444c5dc2 feat: request thread roots for chat message list (#635)
Update im +chat-messages-list to request only thread root messages from /open-apis/im/v1/messages by default. This aligns the shortcut request shape with topic-group usage and makes the intended API behavior explicit in both runtime params and dry-run output.

Change-Id: I3901b27e70b0e4db506ff199eb03c96fcf98671d
2026-04-24 10:40:35 +08:00
SunPeiYang996
e42033f5b5 feat(doc): add v2 API for docs +create / +fetch / +update (#638)
Adds an `--api-version v2` path to the docs shortcuts, backed by the
`docs_ai/v1/documents` OpenAPI. DocxXML is the default document format
and Markdown is available as an alternative. Content input is unified
across the three shortcuts via `--content` + `--doc-format`. The v1
(MCP) path is preserved for backward compatibility and now prints a
deprecation notice on use.

Shortcuts:

  - `docs +create --api-version v2`: create a document from XML or
    Markdown, with optional `--parent-token` or `--parent-position`.
    Bot identity continues to auto-grant the current CLI user
    full_access on the new document.

  - `docs +fetch --api-version v2`: adds `--detail simple|with-ids|full`
    for export granularity and `--scope full|outline|range|keyword|section`
    for partial reads, along with `--context-before` / `--context-after`,
    `--max-depth`, and `--revision-id`.

  - `docs +update --api-version v2`: introduces structured operations
    via `--command`: `str_replace`, `block_delete`, `block_insert_after`,
    `block_copy_insert_after`, `block_replace`, `block_move_after`,
    `overwrite`, `append`.

Framework support in `shortcuts/common`:

  - `OutRaw` / `OutFormatRaw` emit the JSON envelope with HTML escaping
    disabled so XML/HTML document bodies are preserved verbatim.
  - New `Shortcut.PostMount` hook runs after a cobra.Command is fully
    configured; used here to install a version-aware help function
    that hides flags belonging to the inactive `--api-version`.

Also refreshes the lark-doc skill pack (SKILL.md, create/fetch/update
references, new lark-doc-xml and lark-doc-md references, style and
workflow guides), README examples, and downstream skill call sites
(lark-drive, lark-vc, lark-whiteboard, lark-workflow-meeting-summary,
lark-event).

Change-Id: Ide2d86b190a4e21095ae29096e7fb00031d80489
2026-04-23 23:24:30 +08:00
wittam-01
24afe39516 feat: support wiki node targets in drive +upload (#611)
Change-Id: Iaf94270c0a2a2ac02af81c234553ac5850c0668b
2026-04-23 22:37:47 +08:00
Yuxuan Zhao
d3340f5006 fix e2e pagination assumptions (#639) 2026-04-23 22:06:58 +08:00
351 changed files with 36391 additions and 5458 deletions

1
.gitignore vendored
View File

@@ -33,6 +33,7 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
internal/registry/meta_data.json
cmd/api/download.bin
app.log

View File

@@ -54,6 +54,12 @@ linters:
- path: internal/vfs/
linters:
- forbidigo
# The shortcuts-no-raw-http forbidigo rule below is shortcuts-only;
# internal/ legitimately wraps raw HTTP for the client / credential layer.
- path-except: shortcuts/
text: shortcuts-no-raw-http
linters:
- forbidigo
settings:
depguard:
@@ -70,16 +76,18 @@ linters:
desc: >-
shortcuts must not import internal/vfs/localfileio directly.
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
shortcuts-no-raw-http:
files:
- "**/shortcuts/**"
deny:
- pkg: "net/http"
desc: >-
use RuntimeContext.DoAPI/CallAPI/DoAPIJSON instead of raw net/http.
The client layer handles auth, headers, and error normalization.
forbidigo:
forbid:
# ── http: shortcuts must not construct raw HTTP requests ──
# Bans request / client construction; constants (http.MethodPost,
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
# intentionally allowed since they don't bypass the runtime layer.
- pattern: http\.(Client|NewRequest|NewRequestWithContext|Get|Post|PostForm|Head|DefaultClient|DefaultTransport|RoundTripper|Do|Serve|ListenAndServe)\b
msg: >-
[shortcuts-no-raw-http] use RuntimeContext.DoAPI/CallAPI/DoAPIJSON
instead of constructing raw HTTP. The runtime handles auth, headers,
and error normalization. (Constants and helpers like http.MethodPost,
http.StatusOK, http.StatusText remain allowed.)
# ── os: already wrapped in internal/vfs ──
- pattern: os\.(Stat|Lstat|Open|OpenFile|Rename|ReadFile|WriteFile|Getwd|UserHomeDir|ReadDir)\b
msg: "use the corresponding vfs.Xxx() from internal/vfs"

View File

@@ -2,6 +2,57 @@
All notable changes to this project will be documented in this file.
## [v1.0.21] - 2026-04-28
### Features
- **contact**: Add search filters and richer profile fields to `+search-user` (#648)
- **common**: Backfill resource URL when create APIs omit it (#680)
- **risk**: Add risk tiering for command sensitivity classification (#633)
- **okr**: Add progress records support (#574)
- **calendar**: Enhance event search and meeting room finding (#679)
- **event**: Add event subscription & consume system (#654)
- **drive**: Extend `+add-comment` to support slides targets (#674)
- **slides**: Add font management for slides (#681)
### Bug Fixes
- **cmdutil**: Default flag completions to disabled (#688)
- **e2e/wiki**: Pass `obj_type` when deleting wiki nodes in cleanup (#687)
- **readme**: Fix readme statistics (#691)
## [v1.0.20] - 2026-04-27
### Features
- **drive**: Add `+search` shortcut with flat filter flags (#658)
- **mail**: Support sharing emails to IM chats via `+share-to-chat` (#637)
- **calendar**: Add `+update` shortcut (#678)
- **im**: Add `--at-chatter-ids` filter to `+messages-search` (#612)
- **pagination**: Preserve pagination state on truncation and natural end (#659)
- **lark-im**: Add `chat.members.bots` to skill docs (#616)
### Bug Fixes
- **strict-mode**: Reject explicit `--as` instead of silently overriding it (#673)
- **whiteboard**: Manual disable edge case for svg compatibility (#661)
### Documentation
- **lark-drive**: Add missing import command examples (#669)
- **readme**: Add Project (Meegle) to Features table (#660)
## [v1.0.19] - 2026-04-24
### Features
- **mail**: Add read receipt support — `--request-receipt` on compose, `+send-receipt` / `+decline-receipt` for response
- **doc**: Add v2 API for `docs +create` / `+fetch` / `+update` (#638)
- **im**: Request thread roots for chat message list (#635)
- **drive**: Support wiki node targets in `+upload` (#611)
- **config**: Block `auth` / `config` when external credential provider is active (#627)
- **whiteboard**: Pin `whiteboard-cli` to `v0.2.10` in `lark-whiteboard` skill (#649)
## [v1.0.18] - 2026-04-23
### Features
@@ -488,6 +539,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21
[v1.0.20]: https://github.com/larksuite/cli/releases/tag/v1.0.20
[v1.0.19]: https://github.com/larksuite/cli/releases/tag/v1.0.19
[v1.0.18]: https://github.com/larksuite/cli/releases/tag/v1.0.18
[v1.0.17]: https://github.com/larksuite/cli/releases/tag/v1.0.17
[v1.0.16]: https://github.com/larksuite/cli/releases/tag/v1.0.16

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 22 AI Agent [Skills](./skills/).
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 23 AI Agent [Skills](./skills/).
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
## Why lark-cli?
- **Agent-Native Design** — 22 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 14 business domains, 200+ curated commands, 22 AI Agent [Skills](./skills/)
- **Agent-Native Design** — 23 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 16 business domains, 200+ curated commands, 23 AI Agent [Skills](./skills/)
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
@@ -38,7 +38,8 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
| 🕐 Attendance | Query personal attendance check-in records |
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
## Installation & Quick Start
@@ -155,6 +156,7 @@ lark-cli auth status
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
| `lark-okr` | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
## Authentication
@@ -201,7 +203,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --title "Weekly Report" --markdown "# Progress\n- Completed feature X"
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
```
Run `lark-cli <service> --help` to see all shortcut commands.

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 22 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 23 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — 22 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 14 大业务域、200+ 精选命令、22 个 AI Agent [Skills](./skills/)
- **为 Agent 原生设计** — 23 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 16 大业务域、200+ 精选命令、23 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -38,7 +38,8 @@
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐指标 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
## 安装与快速开始
@@ -156,6 +157,7 @@ lark-cli auth status
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
| `lark-okr` | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
## 认证
@@ -202,7 +204,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --title "周报" --markdown "# 本周进展\n- 完成了 X 功能"
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
```
运行 `lark-cli <service> --help` 查看所有快捷命令。

View File

@@ -24,6 +24,16 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "OAuth credentials and authorization management",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
cmd.SilenceUsage = true
// cmd.Name() returns the subcommand name (e.g. "login"), not "auth".
// Pass "auth" as a literal so the error message reads
// `"auth" is not supported: ...`
return f.RequireBuiltinCredentialProvider(cmd.Context(), "auth")
},
}
cmdutil.DisableAuthCheck(cmd)

View File

@@ -5,15 +5,19 @@ package auth
import (
"context"
"errors"
"io"
"net/http"
"sort"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
)
@@ -303,3 +307,72 @@ func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credenti
return &credential.TokenResult{Token: "unexpected-token"}, nil
}
}
// stubExternalProvider is a minimal extcred.Provider that always reports an account,
// simulating env/sidecar mode for guard tests.
type stubExternalProvider struct{ name string }
func (s *stubExternalProvider) Name() string { return s.name }
func (s *stubExternalProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "test-app"}, nil
}
func (s *stubExternalProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
// newFactoryWithExternalProvider creates a Factory whose Credential uses a stub
// extension provider, simulating env/sidecar credential mode.
func newFactoryWithExternalProvider(t *testing.T) *cmdutil.Factory {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
stub := &stubExternalProvider{name: "env"}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = cred
return f
}
func TestAuthBlockedByExternalProvider(t *testing.T) {
f := newFactoryWithExternalProvider(t)
tests := []struct {
name string
args []string
}{
{"login", []string{"login"}},
{"logout", []string{"logout"}},
{"status", []string{"status"}},
{"check", []string{"check", "--scope", "calendar:read"}}, // --scope is required
{"list", []string{"list"}},
{"scopes", []string{"scopes"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := NewCmdAuth(f)
cmd.SilenceErrors = true
cmd.SetErr(io.Discard)
cmd.SetArgs(tt.args)
// Locate the subcommand before execution (PersistentPreRunE receives it as cmd).
matched, _, _ := cmd.Find(tt.args)
err := cmd.Execute()
// PersistentPreRunE sets SilenceUsage on the matched subcommand, not the parent.
if matched != nil && matched != cmd && !matched.SilenceUsage {
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
}
})
}
}

View File

@@ -12,10 +12,12 @@ import (
"github.com/larksuite/cli/cmd/completion"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/doctor"
cmdevent "github.com/larksuite/cli/cmd/event"
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/keychain"
@@ -117,6 +119,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"runtime"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
)
// TestBuild_DefaultNoCompletionLeak verifies that, without any call to
// SetFlagCompletionsEnabled, repeated cmd.Build invocations do not leak
// *cobra.Command instances into cobra's package-global flag-completion map.
//
// This guards the new default (completions disabled) — if someone flips the
// zero-value back to "enabled", the per-Build memory growth observed under
// `scripts/bench_build` would resurface in production hot paths that build
// the root command without serving a completion request.
func TestBuild_DefaultNoCompletionLeak(t *testing.T) {
if cmdutil.FlagCompletionsEnabled() {
t.Fatalf("precondition: FlagCompletionsEnabled() = true, want false (state polluted by another test)")
}
snap := func() (heapMB float64, objs uint64) {
runtime.GC()
runtime.GC()
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)
return float64(m.HeapAlloc) / 1024 / 1024, m.HeapObjects
}
// Warm one-time caches (registry JSON decode, embed reads) so the first
// Build's lazy allocations don't skew the per-iteration delta.
_ = Build(context.Background(), cmdutil.InvocationContext{})
baseMB, baseObj := snap()
const N = 20
for range N {
_ = Build(context.Background(), cmdutil.InvocationContext{})
}
mb, obj := snap()
deltaMB := mb - baseMB
deltaObj := int64(obj) - int64(baseObj)
perBuildKB := deltaMB * 1024 / float64(N)
perBuildObj := deltaObj / int64(N)
t.Logf("%d builds: +%.2f MB, +%d objects (%.1f KB/build, %d objs/build)",
N, deltaMB, deltaObj, perBuildKB, perBuildObj)
// With completions disabled (the default), per-Build retained growth
// should be minimal. Threshold is conservative: the previously observed
// leak with completions enabled was ~hundreds of KB and thousands of
// objects per Build, well above this bound.
const maxKBPerBuild = 50.0
const maxObjsPerBuild = 500
if perBuildKB > maxKBPerBuild {
t.Errorf("per-build heap growth = %.1f KB, want <= %.1f KB (completion registration may be leaking)", perBuildKB, maxKBPerBuild)
}
if perBuildObj > maxObjsPerBuild {
t.Errorf("per-build object growth = %d, want <= %d", perBuildObj, maxObjsPerBuild)
}
}

View File

@@ -14,6 +14,14 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Global CLI configuration management",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
cmd.SilenceUsage = true
// Pass "config" as a literal — cmd.Name() would return the subcommand name.
return f.RequireBuiltinCredentialProvider(cmd.Context(), "config")
},
}
cmdutil.DisableAuthCheck(cmd)

View File

@@ -6,13 +6,16 @@ package config
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
@@ -340,3 +343,68 @@ func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
t.Fatalf("error = %v, want mention of App Secret", err)
}
}
// stubConfigExtProvider simulates env/sidecar credential mode for config guard tests.
type stubConfigExtProvider struct{ name string }
func (s *stubConfigExtProvider) Name() string { return s.name }
func (s *stubConfigExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "test-app"}, nil
}
func (s *stubConfigExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
func newConfigFactoryWithExternalProvider(t *testing.T) *cmdutil.Factory {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
stub := &stubConfigExtProvider{name: "env"}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = cred
return f
}
func TestConfigBlockedByExternalProvider(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
tests := []struct {
name string
args []string
}{
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
{"remove", []string{"remove"}},
{"show", []string{"show"}},
{"default-as", []string{"default-as", "user"}},
{"strict-mode", []string{"strict-mode", "off"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := NewCmdConfig(f)
cmd.SilenceErrors = true
cmd.SetErr(io.Discard)
cmd.SetArgs(tt.args)
// Locate the subcommand before execution (PersistentPreRunE receives it as cmd).
matched, _, _ := cmd.Find(tt.args)
err := cmd.Execute()
// PersistentPreRunE sets SilenceUsage on the matched subcommand, not the parent.
if matched != nil && matched != cmd && !matched.SilenceUsage {
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
}
})
}
}

25
cmd/event/appmeta_err.go Normal file
View File

@@ -0,0 +1,25 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"fmt"
"regexp"
)
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.
func describeAppMetaErr(err error) string {
msg := err.Error()
if url := authURLPattern.FindString(msg); url != "" {
return fmt.Sprintf("bot is missing scopes needed for app-version metadata; grant at: %s", url)
}
const maxErrLen = 200
if len(msg) > maxErrLen {
return msg[:maxErrLen] + "…"
}
return msg
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"strings"
"testing"
)
const realisticPermError = `API GET /open-apis/application/v6/applications/cli_XXXXXXXXXXXXXXXX/app_versions?lang=zh_cn&page_size=2 returned 400: {"code":99991672,"msg":"Access denied. One of the following scopes is required: [application:application:self_manage, application:application.app_version:readonly].应用尚未开通所需的应用身份权限:[application:application:self_manage, application:application.app_version:readonly]点击链接申请并开通任一权限即可https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=application:application:self_manage,application:application.app_version:readonly&op_from=openapi&token_type=tenant","error":{"message":"Refer to the documentation...","log_id":"20260421101203E2A5F141245B6F43B3A6"}}`
func TestDescribeAppMetaErr_PermissionDeniedShort(t *testing.T) {
got := describeAppMetaErr(errors.New(realisticPermError))
if len(got) > 400 {
t.Errorf("summary too long (%d chars): %q", len(got), got)
}
if !strings.Contains(got, "scope") {
t.Errorf("summary should mention scope requirement, got: %q", got)
}
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=application:application:self_manage,application:application.app_version:readonly&op_from=openapi&token_type=tenant"
if !strings.Contains(got, wantURL) {
t.Errorf("summary missing grant URL\ngot: %q\nwant: %q", got, wantURL)
}
for _, noise := range []string{"log_id", `"error":`, "Refer to the documentation"} {
if strings.Contains(got, noise) {
t.Errorf("summary leaked noise %q: %q", noise, got)
}
}
}
func TestDescribeAppMetaErr_UnknownErrorTruncated(t *testing.T) {
long := strings.Repeat("x", 500)
got := describeAppMetaErr(errors.New(long))
if len(got) > 220 {
t.Errorf("unknown error not truncated, len=%d", len(got))
}
}
func TestDescribeAppMetaErr_ShortErrorPassesThrough(t *testing.T) {
got := describeAppMetaErr(errors.New("network unreachable"))
if got != "network unreachable" {
t.Errorf("short err should pass through unchanged, got: %q", got)
}
}
func TestDescribeAppMetaErr_LarkOfficeDomain(t *testing.T) {
msg := `... grant link: https://open.larksuite.com/app/cli_xyz/auth?q=application:application:self_manage&op_from=openapi&token_type=tenant ...`
got := describeAppMetaErr(errors.New(msg))
if !strings.Contains(got, "open.larksuite.com") {
t.Errorf("want larksuite URL extracted, got: %q", got)
}
}

69
cmd/event/bus.go Normal file
View File

@@ -0,0 +1,69 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"os"
"os/signal"
"path/filepath"
"syscall"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/bus"
"github.com/larksuite/cli/internal/event/transport"
)
// NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go.
func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
var domain string
cmd := &cobra.Command{
Use: "_bus",
Short: "Internal event bus daemon (do not call directly)",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
// Sanitize AppID: an unsanitized value could escape events/ via ".." or separators.
eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(cfg.AppID))
logger, err := bus.SetupBusLogger(eventsDir)
if err != nil {
return err
}
tr := transport.New()
b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger)
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(sigCh)
go func() {
select {
case <-sigCh:
cancel()
case <-ctx.Done():
}
}()
return b.Run(ctx)
},
}
cmd.Flags().StringVar(&domain, "domain", "", "API domain")
_ = cmd.Flags().MarkHidden("domain")
return cmd
}

24
cmd/event/console_url.go Normal file
View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/core"
)
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
host, appID, strings.Join(scopes, ","))
}
// consoleEventSubscriptionURL points at the app's event subscription console page.
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s/app/%s/event", host, appID)
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
"im:message:readonly",
"im:message.group_at_msg",
})
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
}
}
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
}
}
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
t.Errorf("unexpected url: %s", got)
}
}

371
cmd/event/consume.go Normal file
View File

@@ -0,0 +1,371 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/consume"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
type consumeCmdOpts struct {
params []string
jqExpr string
quiet bool
outputDir string
maxEvents int
timeout time.Duration
}
func NewCmdConsume(f *cmdutil.Factory) *cobra.Command {
var o consumeCmdOpts
cmd := &cobra.Command{
Use: "consume <EventKey>",
Short: "Start consuming events for an EventKey",
Long: `Start consuming real-time events for the given EventKey.
The consume command connects to the event bus daemon (starting it if needed),
subscribes to the specified EventKey, and streams processed events to stdout.
Output is one JSON object per line (NDJSON). Pipe through 'jq .' if you need
pretty-printed formatting.
Use 'event list' to see all available EventKeys.
Use 'event schema <EventKey>' for parameter details.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runConsume(cmd, f, args[0], o)
},
}
cmd.Flags().StringArrayVarP(&o.params, "param", "p", nil, "Key=value parameter (repeatable)")
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
})
return cmd
}
func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consumeCmdOpts) error {
// Pipe-close (e.g. `... | head -n 1`) must reach the EPIPE error path in the loop, not SIGPIPE-kill.
ignoreBrokenPipe()
cfg, err := f.Config()
if err != nil {
return err
}
paramMap, err := parseParams(o.params)
if err != nil {
return err
}
keyDef, ok := eventlib.Lookup(eventKey)
if !ok {
return unknownEventKeyErr(eventKey)
}
identity, err := resolveIdentity(cmd, f, keyDef)
if err != nil {
return err
}
if o.jqExpr != "" {
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
return output.ErrWithHint(
output.ExitValidation, "validation",
err.Error(),
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
)
}
}
outputDir := o.outputDir
if outputDir != "" {
safePath, err := sanitizeOutputDir(outputDir)
if err != nil {
return err
}
outputDir = safePath
}
domain := core.ResolveEndpoints(cfg.Brand).Open
// Surface auth errors before forking the bus daemon.
if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil {
return err
}
apiClient, err := f.NewAPIClient()
if err != nil {
return err
}
runtime := &consumeRuntime{client: apiClient, accessIdentity: identity}
// botRuntime pins AsBot: /app_versions rejects UAT (99991668) and /connection is app-level.
botRuntime := &consumeRuntime{client: apiClient, accessIdentity: core.AsBot}
// Weak-dependency fetch: failures leave appVer==nil and downgrade preflight to a no-op.
preflightErrOut := f.IOStreams.ErrOut
if o.quiet {
preflightErrOut = io.Discard
}
appVer, appVerErr := appmeta.FetchCurrentPublished(cmd.Context(), botRuntime, cfg.AppID)
switch {
case appVerErr != nil:
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(appVerErr))
case appVer == nil:
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
}
pf := &preflightCtx{
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
}
if err := preflightEventTypes(pf); err != nil {
return err
}
if err := preflightScopes(cmd.Context(), pf); err != nil {
return err
}
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigCh)
go func() {
select {
case <-sigCh:
if !o.quiet && f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, "\nShutting down...")
}
cancel()
case <-ctx.Done():
}
}()
errOut := f.IOStreams.ErrOut
if o.quiet {
errOut = io.Discard
}
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
if !f.IOStreams.IsTerminal {
watchStdinEOF(os.Stdin, cancel, errOut)
}
if err := consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{
EventKey: eventKey,
Params: paramMap,
JQExpr: o.jqExpr,
Quiet: o.quiet,
OutputDir: outputDir,
Runtime: runtime,
Out: f.IOStreams.Out,
ErrOut: errOut,
RemoteAPIClient: botRuntime,
MaxEvents: o.maxEvents,
Timeout: o.timeout,
IsTTY: f.IOStreams.IsTerminal,
}); err != nil {
return err
}
return nil
}
// resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist.
func resolveIdentity(cmd *cobra.Command, f *cmdutil.Factory, keyDef *eventlib.KeyDefinition) (core.Identity, error) {
flagAs := core.Identity(cmd.Flag("as").Value.String())
identity := f.ResolveAs(cmd.Context(), cmd, flagAs)
if len(keyDef.AuthTypes) > 0 {
if err := f.CheckIdentity(identity, keyDef.AuthTypes); err != nil {
return "", err
}
}
return identity, nil
}
type preflightCtx struct {
factory *cmdutil.Factory
appID string
brand core.LarkBrand
eventKey string
identity core.Identity
keyDef *eventlib.KeyDefinition
appVer *appmeta.AppVersion
}
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
func preflightScopes(ctx context.Context, pf *preflightCtx) error {
if len(pf.keyDef.Scopes) == 0 || pf.identity == "" {
return nil
}
if ctx == nil {
ctx = context.Background()
}
var storedScopes string
switch {
case pf.identity.IsBot():
if pf.appVer == nil {
return nil
}
storedScopes = strings.Join(pf.appVer.TenantScopes, " ")
case pf.identity == core.AsUser:
result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID))
if err != nil || result == nil || result.Scopes == "" {
return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error
}
storedScopes = result.Scopes
default:
return nil
}
missing := auth.MissingScopes(storedScopes, pf.keyDef.Scopes)
if len(missing) == 0 {
return nil
}
return output.ErrWithHint(
output.ExitAuth, "auth",
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
)
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
if identity.IsBot() {
return fmt.Sprintf(
"grant these scopes and publish a new app version at: %s",
consoleScopeGrantURL(brand, appID, missing),
)
}
return fmt.Sprintf(
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
strings.Join(missing, " "),
)
}
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
func preflightEventTypes(pf *preflightCtx) error {
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
return nil
}
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
for _, t := range pf.appVer.EventTypes {
subscribed[t] = true
}
var missing []string
for _, t := range pf.keyDef.RequiredConsoleEvents {
if !subscribed[t] {
missing = append(missing, t)
}
}
if len(missing) == 0 {
return nil
}
return output.ErrWithHint(
output.ExitValidation, "validation",
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
pf.keyDef.Key, strings.Join(missing, ", ")),
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
consoleEventSubscriptionURL(pf.brand, pf.appID)),
)
}
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
func sanitizeOutputDir(dir string) (string, error) {
if strings.HasPrefix(dir, "~") {
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
}
safe, err := validate.SafeOutputPath(dir)
if err != nil {
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
}
return safe, nil
}
// resolveTenantToken fetches the app's tenant access token.
func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
if err != nil {
return "", output.ErrAuth("resolve tenant access token: %s", err)
}
if result == nil || result.Token == "" {
return "", output.ErrWithHint(
output.ExitAuth, "auth",
fmt.Sprintf("no tenant access token available for app %s", appID),
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
)
}
return result.Token, nil
}
var (
errInvalidParamFormat = errors.New("invalid --param format")
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
errOutputDirUnsafe = errors.New("unsafe --output-dir")
)
func parseParams(raw []string) (map[string]string, error) {
m := make(map[string]string)
for _, kv := range raw {
k, v, ok := strings.Cut(kv, "=")
if !ok || k == "" {
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
}
m[k] = v
}
return m, nil
}
// watchStdinEOF drains r until EOF, writes a diagnostic, then cancels; only safe in non-TTY mode.
func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
go func() {
_, _ = io.Copy(io.Discard, r)
fmt.Fprintln(errOut, "[event] stdin closed — shutting down. "+
"consume treats stdin EOF as exit signal (wired for AI subprocess callers). "+
"To keep running: pass --max-events/--timeout for bounded run, "+
"or keep stdin open (e.g. `< /dev/tty` interactive, `< <(tail -f /dev/null)` script), "+
"or stop via SIGTERM instead of closing stdin.")
cancel()
}()
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"bytes"
"context"
"io"
"strings"
"testing"
"time"
)
func TestWatchStdinEOF_CancelsOnEOF(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
watchStdinEOF(strings.NewReader(""), cancel, io.Discard)
select {
case <-ctx.Done():
case <-time.After(1 * time.Second):
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
}
}
func TestWatchStdinEOF_StaysAliveWhileReaderBlocks(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pr, _ := io.Pipe()
defer pr.Close()
watchStdinEOF(pr, cancel, io.Discard)
select {
case <-ctx.Done():
t.Fatal("watchStdinEOF cancelled without EOF")
case <-time.After(200 * time.Millisecond):
}
}
// On EOF the watcher must emit a diagnostic naming stdin close + workarounds (daemon-style callers depend on it).
func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var buf bytes.Buffer
watchStdinEOF(strings.NewReader(""), cancel, &buf)
select {
case <-ctx.Done():
got := buf.String()
for _, want := range []string{"stdin closed", "--max-events", "--timeout", "SIGTERM"} {
if !strings.Contains(got, want) {
t.Errorf("diagnostic missing %q; got:\n%s", want, got)
}
}
case <-time.After(1 * time.Second):
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
}
}

143
cmd/event/consume_test.go Normal file
View File

@@ -0,0 +1,143 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"strings"
"testing"
)
func TestParseParams(t *testing.T) {
cases := []struct {
name string
in []string
want map[string]string
wantSentry error
wantEcho string
}{
{
name: "empty input",
in: nil,
want: map[string]string{},
},
{
name: "single key=value",
in: []string{"mailbox=user@example.com"},
want: map[string]string{"mailbox": "user@example.com"},
},
{
name: "multiple pairs",
in: []string{"a=1", "b=2", "c=3"},
want: map[string]string{"a": "1", "b": "2", "c": "3"},
},
{
name: "value containing = is kept intact",
in: []string{"filter=foo=bar"},
want: map[string]string{"filter": "foo=bar"},
},
{
name: "empty value allowed",
in: []string{"key="},
want: map[string]string{"key": ""},
},
{
name: "duplicate key — last wins",
in: []string{"k=1", "k=2"},
want: map[string]string{"k": "2"},
},
{
name: "missing = separator",
in: []string{"mailbox"},
wantSentry: errInvalidParamFormat,
wantEcho: `"mailbox"`,
},
{
name: "leading = (empty key)",
in: []string{"=value"},
wantSentry: errInvalidParamFormat,
wantEcho: `"=value"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseParams(tc.in)
if tc.wantSentry != nil {
if err == nil {
t.Fatalf("want error wrapping %v, got nil", tc.wantSentry)
}
if !errors.Is(err, tc.wantSentry) {
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
}
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tc.want) {
t.Fatalf("len = %d, want %d; got=%v", len(got), len(tc.want), got)
}
for k, v := range tc.want {
if got[k] != v {
t.Errorf("key %q: got %q, want %q", k, got[k], v)
}
}
})
}
}
func TestSanitizeOutputDir(t *testing.T) {
cases := []struct {
name string
in string
wantSentry error
}{
{
name: "relative path accepted",
in: "./output",
},
{
name: "nested relative path accepted",
in: "events/today",
},
{
name: "tilde rejected explicitly",
in: "~/events",
wantSentry: errOutputDirTilde,
},
{
name: "parent escape rejected",
in: "../outside",
wantSentry: errOutputDirUnsafe,
},
{
name: "absolute path rejected",
in: "/tmp/events",
wantSentry: errOutputDirUnsafe,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := sanitizeOutputDir(tc.in)
if tc.wantSentry != nil {
if err == nil {
t.Fatalf("want error wrapping %v, got nil (path=%q)", tc.wantSentry, got)
}
if !errors.Is(err, tc.wantSentry) {
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == "" {
t.Errorf("expected non-empty safe path, got %q", got)
}
})
}
}

29
cmd/event/event.go Normal file
View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "event",
Short: "Consume and manage real-time events",
Long: `Unified event consumption system. Use 'event consume <EventKey>' to start consuming events.`,
// Without SilenceUsage, RunE errors print the full flag help banner.
SilenceUsage: true,
}
cmd.AddCommand(NewCmdConsume(f))
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdSchema(f))
cmd.AddCommand(NewCmdStatus(f))
cmd.AddCommand(NewCmdStop(f))
cmd.AddCommand(NewCmdBus(f))
return cmd
}

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/output"
)
func TestWriteStopJSON_ShapeAndEmpty(t *testing.T) {
var buf bytes.Buffer
if err := writeStopJSON(&buf, []stopResult{
{AppID: "cli_XXXXXXXXXXXXXXXX", Status: stopStopped, PID: 42},
{AppID: "cli_YYYYYYYYYYYYYYYY", Status: stopRefused, PID: 43, Reason: "2 active consumer(s)"},
}); err != nil {
t.Fatalf("writeStopJSON: %v", err)
}
var got struct {
Results []map[string]interface{} `json:"results"`
}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
}
if len(got.Results) != 2 {
t.Fatalf("results len = %d, want 2", len(got.Results))
}
if got.Results[0]["status"] != "stopped" {
t.Errorf("results[0].status = %v, want stopped", got.Results[0]["status"])
}
if got.Results[1]["status"] != "refused" {
t.Errorf("results[1].status = %v, want refused", got.Results[1]["status"])
}
buf.Reset()
if err := writeStopJSON(&buf, nil); err != nil {
t.Fatalf("writeStopJSON(nil): %v", err)
}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("nil output is not JSON: %v\n%s", err, buf.String())
}
if got.Results == nil || len(got.Results) != 0 {
t.Errorf("results = %v, want []", got.Results)
}
}
func TestWriteStopText_RoutesToStdoutOrStderr(t *testing.T) {
var out, errOut bytes.Buffer
writeStopText(&out, &errOut, []stopResult{
{AppID: "cli_XXXXXXXXXXXXXXXX", Status: stopStopped, PID: 1},
{AppID: "cli_YYYYYYYYYYYYYYYY", Status: stopNoBus},
{AppID: "cli_ZZZZZZZZZZZZZZZZ", Status: stopRefused, Reason: "busy"},
{AppID: "cli_WWWWWWWWWWWWWWWW", Status: stopErrored, Reason: "kill failed"},
})
if !strings.Contains(out.String(), "Bus stopped for cli_XXXXXXXXXXXXXXXX") {
t.Errorf("stopped line missing from stdout: %q", out.String())
}
if !strings.Contains(out.String(), "No bus running for cli_YYYYYYYYYYYYYYYY") {
t.Errorf("no-bus line missing from stdout: %q", out.String())
}
if !strings.Contains(errOut.String(), "Refused stopping cli_ZZZZZZZZZZZZZZZZ: busy") {
t.Errorf("refused line missing from stderr: %q", errOut.String())
}
if !strings.Contains(errOut.String(), "Error stopping cli_WWWWWWWWWWWWWWWW: kill failed") {
t.Errorf("error line missing from stderr: %q", errOut.String())
}
if strings.Contains(out.String(), "Refused") || strings.Contains(out.String(), "Error") {
t.Errorf("failure lines leaked to stdout: %q", out.String())
}
}
func TestBusState_String(t *testing.T) {
for _, tc := range []struct {
s busState
want string
}{
{stateNotRunning, "not_running"},
{stateRunning, "running"},
{stateOrphan, "orphan"},
} {
if got := tc.s.String(); got != tc.want {
t.Errorf("busState(%d).String() = %q, want %q", tc.s, got, tc.want)
}
}
}
func TestHumanizeDuration_AllBuckets(t *testing.T) {
for _, tc := range []struct {
d time.Duration
want string
}{
{30 * time.Second, "30s ago"},
{90 * time.Second, "1m ago"},
{2 * time.Hour, "2h ago"},
{50 * time.Hour, "2d ago"},
} {
if got := humanizeDuration(tc.d); got != tc.want {
t.Errorf("humanizeDuration(%v) = %q, want %q", tc.d, got, tc.want)
}
}
}
func TestWriteStatusText_CoversAllStates(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{AppID: "cli_NOTRUNNINGXXXXXX", State: stateNotRunning},
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 3661,
Active: 2,
Consumers: []protocol.ConsumerInfo{
{PID: 10, EventKey: "im.message.receive_v1", Received: 5, Dropped: 0},
{PID: 11, EventKey: "im.message.receive_v1", Received: 3, Dropped: 1},
},
},
{AppID: "cli_ORPHANXXXXXXXXXX", State: stateOrphan, PID: 5678, UptimeSec: 3600},
})
out := buf.String()
for _, want := range []string{
"── cli_NOTRUNNINGXXXXXX ──",
"Bus: not running",
"── cli_RUNNINGXXXXXXXXX ──",
"running (PID 1234",
"Active consumers: 2",
"im.message.receive_v1",
"── cli_ORPHANXXXXXXXXXX ──",
"orphan (PID 5678",
"Action: kill 5678",
} {
if !strings.Contains(out, want) {
t.Errorf("writeStatusText missing %q; full:\n%s", want, out)
}
}
}
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
var buf bytes.Buffer
if err := writeStatusJSON(&buf, []appStatus{
{AppID: "cli_ORPHANXXXXXXXXXX", State: stateOrphan, PID: 99, UptimeSec: 60},
{AppID: "cli_RUNNINGXXXXXXXXX", State: stateRunning, PID: 1, UptimeSec: 10, Active: 0},
}); err != nil {
t.Fatalf("writeStatusJSON: %v", err)
}
var got struct {
Apps []map[string]interface{} `json:"apps"`
}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, buf.String())
}
if len(got.Apps) != 2 {
t.Fatalf("apps len = %d", len(got.Apps))
}
orphan := got.Apps[0]
if orphan["status"] != "orphan" {
t.Errorf("orphan status = %v", orphan["status"])
}
if orphan["suggested_action"] != "kill 99" {
t.Errorf("orphan suggested_action = %v, want 'kill 99'", orphan["suggested_action"])
}
if orphan["issue"] == nil {
t.Error("orphan issue missing")
}
run := got.Apps[1]
if run["issue"] != nil {
t.Errorf("running entry leaked issue: %v", run["issue"])
}
if run["suggested_action"] != nil {
t.Errorf("running entry leaked suggested_action: %v", run["suggested_action"])
}
}
func TestExitForOrphan(t *testing.T) {
orphan := []appStatus{{State: stateOrphan}}
running := []appStatus{{State: stateRunning}}
if err := exitForOrphan(orphan, false); err != nil {
t.Errorf("flag off + orphan → nil expected, got %v", err)
}
if err := exitForOrphan(running, false); err != nil {
t.Errorf("flag off + running → nil expected, got %v", err)
}
if err := exitForOrphan(running, true); err != nil {
t.Errorf("flag on + no orphan → nil expected, got %v", err)
}
err := exitForOrphan(orphan, true)
if err == nil {
t.Fatal("flag on + orphan → expected error, got nil")
}
var exit *output.ExitError
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
t.Errorf("exit code = %v, want ExitValidation", err)
}
}
func errorAs(err error, target interface{}) bool {
if e, ok := err.(*output.ExitError); ok {
if t, ok := target.(**output.ExitError); ok {
*t = e
return true
}
}
return false
}
func TestNewCmdFactories_WireFlags(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
t.Run("consume", func(t *testing.T) {
cmd := NewCmdConsume(f)
for _, flag := range []string{"param", "jq", "quiet", "output-dir", "max-events", "timeout", "as"} {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("consume missing --%s flag", flag)
}
}
if cmd.RunE == nil {
t.Error("consume RunE is nil")
}
})
t.Run("status", func(t *testing.T) {
cmd := NewCmdStatus(f)
for _, flag := range []string{"json", "current", "fail-on-orphan"} {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("status missing --%s flag", flag)
}
}
})
t.Run("stop", func(t *testing.T) {
cmd := NewCmdStop(f)
for _, flag := range []string{"app-id", "all", "force", "json"} {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("stop missing --%s flag", flag)
}
}
})
t.Run("list", func(t *testing.T) {
cmd := NewCmdList(f)
if cmd.Flags().Lookup("json") == nil {
t.Error("list missing --json flag")
}
})
t.Run("bus", func(t *testing.T) {
cmd := NewCmdBus(f)
if !cmd.Hidden {
t.Error("bus should be hidden (internal daemon entrypoint)")
}
if cmd.Flags().Lookup("domain") == nil {
t.Error("bus missing --domain flag")
}
})
}

121
cmd/event/list.go Normal file
View File

@@ -0,0 +1,121 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"encoding/json"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
)
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "list",
Short: "List all available EventKeys",
Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --json for machine-readable output.",
RunE: func(cmd *cobra.Command, args []string) error {
return runList(f, asJSON)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
return cmd
}
func runList(f *cmdutil.Factory, asJSON bool) error {
all := eventlib.ListAll()
if asJSON {
return writeListJSON(f, all)
}
if len(all) == 0 {
// stderr so `event list | jq` doesn't ingest it as a row.
fmt.Fprintln(f.IOStreams.ErrOut, "No EventKeys registered.")
return nil
}
type group struct {
domain string
keys []*eventlib.KeyDefinition
}
order := []string{}
groups := map[string]*group{}
for _, def := range all {
domain := def.Key
if idx := strings.Index(def.Key, "."); idx > 0 {
domain = def.Key[:idx]
}
g, ok := groups[domain]
if !ok {
g = &group{domain: domain}
groups[domain] = g
order = append(order, domain)
}
g.keys = append(g.keys, def)
}
// Global widths (not per-section) keep "── domain ──" dividers aligned across groups.
headers := []string{"KEY", "AUTH", "PARAMS", "DESCRIPTION"}
rowsByDomain := make(map[string][][]string, len(order))
var allRows [][]string
for _, domain := range order {
for _, def := range groups[domain].keys {
auth := "-"
if len(def.AuthTypes) > 0 {
auth = strings.Join(def.AuthTypes, "|")
}
desc := def.Description
if desc == "" {
desc = "-"
}
row := []string{
def.Key,
auth,
fmt.Sprintf("%d", len(def.Params)),
desc,
}
rowsByDomain[domain] = append(rowsByDomain[domain], row)
allRows = append(allRows, row)
}
}
out := f.IOStreams.Out
const colGap = " "
widths := tableWidths(headers, allRows)
printTableRow(out, widths, headers, colGap)
for _, domain := range order {
fmt.Fprintf(out, "\n── %s ──\n", domain)
for _, row := range rowsByDomain[domain] {
printTableRow(out, widths, row, colGap)
}
}
// stderr keeps stdout pipe-clean for `event list | jq`.
fmt.Fprintln(f.IOStreams.ErrOut, "\nUse 'event schema <key>' for details.")
return nil
}
func writeListJSON(f *cmdutil.Factory, all []*eventlib.KeyDefinition) error {
type row struct {
*eventlib.KeyDefinition
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
}
rows := make([]row, len(all))
for i, def := range all {
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return err
}
rows[i] = row{KeyDefinition: def, ResolvedSchema: resolved}
}
output.PrintJson(f.IOStreams.Out, rows)
return nil
}

58
cmd/event/list_test.go Normal file
View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
_ "github.com/larksuite/cli/events"
)
func TestRunList_TextOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, false); err != nil {
t.Fatalf("runList: %v", err)
}
out := stdout.String()
for _, want := range []string{
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"im.message.receive_v1",
"im.message.message_read_v1",
} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q; full output:\n%s", want, out)
}
}
}
func TestRunList_JSONOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runList(f, true); err != nil {
t.Fatalf("runList json: %v", err)
}
var rows []map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if len(rows) == 0 {
t.Fatal("expected at least one EventKey in JSON output")
}
for _, row := range rows {
for _, field := range []string{"key", "event_type", "schema"} {
if row[field] == nil {
t.Errorf("row missing %q: %+v", field, row)
}
}
}
}

176
cmd/event/preflight_test.go Normal file
View File

@@ -0,0 +1,176 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
)
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
key := ""
if keyDef != nil {
key = keyDef.Key
}
return &preflightCtx{
appID: appID,
brand: brand,
eventKey: key,
identity: identity,
keyDef: keyDef,
appVer: appVer,
}
}
func TestPreflightEventTypes_NilAppVer_SkipsCheck(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "im.message.text",
EventType: "im.message.receive_v1",
RequiredConsoleEvents: []string{"im.message.receive_v1"},
}
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, nil)); err != nil {
t.Fatalf("nil appVer must be a weak-dependency skip, got err: %v", err)
}
}
func TestPreflightEventTypes_EmptyRequired_SkipsEvenIfEventTypeSet(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "im.message.message_read_v1",
EventType: "im.message.message_read_v1",
}
appVer := &appmeta.AppVersion{EventTypes: []string{"im.message.receive_v1"}}
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil {
t.Fatalf("empty RequiredConsoleEvents must skip, got: %v", err)
}
}
func TestPreflightEventTypes_AllSubscribed_Passes(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "im.reaction",
EventType: "im.message.reaction.created_v1",
RequiredConsoleEvents: []string{
"im.message.reaction.created_v1",
"im.message.reaction.deleted_v1",
},
}
appVer := &appmeta.AppVersion{EventTypes: []string{
"im.message.reaction.created_v1",
"im.message.reaction.deleted_v1",
"im.message.receive_v1",
}}
if err := preflightEventTypes(newPreflightCtx("cli_x", "feishu", "", def, appVer)); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "mail.receive",
EventType: "mail.user_mailbox.event.message_received_v1",
RequiredConsoleEvents: []string{
"mail.user_mailbox.event.message_received_v1",
"mail.user_mailbox.event.message_read_v1",
},
}
appVer := &appmeta.AppVersion{EventTypes: []string{
"mail.user_mailbox.event.message_received_v1",
}}
err := preflightEventTypes(newPreflightCtx("cli_XXXXXXXXXXXXXXXX", "feishu", "", def, appVer))
if err == nil {
t.Fatal("expected error for missing subscription")
}
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
t.Errorf("error should name the missing event type, got: %v", err)
}
var exit *output.ExitError
if !errors.As(err, &exit) {
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
}
if exit.Code != output.ExitValidation {
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
}
if exit.Detail == nil {
t.Fatal("expected Detail with hint")
}
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
if !strings.Contains(exit.Detail.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
}
}
func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "im.message.text",
Scopes: []string{"im:message", "im:message.group_at_msg"},
}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil))
if err != nil {
t.Fatalf("bot + nil appVer should skip, got: %v", err)
}
}
func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "im.message.text",
Scopes: []string{"im:message", "im:message.group_at_msg"},
}
appVer := &appmeta.AppVersion{TenantScopes: []string{
"im:message",
"im:message.group_at_msg",
"contact:user:readonly",
}}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
if err != nil {
t.Fatalf("all scopes granted, unexpected error: %v", err)
}
}
func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "im.message.text",
Scopes: []string{"im:message", "im:message.group_at_msg"},
}
appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}}
err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer))
if err == nil {
t.Fatal("expected error for missing scope")
}
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
t.Errorf("error should name missing scope, got: %v", err)
}
var exit *output.ExitError
if !errors.As(err, &exit) {
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
}
if exit.Code != output.ExitAuth {
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
}
if exit.Detail == nil {
t.Fatal("expected Detail with hint, got nil Detail")
}
hint := exit.Detail.Hint
wantSubstrings := []string{
"https://open.feishu.cn/app/cli_x/auth?q=",
"im:message.group_at_msg",
"token_type=tenant",
}
for _, want := range wantSubstrings {
if !strings.Contains(hint, want) {
t.Errorf("hint missing %q\ngot: %s", want, hint)
}
}
}
func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
def := &eventlib.KeyDefinition{Key: "x"}
if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil {
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
}
}

49
cmd/event/runtime.go Normal file
View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
// consumeRuntime routes event.APIClient calls through the shared client.APIClient with a pinned identity.
type consumeRuntime struct {
client *client.APIClient
accessIdentity core.Identity
}
func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {
resp, err := r.client.DoAPI(ctx, client.RawApiRequest{
Method: method,
URL: path,
Data: body,
As: r.accessIdentity,
})
if err != nil {
return nil, err
}
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
ct := resp.Header.Get("Content-Type")
if resp.StatusCode >= 400 && !client.IsJSONContentType(ct) && ct != "" {
const maxBodyEcho = 256
body := string(resp.RawBody)
if len(body) > maxBodyEcho {
body = body[:maxBodyEcho] + "…(truncated)"
}
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
}
result, err := client.ParseJSONResponse(resp)
if err != nil {
return nil, err
}
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
return json.RawMessage(resp.RawBody), apiErr
}
return json.RawMessage(resp.RawBody), nil
}

223
cmd/event/schema.go Normal file
View File

@@ -0,0 +1,223 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"encoding/json"
"fmt"
"io"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
"github.com/larksuite/cli/internal/output"
)
// resolveSchemaJSON returns the final JSON Schema for an EventKey (reflected base, V2-wrapped for Native, overlay applied); orphans lists unresolved FieldOverrides pointers.
func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string, error) {
spec, isNative := pickSpec(def.Schema)
if spec == nil {
return nil, nil, nil
}
base, err := renderSpec(spec)
if err != nil {
return nil, nil, err
}
if base == nil {
return nil, nil, nil
}
if isNative {
base = schemas.WrapV2Envelope(base)
}
if len(def.Schema.FieldOverrides) > 0 {
var parsed map[string]interface{}
if err := json.Unmarshal(base, &parsed); err != nil {
return nil, nil, err
}
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
out, err := json.Marshal(parsed)
if err != nil {
return nil, nil, err
}
return out, orphans, nil
}
return base, nil, nil
}
// pickSpec returns the non-nil spec and whether it is Native (requires V2 envelope wrap).
func pickSpec(s eventlib.SchemaDef) (*eventlib.SchemaSpec, bool) {
if s.Native != nil {
return s.Native, true
}
if s.Custom != nil {
return s.Custom, false
}
return nil, false
}
// renderSpec produces a JSON Schema from Type (reflected) or Raw (copied).
func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
if s.Type != nil {
return schemas.FromType(s.Type), nil
}
if len(s.Raw) > 0 {
buf := make(json.RawMessage, len(s.Raw))
copy(buf, s.Raw)
return buf, nil
}
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
}
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "schema <EventKey>",
Short: "Show details for an EventKey",
Long: "Display detailed information about an EventKey including type, events, parameters, and response schema. Use --json for machine-readable output.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runSchema(f, args[0], asJSON)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
return cmd
}
func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
def, ok := eventlib.Lookup(key)
if !ok {
return unknownEventKeyErr(key)
}
if asJSON {
return writeSchemaJSON(f, def)
}
out := f.IOStreams.Out
fmt.Fprintf(out, "Key: %s\n", def.Key)
if def.Description != "" {
fmt.Fprintf(out, "Description: %s\n", def.Description)
}
fmt.Fprintf(out, "Event: %s\n", def.EventType)
if def.PreConsume != nil {
fmt.Fprintf(out, "Pre-consume: yes\n")
}
if len(def.Scopes) > 0 {
fmt.Fprintf(out, "\nRequired Scopes:\n")
for _, s := range def.Scopes {
fmt.Fprintf(out, " - %s\n", s)
}
}
if len(def.RequiredConsoleEvents) > 0 {
fmt.Fprintf(out, "\nRequired Console Events (must be enabled in developer console):\n")
for _, e := range def.RequiredConsoleEvents {
fmt.Fprintf(out, " - %s\n", e)
}
}
if len(def.Params) > 0 {
fmt.Fprintf(out, "\nParameters:\n")
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
for _, p := range def.Params {
required := "no"
if p.Required {
required = "yes"
}
defaultVal := p.Default
if defaultVal == "" {
defaultVal = "-"
}
desc := p.Description
if desc == "" {
desc = "-"
}
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
}
w.Flush()
// Inline Values below the table so AI consumers see allowed enum/multi values without --json.
for _, p := range def.Params {
if len(p.Values) == 0 {
continue
}
fmt.Fprintf(out, "\n %s values:\n", p.Name)
vw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
for _, v := range p.Values {
fmt.Fprintf(vw, " %s\t%s\n", v.Value, v.Desc)
}
vw.Flush()
}
}
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
}
if resolved != nil {
fmt.Fprintf(out, "\nOutput Schema:\n")
printIndentedJSON(out, resolved)
} else {
fmt.Fprintf(out, "\nOutput Schema: (schema not declared)\n")
if def.Schema.Native != nil {
fmt.Fprintf(out, " Consumers receive the V2 envelope: {schema, header, event}.\n")
fmt.Fprintf(out, " Inspect real payloads via `lark-cli event consume %s`.\n", def.Key)
}
}
return nil
}
// printIndentedJSON pretty-prints raw JSON with a 2-space leading indent.
func printIndentedJSON(out io.Writer, raw json.RawMessage) {
var parsed json.RawMessage
if err := json.Unmarshal(raw, &parsed); err != nil {
fmt.Fprintln(out, " <invalid JSON>")
return
}
formatted, err := json.MarshalIndent(parsed, " ", " ")
if err != nil {
return
}
fmt.Fprintf(out, " %s\n", string(formatted))
}
// writeSchemaJSON emits the EventKey definition plus resolved schema; jq_root_path tells callers whether fields live at `.` or `.event`.
func writeSchemaJSON(f *cmdutil.Factory, def *eventlib.KeyDefinition) error {
type payload struct {
*eventlib.KeyDefinition
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
JQRootPath string `json:"jq_root_path,omitempty"`
}
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return err
}
var jqRootPath string
if resolved != nil {
// Native → V2 envelope ⇒ `.event.xxx`; Custom → flat ⇒ `.`.
_, isNative := pickSpec(def.Schema)
jqRootPath = "."
if isNative {
jqRootPath = ".event"
}
}
output.PrintJson(f.IOStreams.Out, payload{
KeyDefinition: def,
ResolvedSchema: resolved,
JQRootPath: jqRootPath,
})
return nil
}

131
cmd/event/schema_test.go Normal file
View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
_ "github.com/larksuite/cli/events"
)
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.receive_v1", false); err != nil {
t.Fatalf("runSchema: %v", err)
}
out := stdout.String()
for _, want := range []string{
"Key:", "im.message.receive_v1",
"Event:", "im.message.receive_v1",
"Output Schema:",
`"message_id"`,
} {
if !strings.Contains(out, want) {
t.Errorf("schema output missing %q; got:\n%s", want, out)
}
}
}
func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.message_read_v1", false); err != nil {
t.Fatalf("runSchema: %v", err)
}
out := stdout.String()
for _, want := range []string{
"Output Schema:",
`"schema"`,
`"header"`,
`"event"`,
} {
if !strings.Contains(out, want) {
t.Errorf("native schema output missing %q; got:\n%s", want, out)
}
}
}
func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
err := runSchema(f, "im.message.recieve_v1", false)
if err == nil {
t.Fatal("expected error for unknown key")
}
msg := err.Error()
if !strings.Contains(msg, "unknown EventKey") {
t.Errorf("error should mention unknown EventKey: %q", msg)
}
if !strings.Contains(msg, "im.message.receive_v1") {
t.Errorf("error should suggest the real key name (typo correction): %q", msg)
}
}
func TestRunSchema_JSONOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
for _, field := range []string{"key", "event_type", "schema", "resolved_output_schema"} {
if _, ok := payload[field]; !ok {
t.Errorf("JSON output missing field %q: %+v", field, payload)
}
}
if payload["key"] != "im.message.receive_v1" {
t.Errorf("key = %v, want im.message.receive_v1", payload["key"])
}
}
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
const syntheticKey = "t.custom.overlay"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
type out struct {
SenderID string `json:"sender_id"`
}
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Schema: eventlib.SchemaDef{
Custom: &eventlib.SchemaSpec{Type: reflect.TypeOf(out{})},
FieldOverrides: map[string]schemas.FieldMeta{
"/sender_id": {Kind: "open_id"},
},
},
Process: func(context.Context, eventlib.APIClient, *eventlib.RawEvent, map[string]string) (json.RawMessage, error) {
return nil, nil
},
})
def, _ := eventlib.Lookup(syntheticKey)
resolved, orphans, err := resolveSchemaJSON(def)
if err != nil || len(orphans) != 0 {
t.Fatalf("resolve: err=%v orphans=%v", err, orphans)
}
var parsed map[string]interface{}
if err := json.Unmarshal(resolved, &parsed); err != nil {
t.Fatal(err)
}
got := parsed["properties"].(map[string]interface{})["sender_id"].(map[string]interface{})["format"]
if got != "open_id" {
t.Errorf("overlay format = %v, want open_id", got)
}
}

17
cmd/event/sigpipe_unix.go Normal file
View File

@@ -0,0 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build unix
package event
import (
"os/signal"
"syscall"
)
// ignoreBrokenPipe stops Go's default SIGPIPE-on-stdout terminate behavior.
// Subsequent stdout writes return syscall.EPIPE so consume can shut down cleanly.
func ignoreBrokenPipe() {
signal.Ignore(syscall.SIGPIPE)
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package event
// ignoreBrokenPipe is a no-op on Windows (no SIGPIPE; closed-pipe writes return ERROR_BROKEN_PIPE directly).
func ignoreBrokenPipe() {}

328
cmd/event/status.go Normal file
View File

@@ -0,0 +1,328 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"fmt"
"io"
"sort"
"sync"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/event/busctl"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/output"
)
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
var (
asJSON bool
current bool
failOnOrphan bool
)
cmd := &cobra.Command{
Use: "status",
Short: "Show event bus daemon status for all discovered apps",
Long: "Connect to each bus daemon under the config-dir/events/ tree and show PID, uptime, and active consumers. Use --current for only the current profile's app. Use --json for machine-readable output. Use --fail-on-orphan to exit 2 when any orphan bus is detected (for health checks).",
RunE: func(cmd *cobra.Command, args []string) error {
return runStatus(f, current, asJSON, failOnOrphan)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit status as JSON (for AI / scripts)")
cmd.Flags().BoolVar(&current, "current", false, "Only show status for the current profile's app")
cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "Exit 2 when any orphan bus is detected (default: always exit 0)")
return cmd
}
type busState int
const (
stateNotRunning busState = iota
stateRunning
stateOrphan
)
func (s busState) String() string {
switch s {
case stateRunning:
return "running"
case stateOrphan:
return "orphan"
default:
return "not_running"
}
}
// appStatus bundles one AppID's derived status; State picks which fields are meaningful.
type appStatus struct {
AppID string
State busState
PID int
UptimeSec int
Active int
Consumers []protocol.ConsumerInfo
}
type busQuerier interface {
QueryBusStatus(appID string) (*protocol.StatusResponse, error)
}
// singleAppScanner wraps a Scanner and filters to one AppID for --current queries.
type singleAppScanner struct {
appID string
inner busdiscover.Scanner
}
func (s singleAppScanner) ScanBusProcesses() ([]busdiscover.Process, error) {
if s.inner == nil {
return nil, nil
}
all, err := s.inner.ScanBusProcesses()
if err != nil {
return nil, err
}
out := all[:0]
for _, p := range all {
if p.AppID == s.appID {
out = append(out, p)
}
}
return out, nil
}
type transportQuerier struct {
tr transport.IPC
}
func (q *transportQuerier) QueryBusStatus(appID string) (*protocol.StatusResponse, error) {
return busctl.QueryStatus(q.tr, appID)
}
func runStatus(f *cmdutil.Factory, current, asJSON, failOnOrphan bool) error {
cfg, err := f.Config()
if err != nil {
return err
}
seeds := map[string]struct{}{}
if current {
seeds[cfg.AppID] = struct{}{}
} else {
for _, id := range discoverAppIDs() {
seeds[id] = struct{}{}
}
// Always include the current profile so a first-time user sees it as not_running.
seeds[cfg.AppID] = struct{}{}
}
seedList := make([]string, 0, len(seeds))
for id := range seeds {
seedList = append(seedList, id)
}
tr := transport.New()
// --current: scope the scanner to this AppID so unrelated orphans don't surface.
var scanner busdiscover.Scanner
if current {
scanner = singleAppScanner{appID: cfg.AppID, inner: busdiscover.Default()}
} else {
scanner = busdiscover.Default()
}
statuses := deriveStatuses(
seedList,
scanner,
&transportQuerier{tr: tr},
time.Now(),
)
if asJSON {
if err := writeStatusJSON(f.IOStreams.Out, statuses); err != nil {
return err
}
} else {
writeStatusText(f.IOStreams.Out, statuses)
}
return exitForOrphan(statuses, failOnOrphan)
}
// deriveStatuses classifies each AppID as running/orphan/not_running from socket + process-scan inputs; scanner errors are non-fatal.
func deriveStatuses(seedAppIDs []string, sc busdiscover.Scanner, q busQuerier, now time.Time) []appStatus {
procByAppID := map[string]busdiscover.Process{}
if sc != nil {
if procs, err := sc.ScanBusProcesses(); err == nil {
for _, p := range procs {
procByAppID[p.AppID] = p
}
}
}
ids := map[string]struct{}{}
for _, id := range seedAppIDs {
ids[id] = struct{}{}
}
for id := range procByAppID {
ids[id] = struct{}{}
}
sorted := make([]string, 0, len(ids))
for id := range ids {
sorted = append(sorted, id)
}
sort.Strings(sorted)
// Query in parallel so one wedged peer can't compound the per-op deadline across many apps.
type probe struct {
resp *protocol.StatusResponse
err error
}
probes := make([]probe, len(sorted))
var wg sync.WaitGroup
for i, appID := range sorted {
wg.Add(1)
go func(i int, appID string) {
defer wg.Done()
probes[i].resp, probes[i].err = q.QueryBusStatus(appID)
}(i, appID)
}
wg.Wait()
result := make([]appStatus, 0, len(sorted))
for i, appID := range sorted {
s := appStatus{AppID: appID, State: stateNotRunning}
if probes[i].err == nil {
resp := probes[i].resp
s.State = stateRunning
s.PID = resp.PID
s.UptimeSec = resp.UptimeSec
s.Active = resp.ActiveConns
s.Consumers = resp.Consumers
} else if p, ok := procByAppID[appID]; ok {
s.State = stateOrphan
s.PID = p.PID
s.UptimeSec = int(now.Sub(p.StartTime).Seconds())
}
result = append(result, s)
}
return result
}
// humanizeDuration formats d as a coarse "N unit ago" string.
func humanizeDuration(d time.Duration) string {
s := int(d.Seconds())
if s < 60 {
return fmt.Sprintf("%ds ago", s)
}
m := s / 60
if m < 60 {
return fmt.Sprintf("%dm ago", m)
}
h := m / 60
if h < 24 {
return fmt.Sprintf("%dh ago", h)
}
return fmt.Sprintf("%dd ago", h/24)
}
func writeStatusText(out io.Writer, statuses []appStatus) {
for i, s := range statuses {
if i > 0 {
fmt.Fprintln(out)
}
fmt.Fprintf(out, "── %s ──\n", s.AppID)
switch s.State {
case stateNotRunning:
fmt.Fprintln(out, " Bus: not running")
case stateRunning:
fmt.Fprintf(out, " Bus: running (PID %d, uptime %s)\n",
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
if len(s.Consumers) > 0 {
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
rows := make([][]string, 0, len(s.Consumers))
for _, c := range s.Consumers {
rows = append(rows, []string{
fmt.Sprintf("pid=%d", c.PID),
c.EventKey,
fmt.Sprintf("%d", c.Received),
fmt.Sprintf("%d", c.Dropped),
})
}
widths := tableWidths(headers, rows)
const colGap = " "
fmt.Fprintln(out)
fmt.Fprint(out, " ")
printTableRow(out, widths, headers, colGap)
for _, row := range rows {
fmt.Fprint(out, " ")
printTableRow(out, widths, row, colGap)
}
}
case stateOrphan:
if s.PID == 0 {
fmt.Fprintln(out, " Bus: orphan (PID unknown — bus.pid file unreadable)")
fmt.Fprintln(out, " Issue: live bus detected but pid file is missing or corrupt")
fmt.Fprintln(out, " Action: inspect ~/.lark-cli/events/<app>/bus.pid and kill manually")
break
}
fmt.Fprintf(out, " Bus: orphan (PID %d, started %s)\n",
s.PID, humanizeDuration(time.Duration(s.UptimeSec)*time.Second))
fmt.Fprintln(out, " Issue: socket file missing — consumers cannot connect")
fmt.Fprintf(out, " Action: kill %d\n", s.PID)
}
}
}
func writeStatusJSON(w io.Writer, statuses []appStatus) error {
type jsonStatus struct {
AppID string `json:"app_id"`
Status string `json:"status"`
Running bool `json:"running"` // backward compat
PID int `json:"pid,omitempty"`
UptimeSec int `json:"uptime_sec,omitempty"`
Active int `json:"active_consumers,omitempty"`
Consumers []protocol.ConsumerInfo `json:"consumers,omitempty"`
Issue string `json:"issue,omitempty"`
SuggestedAction string `json:"suggested_action,omitempty"`
}
payload := make([]jsonStatus, 0, len(statuses))
for _, s := range statuses {
js := jsonStatus{
AppID: s.AppID,
Status: s.State.String(),
Running: s.State == stateRunning,
PID: s.PID,
UptimeSec: s.UptimeSec,
Active: s.Active,
Consumers: s.Consumers,
}
if s.State == stateOrphan {
if s.PID == 0 {
js.Issue = "live bus detected but pid file is missing or corrupt"
js.SuggestedAction = "inspect events dir and kill manually"
} else {
js.Issue = "socket file missing"
js.SuggestedAction = fmt.Sprintf("kill %d", s.PID)
}
}
payload = append(payload, js)
}
output.PrintJson(w, map[string]interface{}{"apps": payload})
return nil
}
// exitForOrphan returns ExitValidation iff failOnOrphan and any status is orphan; default exit 0 preserves observe-only semantics.
func exitForOrphan(statuses []appStatus, failOnOrphan bool) error {
if !failOnOrphan {
return nil
}
for _, s := range statuses {
if s.State == stateOrphan {
return output.ErrBare(output.ExitValidation)
}
}
return nil
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/output"
)
func TestExitForOrphan_Orphan(t *testing.T) {
statuses := []appStatus{
{AppID: "cli_a", State: stateRunning},
{AppID: "cli_b", State: stateOrphan, PID: 70926},
}
err := exitForOrphan(statuses, true)
if err == nil {
t.Fatal("expected error when failOnOrphan=true and orphan present")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
}
}
func TestExitForOrphan_NoOrphan(t *testing.T) {
statuses := []appStatus{
{AppID: "cli_a", State: stateRunning},
{AppID: "cli_b", State: stateNotRunning},
}
if err := exitForOrphan(statuses, true); err != nil {
t.Errorf("expected nil error when no orphan; got %v", err)
}
}
func TestExitForOrphan_FlagDisabled(t *testing.T) {
statuses := []appStatus{
{AppID: "cli_b", State: stateOrphan, PID: 70926},
}
if err := exitForOrphan(statuses, false); err != nil {
t.Errorf("flag off should never return error; got %v", err)
}
}

View File

@@ -0,0 +1,242 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/protocol"
)
type fakeScanner struct {
procs []busdiscover.Process
err error
}
func (f *fakeScanner) ScanBusProcesses() ([]busdiscover.Process, error) {
return f.procs, f.err
}
type fakeBusQuerier struct {
respByAppID map[string]*protocol.StatusResponse
}
func (f *fakeBusQuerier) QueryBusStatus(appID string) (*protocol.StatusResponse, error) {
if r, ok := f.respByAppID[appID]; ok {
return r, nil
}
return nil, errors.New("dial failed")
}
func TestDeriveStatuses_RunningBus(t *testing.T) {
q := &fakeBusQuerier{
respByAppID: map[string]*protocol.StatusResponse{
"cli_a": protocol.NewStatusResponse(12345, 150, 1, nil),
},
}
sc := &fakeScanner{procs: nil}
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
if len(statuses) != 1 {
t.Fatalf("expected 1 status, got %d", len(statuses))
}
s := statuses[0]
if s.State != stateRunning {
t.Errorf("State = %v, want stateRunning", s.State)
}
if s.PID != 12345 {
t.Errorf("PID = %d, want 12345", s.PID)
}
if s.UptimeSec != 150 {
t.Errorf("UptimeSec = %d, want 150", s.UptimeSec)
}
}
func TestDeriveStatuses_OrphanBus(t *testing.T) {
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
sc := &fakeScanner{procs: []busdiscover.Process{
{PID: 70926, AppID: "cli_a", StartTime: time.Now().Add(-19 * time.Hour)},
}}
now := time.Now()
statuses := deriveStatuses([]string{"cli_a"}, sc, q, now)
if len(statuses) != 1 {
t.Fatalf("expected 1 status, got %d", len(statuses))
}
s := statuses[0]
if s.State != stateOrphan {
t.Errorf("State = %v, want stateOrphan", s.State)
}
if s.PID != 70926 {
t.Errorf("PID = %d, want 70926", s.PID)
}
wantUptime := int((19 * time.Hour).Seconds())
if s.UptimeSec < wantUptime-60 || s.UptimeSec > wantUptime+60 {
t.Errorf("UptimeSec = %d, want ~%d", s.UptimeSec, wantUptime)
}
}
func TestDeriveStatuses_NotRunning(t *testing.T) {
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
sc := &fakeScanner{procs: nil}
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
if len(statuses) != 1 {
t.Fatalf("expected 1 status, got %d", len(statuses))
}
s := statuses[0]
if s.State != stateNotRunning {
t.Errorf("State = %v, want stateNotRunning", s.State)
}
}
func TestDeriveStatuses_DiscoversOrphanAppIDsFromProcessScan(t *testing.T) {
q := &fakeBusQuerier{respByAppID: map[string]*protocol.StatusResponse{}}
sc := &fakeScanner{procs: []busdiscover.Process{
{PID: 70926, AppID: "cli_orphan", StartTime: time.Now().Add(-1 * time.Hour)},
}}
statuses := deriveStatuses([]string{"cli_known"}, sc, q, time.Now())
if len(statuses) != 2 {
t.Fatalf("expected 2 statuses, got %d: %+v", len(statuses), statuses)
}
byID := map[string]appStatus{}
for _, s := range statuses {
byID[s.AppID] = s
}
if byID["cli_known"].State != stateNotRunning {
t.Errorf("cli_known state = %v, want stateNotRunning", byID["cli_known"].State)
}
if byID["cli_orphan"].State != stateOrphan {
t.Errorf("cli_orphan state = %v, want stateOrphan", byID["cli_orphan"].State)
}
}
func TestDeriveStatuses_ScannerErrorIsNotFatal(t *testing.T) {
q := &fakeBusQuerier{
respByAppID: map[string]*protocol.StatusResponse{
"cli_a": protocol.NewStatusResponse(12345, 150, 1, nil),
},
}
sc := &fakeScanner{err: errors.New("ps failed")}
statuses := deriveStatuses([]string{"cli_a"}, sc, q, time.Now())
if len(statuses) != 1 {
t.Fatalf("expected 1 status, got %d", len(statuses))
}
if statuses[0].State != stateRunning {
t.Errorf("State = %v, want stateRunning (scanner error must not break running detection)", statuses[0].State)
}
}
func TestWriteStatusText_OrphanBlock(t *testing.T) {
var buf bytes.Buffer
statuses := []appStatus{{
AppID: "cli_XXXXXXXXXXXXXXXX",
State: stateOrphan,
PID: 70926,
UptimeSec: 68400,
}}
writeStatusText(&buf, statuses)
out := buf.String()
for _, want := range []string{
"── cli_XXXXXXXXXXXXXXXX ──",
"Bus: orphan (PID 70926, started 19h ago)",
"Issue: socket file missing — consumers cannot connect",
"Action: kill 70926",
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q\nfull output:\n%s", want, out)
}
}
if strings.Contains(out, "running (PID") {
t.Errorf("orphan block must not contain 'running' text; got:\n%s", out)
}
}
func TestWriteStatusJSON_OrphanFields(t *testing.T) {
var buf bytes.Buffer
statuses := []appStatus{{
AppID: "cli_XXXXXXXXXXXXXXXX",
State: stateOrphan,
PID: 70926,
UptimeSec: 68400,
}}
if err := writeStatusJSON(&buf, statuses); err != nil {
t.Fatalf("writeStatusJSON: %v", err)
}
var payload struct {
Apps []map[string]interface{} `json:"apps"`
}
if err := json.Unmarshal(buf.Bytes(), &payload); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(payload.Apps) != 1 {
t.Fatalf("apps len = %d, want 1", len(payload.Apps))
}
a := payload.Apps[0]
if a["status"] != "orphan" {
t.Errorf("status = %v, want \"orphan\"", a["status"])
}
if a["running"] != false {
t.Errorf("running = %v, want false", a["running"])
}
if a["issue"] != "socket file missing" {
t.Errorf("issue = %v, want \"socket file missing\"", a["issue"])
}
if a["suggested_action"] != "kill 70926" {
t.Errorf("suggested_action = %v, want \"kill 70926\"", a["suggested_action"])
}
if pid, ok := a["pid"].(float64); !ok || int(pid) != 70926 {
t.Errorf("pid = %v, want 70926", a["pid"])
}
}
func TestWriteStatusJSON_RunningOmitsOrphanFields(t *testing.T) {
var buf bytes.Buffer
statuses := []appStatus{{
AppID: "cli_running",
State: stateRunning,
PID: 11111,
UptimeSec: 60,
Active: 0,
}}
if err := writeStatusJSON(&buf, statuses); err != nil {
t.Fatalf("writeStatusJSON: %v", err)
}
out := buf.String()
if strings.Contains(out, `"issue"`) {
t.Errorf("running status must not include 'issue' field; got:\n%s", out)
}
if strings.Contains(out, `"suggested_action"`) {
t.Errorf("running status must not include 'suggested_action' field; got:\n%s", out)
}
}
func TestHumanizeDuration(t *testing.T) {
for _, tt := range []struct {
d time.Duration
want string
}{
{30 * time.Second, "30s ago"},
{90 * time.Second, "1m ago"},
{45 * time.Minute, "45m ago"},
{90 * time.Minute, "1h ago"},
{5 * time.Hour, "5h ago"},
{30 * time.Hour, "1d ago"},
{80 * time.Hour, "3d ago"},
} {
got := humanizeDuration(tt.d)
if got != tt.want {
t.Errorf("humanizeDuration(%v) = %q, want %q", tt.d, got, tt.want)
}
}
}

241
cmd/event/stop.go Normal file
View File

@@ -0,0 +1,241 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"fmt"
"io"
"os"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/event/busctl"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/output"
)
// stopStatus is the outcome tag; JSON wire format is the string form — keep values stable.
type stopStatus string
const (
stopStopped stopStatus = "stopped"
stopNoBus stopStatus = "no_bus"
stopRefused stopStatus = "refused"
stopErrored stopStatus = "error"
)
type stopResult struct {
AppID string `json:"app_id"`
Status stopStatus `json:"status"`
PID int `json:"pid,omitempty"`
Reason string `json:"reason,omitempty"`
}
type stopCmdOpts struct {
appID string
all bool
force bool
asJSON bool
}
func NewCmdStop(f *cmdutil.Factory) *cobra.Command {
var o stopCmdOpts
cmd := &cobra.Command{
Use: "stop",
Short: "Stop the event bus daemon",
Long: `Stop the event bus daemon. Target is one of:
• the current profile's AppID (default)
• an explicit AppID via --app-id
• every running bus on this machine via --all
Exit code: 2 if any target was refused or errored, 0 otherwise.
--force widens two gates:
1. Allows stopping a bus that still has active consumers.
2. On shutdown-timeout (bus didn't exit within 5s), SIGKILLs the
process and cleans up the stale socket instead of returning an
error.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runStop(f, o)
},
}
cmd.Flags().StringVar(&o.appID, "app-id", "", "App ID of the bus to stop (default: current profile)")
cmd.Flags().BoolVar(&o.all, "all", false, "Stop all running bus daemons")
cmd.Flags().BoolVar(&o.force, "force", false, "Stop even with active consumers; on shutdown-timeout also SIGKILL the bus")
cmd.Flags().BoolVar(&o.asJSON, "json", false, "Emit results as JSON (for AI / scripts)")
return cmd
}
func runStop(f *cmdutil.Factory, o stopCmdOpts) error {
tr := transport.New()
var targets []string
if o.all {
targets = discoverAppIDs()
} else {
targetAppID := o.appID
if targetAppID == "" {
cfg, err := f.Config()
if err != nil {
return err
}
targetAppID = cfg.AppID
}
targets = []string{targetAppID}
}
if len(targets) == 0 {
if o.asJSON {
return writeStopJSON(f.IOStreams.Out, nil)
}
fmt.Fprintln(f.IOStreams.Out, "No event bus instances found.")
return nil
}
results := make([]stopResult, 0, len(targets))
for _, id := range targets {
results = append(results, stopBusOne(tr, id, o.force))
}
if o.asJSON {
return writeStopJSON(f.IOStreams.Out, results)
}
writeStopText(f.IOStreams.Out, f.IOStreams.ErrOut, results)
// Non-zero exit for refused/errored so non-JSON callers still get a signal.
for _, r := range results {
if r.Status == stopRefused || r.Status == stopErrored {
return output.ErrBare(output.ExitValidation)
}
}
return nil
}
// stopBusOne attempts to stop appID's bus; polls tr.Dial post-Shutdown until listener is gone or budget elapses.
func stopBusOne(tr transport.IPC, appID string, force bool) stopResult {
resp, err := busctl.QueryStatus(tr, appID)
if err != nil {
return stopResult{AppID: appID, Status: stopNoBus}
}
if resp.ActiveConns > 0 && !force {
pids := make([]int, len(resp.Consumers))
for i, c := range resp.Consumers {
pids[i] = c.PID
}
return stopResult{
AppID: appID,
Status: stopRefused,
PID: resp.PID,
Reason: fmt.Sprintf("%d active consumer(s) (pids: %v); use --force to override", resp.ActiveConns, pids),
}
}
if err := busctl.SendShutdown(tr, appID); err != nil {
return stopResult{AppID: appID, Status: stopErrored, PID: resp.PID, Reason: err.Error()}
}
const pollInterval = 100 * time.Millisecond
deadline := time.Now().Add(shutdownBudget)
for time.Now().Before(deadline) {
time.Sleep(pollInterval)
probe, dialErr := tr.Dial(tr.Address(appID))
if dialErr != nil {
return stopResult{AppID: appID, Status: stopStopped, PID: resp.PID}
}
probe.Close()
}
if !force {
return stopResult{
AppID: appID,
Status: stopErrored,
PID: resp.PID,
Reason: fmt.Sprintf("Bus did not exit within %v (pid=%d still listening); use --force to kill", shutdownBudget, resp.PID),
}
}
// --force: SIGKILL and clean up the stale socket.
if err := killProcess(resp.PID); err != nil {
if errors.Is(err, os.ErrProcessDone) {
// Bus exited between timeout and kill — treat as success.
tr.Cleanup(tr.Address(appID))
return stopResult{
AppID: appID,
Status: stopStopped,
PID: resp.PID,
Reason: "bus exited during kill attempt",
}
}
return stopResult{
AppID: appID,
Status: stopErrored,
PID: resp.PID,
Reason: fmt.Sprintf("failed to kill bus process: %v", err),
}
}
tr.Cleanup(tr.Address(appID))
return stopResult{
AppID: appID,
Status: stopStopped,
PID: resp.PID,
Reason: "killed (ungraceful) after shutdown timeout",
}
}
// killProcess is a var so tests can swap it without spawning sub-processes.
var killProcess = func(pid int) error {
p, err := os.FindProcess(pid)
if err != nil {
return err
}
return p.Kill()
}
// shutdownBudget (var so tests can shrink it) bounds the post-Shutdown exit wait.
var shutdownBudget = 5 * time.Second
func writeStopJSON(w io.Writer, results []stopResult) error {
if results == nil {
results = []stopResult{}
}
output.PrintJson(w, map[string]interface{}{"results": results})
return nil
}
func writeStopText(out, errOut io.Writer, results []stopResult) {
for _, r := range results {
switch r.Status {
case stopStopped:
fmt.Fprintf(out, "Bus stopped for %s (pid=%d)\n", r.AppID, r.PID)
case stopNoBus:
fmt.Fprintf(out, "No bus running for %s\n", r.AppID)
case stopRefused:
fmt.Fprintf(errOut, "Refused stopping %s: %s\n", r.AppID, r.Reason)
case stopErrored:
fmt.Fprintf(errOut, "Error stopping %s: %s\n", r.AppID, r.Reason)
}
}
}
// discoverAppIDs returns appIDs whose bus.alive.lock is held by a live process.
// Cross-platform via lockfile (flock on Unix, LockFileEx on Windows); ignores stale socket files.
func discoverAppIDs() []string {
procs, err := busdiscover.Default().ScanBusProcesses()
if err != nil {
return nil
}
ids := make([]string, 0, len(procs))
for _, p := range procs {
ids = append(ids, p.AppID)
}
return ids
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"os"
"path/filepath"
"sort"
"testing"
"github.com/larksuite/cli/internal/event/busdiscover"
)
func TestDiscoverAppIDs_OnlyLiveLockHolders(t *testing.T) {
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
eventsDir := filepath.Join(tmp, "events")
// Two live buses (lock held until t.Cleanup releases it).
for _, app := range []string{"cli_XXXXXXXXXXXXXXXX", "cli_YYYYYYYYYYYYYYYY"} {
appDir := filepath.Join(eventsDir, app)
h, err := busdiscover.WritePIDFile(appDir, 1234)
if err != nil {
t.Fatalf("WritePIDFile %s: %v", app, err)
}
t.Cleanup(func() { _ = h.Release() })
}
// Dead bus: lock acquired then released → looks like a stale dir on disk.
deadDir := filepath.Join(eventsDir, "cli_ZZZZZZZZZZZZZZZZ")
hDead, err := busdiscover.WritePIDFile(deadDir, 9999)
if err != nil {
t.Fatalf("WritePIDFile dead: %v", err)
}
if err := hDead.Release(); err != nil {
t.Fatalf("Release dead: %v", err)
}
// Stale bus.sock without alive.lock — old behavior would surface it; new must not.
staleSockDir := filepath.Join(eventsDir, "cli_SSSSSSSSSSSSSSSS")
if err := os.MkdirAll(staleSockDir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staleSockDir, "bus.sock"), nil, 0600); err != nil {
t.Fatal(err)
}
// Stray non-dir file under events/.
if err := os.WriteFile(filepath.Join(eventsDir, "stray.txt"), nil, 0600); err != nil {
t.Fatal(err)
}
got := discoverAppIDs()
sort.Strings(got)
want := []string{"cli_XXXXXXXXXXXXXXXX", "cli_YYYYYYYYYYYYYYYY"}
if len(got) != len(want) {
t.Fatalf("discoverAppIDs() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("discoverAppIDs()[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestDiscoverAppIDs_MissingEventsDir(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if got := discoverAppIDs(); len(got) != 0 {
t.Errorf("discoverAppIDs() on missing events/ = %v, want empty", got)
}
}

View File

@@ -0,0 +1,340 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"bufio"
"net"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/internal/event/protocol"
)
type mockTransport struct {
mu sync.Mutex
addr string
cleaned bool
}
func (t *mockTransport) Listen(addr string) (net.Listener, error) {
return net.Listen("tcp", addr)
}
func (t *mockTransport) Dial(addr string) (net.Conn, error) {
return net.DialTimeout("tcp", addr, 500*time.Millisecond)
}
func (t *mockTransport) Address(appID string) string {
t.mu.Lock()
defer t.mu.Unlock()
return t.addr
}
func (t *mockTransport) Cleanup(addr string) {
t.mu.Lock()
t.cleaned = true
t.mu.Unlock()
}
func (t *mockTransport) didCleanup() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.cleaned
}
type fakeBus struct {
listener net.Listener
pid int
exitDelay time.Duration
unresponsive bool
shutdownCount int32
wg sync.WaitGroup
stopOnce sync.Once
done chan struct{}
}
func newFakeBus(t *testing.T, pid int, exitDelay time.Duration, unresponsive bool) *fakeBus {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to listen: %v", err)
}
b := &fakeBus{
listener: ln,
pid: pid,
exitDelay: exitDelay,
unresponsive: unresponsive,
done: make(chan struct{}),
}
b.wg.Add(1)
go b.serve()
return b
}
func (b *fakeBus) addr() string { return b.listener.Addr().String() }
func (b *fakeBus) serve() {
defer b.wg.Done()
for {
conn, err := b.listener.Accept()
if err != nil {
return
}
b.wg.Add(1)
go b.handle(conn)
}
}
func (b *fakeBus) handle(conn net.Conn) {
defer b.wg.Done()
defer conn.Close()
r := bufio.NewReader(conn)
line, err := r.ReadBytes('\n')
if err != nil {
return
}
msg, err := protocol.Decode(line)
if err != nil {
return
}
switch msg.(type) {
case *protocol.StatusQuery:
_ = protocol.Encode(conn, &protocol.StatusResponse{
Type: protocol.MsgTypeStatusResponse,
PID: b.pid,
UptimeSec: 1,
ActiveConns: 0,
Consumers: nil,
})
case *protocol.Shutdown:
atomic.AddInt32(&b.shutdownCount, 1)
if b.unresponsive {
return
}
if b.exitDelay > 0 {
go func() {
time.Sleep(b.exitDelay)
b.stop()
}()
} else {
go b.stop()
}
}
}
func (b *fakeBus) stop() {
b.stopOnce.Do(func() {
_ = b.listener.Close()
close(b.done)
})
}
func (b *fakeBus) wait(t *testing.T, budget time.Duration) {
t.Helper()
done := make(chan struct{})
go func() {
b.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(budget):
t.Fatalf("fakeBus did not shut down within %v", budget)
}
}
func TestStopReturnsStoppedOnlyAfterBusExits(t *testing.T) {
const pid = 44441
const exitDelay = 500 * time.Millisecond
bus := newFakeBus(t, pid, exitDelay, false)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
start := time.Now()
res := stopBusOne(tr, "test-app", false)
elapsed := time.Since(start)
if res.Status != "stopped" {
t.Fatalf("status = %q (reason=%q); want stopped", res.Status, res.Reason)
}
if res.PID != pid {
t.Fatalf("pid = %d; want %d", res.PID, pid)
}
if elapsed < 400*time.Millisecond {
t.Fatalf("stopBusOne returned in %v; expected >= %v (waited for bus to exit)", elapsed, exitDelay)
}
if elapsed > 3*time.Second {
t.Fatalf("stopBusOne took %v; expected well under 3s", elapsed)
}
bus.wait(t, 2*time.Second)
if got := atomic.LoadInt32(&bus.shutdownCount); got != 1 {
t.Errorf("fakeBus received %d Shutdown messages; want 1", got)
}
}
func TestStopTimesOutOnUnresponsiveBusWithoutForce(t *testing.T) {
const pid = 44442
origKill := killProcess
t.Cleanup(func() { killProcess = origKill })
var killCalls []int
var killMu sync.Mutex
killProcess = func(p int) error {
killMu.Lock()
killCalls = append(killCalls, p)
killMu.Unlock()
return nil
}
bus := newFakeBus(t, pid, 0, true)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
origBudget := shutdownBudget
t.Cleanup(func() { shutdownBudget = origBudget })
shutdownBudget = 500 * time.Millisecond
start := time.Now()
res := stopBusOne(tr, "test-app", false)
elapsed := time.Since(start)
if res.Status != "error" {
t.Fatalf("status = %q (reason=%q); want error", res.Status, res.Reason)
}
if res.PID != pid {
t.Errorf("pid = %d; want %d", res.PID, pid)
}
if elapsed < shutdownBudget || elapsed > shutdownBudget+2*time.Second {
t.Fatalf("elapsed = %v; want >= %v and < %v", elapsed, shutdownBudget, shutdownBudget+2*time.Second)
}
if !strings.Contains(res.Reason, "did not exit within") {
t.Errorf("reason %q should mention 'did not exit within'", res.Reason)
}
killMu.Lock()
defer killMu.Unlock()
if len(killCalls) != 0 {
t.Errorf("killProcess called %v; want 0 calls without --force", killCalls)
}
if tr.didCleanup() {
t.Errorf("Cleanup should not be called when --force is false")
}
}
func TestStopForceKillsUnresponsiveBus(t *testing.T) {
const pid = 44443
origKill := killProcess
t.Cleanup(func() { killProcess = origKill })
var killCalls []int
var killMu sync.Mutex
killProcess = func(p int) error {
killMu.Lock()
killCalls = append(killCalls, p)
killMu.Unlock()
return nil
}
bus := newFakeBus(t, pid, 0, true)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
origBudget := shutdownBudget
t.Cleanup(func() { shutdownBudget = origBudget })
shutdownBudget = 500 * time.Millisecond
start := time.Now()
res := stopBusOne(tr, "test-app", true)
elapsed := time.Since(start)
if res.Status != "stopped" {
t.Fatalf("status = %q (reason=%q); want stopped", res.Status, res.Reason)
}
if res.PID != pid {
t.Errorf("pid = %d; want %d", res.PID, pid)
}
if elapsed < shutdownBudget || elapsed > shutdownBudget+2*time.Second {
t.Fatalf("elapsed = %v; want >= %v and < %v", elapsed, shutdownBudget, shutdownBudget+2*time.Second)
}
if !strings.Contains(res.Reason, "killed") {
t.Errorf("reason %q should mention 'killed'", res.Reason)
}
killMu.Lock()
defer killMu.Unlock()
if len(killCalls) != 1 || killCalls[0] != pid {
t.Errorf("killProcess calls = %v; want [%d]", killCalls, pid)
}
if !tr.didCleanup() {
t.Errorf("Cleanup was not invoked after force-kill")
}
}
func TestStopReturnsStoppedFastWhenBusExitsImmediately(t *testing.T) {
const pid = 12345
bus := newFakeBus(t, pid, 0, false)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
start := time.Now()
res := stopBusOne(tr, "test-app", false)
elapsed := time.Since(start)
if res.Status != "stopped" {
t.Fatalf("expected stopped, got %q (reason: %s)", res.Status, res.Reason)
}
if res.PID != pid {
t.Errorf("expected PID=%d, got %d", pid, res.PID)
}
if elapsed > 500*time.Millisecond {
t.Errorf("expected fast return (<500ms), got %v — possibly waiting the full budget", elapsed)
}
}
func TestStopForceHandlesProcessAlreadyDeadRace(t *testing.T) {
const pid = 99999
origKill := killProcess
t.Cleanup(func() { killProcess = origKill })
var killCalls []int
var killMu sync.Mutex
killProcess = func(p int) error {
killMu.Lock()
killCalls = append(killCalls, p)
killMu.Unlock()
return os.ErrProcessDone
}
bus := newFakeBus(t, pid, 0, true)
defer bus.stop()
tr := &mockTransport{addr: bus.addr()}
res := stopBusOne(tr, "test-app", true)
if res.Status != "stopped" {
t.Errorf("expected stopped (race treated as success), got %q (reason: %s)", res.Status, res.Reason)
}
killMu.Lock()
if len(killCalls) != 1 || killCalls[0] != pid {
t.Errorf("expected killProcess called once with pid=%d, got %v", pid, killCalls)
}
killMu.Unlock()
if !tr.didCleanup() {
t.Error("expected Cleanup to be called even when kill reported already-dead")
}
if !strings.Contains(res.Reason, "exited during kill attempt") {
t.Errorf("expected reason about race, got %q", res.Reason)
}
}

102
cmd/event/suggestions.go Normal file
View File

@@ -0,0 +1,102 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"fmt"
"sort"
"strings"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
)
const maxSuggestions = 3
// suggestEventKeys returns up to maxSuggestions keys resembling input (substring match beats edit distance).
func suggestEventKeys(input string) []string {
type match struct {
key string
dist int
}
var hits []match
threshold := max(2, len(input)/5)
for _, def := range eventlib.ListAll() {
if strings.Contains(def.Key, input) {
hits = append(hits, match{def.Key, 0})
continue
}
if d := levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d})
}
}
sort.Slice(hits, func(i, j int) bool { return hits[i].dist < hits[j].dist })
n := min(maxSuggestions, len(hits))
out := make([]string, n)
for i := range out {
out[i] = hits[i].key
}
return out
}
// formatSuggestions renders keys as a human-readable quoted tail.
func formatSuggestions(keys []string) string {
if len(keys) == 0 {
return ""
}
quoted := make([]string, len(keys))
for i, k := range keys {
quoted[i] = fmt.Sprintf("%q", k)
}
if len(quoted) == 1 {
return quoted[0]
}
return "one of: " + strings.Join(quoted, ", ")
}
// unknownEventKeyErr builds the shared "unknown EventKey" error with a suggestion tail when available.
func unknownEventKeyErr(key string) error {
msg := fmt.Sprintf("unknown EventKey: %s", key)
if guesses := suggestEventKeys(key); len(guesses) > 0 {
msg += " — did you mean " + formatSuggestions(guesses) + "?"
}
return output.ErrWithHint(
output.ExitValidation, "validation",
msg,
"Run 'lark-cli event list' to see available keys.",
)
}
// levenshtein computes classic edit distance (two-row DP).
func levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}

View File

@@ -0,0 +1,150 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"strings"
"testing"
_ "github.com/larksuite/cli/events"
)
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "abc", 3},
{"kitten", "kitten", 0},
{"kitten", "sitten", 1},
{"kitten", "sitting", 3},
{"飞书", "飞书", 0},
{"飞书", "飞s", 1},
}
for _, tc := range cases {
if got := levenshtein(tc.a, tc.b); got != tc.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestSuggestEventKeys(t *testing.T) {
cases := []struct {
name string
input string
wantEmpty bool
wantAllHavePrefix string
wantContains string
}{
{
name: "typo via Levenshtein (recieve → receive)",
input: "im.message.recieve_v1",
wantContains: "im.message.receive_v1",
},
{
name: "substring match returns im.message.* keys",
input: "im.message",
wantAllHavePrefix: "im.message.",
},
{
name: "completely unrelated input returns empty",
input: "xyzzy_no_such_event_key_at_all",
wantEmpty: true,
},
{
name: "exact key is a substring of itself",
input: "im.message.receive_v1",
wantContains: "im.message.receive_v1",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := suggestEventKeys(tc.input)
if tc.wantEmpty {
if len(got) != 0 {
t.Errorf("expected empty slice, got %v", got)
}
return
}
if len(got) == 0 {
t.Fatalf("expected non-empty suggestions, got nothing")
}
if len(got) > maxSuggestions {
t.Errorf("got %d suggestions, want at most %d: %v", len(got), maxSuggestions, got)
}
if tc.wantAllHavePrefix != "" {
for _, k := range got {
if !strings.HasPrefix(k, tc.wantAllHavePrefix) {
t.Errorf("suggestion %q lacks prefix %q (full slice: %v)", k, tc.wantAllHavePrefix, got)
}
}
}
if tc.wantContains != "" {
found := false
for _, k := range got {
if k == tc.wantContains {
found = true
break
}
}
if !found {
t.Errorf("want %q in suggestions, got %v", tc.wantContains, got)
}
}
})
}
}
func TestFormatSuggestions(t *testing.T) {
cases := []struct {
name string
in []string
want string
}{
{name: "empty → empty string", in: nil, want: ""},
{name: "single key → just quoted", in: []string{"a"}, want: `"a"`},
{name: "two keys → one of", in: []string{"a", "b"}, want: `one of: "a", "b"`},
{name: "three keys → one of", in: []string{"a", "b", "c"}, want: `one of: "a", "b", "c"`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := formatSuggestions(tc.in); got != tc.want {
t.Errorf("formatSuggestions(%v) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) {
err := unknownEventKeyErr("im.message.recieve_v1")
if err == nil {
t.Fatal("expected error")
}
msg := err.Error()
for _, want := range []string{
"unknown EventKey: im.message.recieve_v1",
"did you mean",
"im.message.receive_v1",
} {
if !strings.Contains(msg, want) {
t.Errorf("error %q missing %q", msg, want)
}
}
}
func TestUnknownEventKeyErr_NoSuggestion(t *testing.T) {
err := unknownEventKeyErr("xyzzy_no_such_event_key_at_all")
if err == nil {
t.Fatal("expected error")
}
msg := err.Error()
if !strings.Contains(msg, "unknown EventKey") {
t.Errorf("error should mention unknown EventKey: %q", msg)
}
if strings.Contains(msg, "did you mean") {
t.Errorf("error should NOT suggest anything for nonsense input: %q", msg)
}
}

39
cmd/event/table.go Normal file
View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"fmt"
"io"
)
// tableWidths returns the max cell width per column across headers + rows.
func tableWidths(headers []string, rows [][]string) []int {
widths := make([]int, len(headers))
for i, h := range headers {
widths[i] = len(h)
}
for _, row := range rows {
for i, cell := range row {
if i >= len(widths) {
break
}
if l := len(cell); l > widths[i] {
widths[i] = l
}
}
}
return widths
}
// printTableRow renders one padded row; final cell is unpadded to avoid trailing whitespace.
func printTableRow(out io.Writer, widths []int, cells []string, gap string) {
for i, cell := range cells {
if i == len(cells)-1 {
fmt.Fprintln(out, cell)
return
}
fmt.Fprintf(out, "%-*s%s", widths[i], cell, gap)
}
}

View File

@@ -158,7 +158,7 @@ func isCompletionCommand(args []string) bool {
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
// the invocation will actually serve a __complete request.
func configureFlagCompletions(args []string) {
cmdutil.SetFlagCompletionsDisabled(!isCompletionCommand(args))
cmdutil.SetFlagCompletionsEnabled(isCompletionCommand(args))
}
// handleRootError dispatches a command error to the appropriate handler
@@ -262,11 +262,15 @@ func installTipsHelpFunc(root *cobra.Command) {
}
}
defaultHelp(cmd, args)
out := cmd.OutOrStdout()
if level, ok := cmdutil.GetRisk(cmd); ok {
fmt.Fprintln(out)
fmt.Fprintln(out, "Risk:", level)
}
tips := cmdutil.GetTips(cmd)
if len(tips) == 0 {
return
}
out := cmd.OutOrStdout()
fmt.Fprintln(out)
fmt.Fprintln(out, "Tips:")
for _, tip := range tips {

View File

@@ -149,20 +149,6 @@ func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
stderr.Reset()
}
func parseDryRunJSON(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
out := stdout.String()
const prefix = "=== Dry Run ===\n"
if !strings.HasPrefix(out, prefix) {
t.Fatalf("expected dry-run prefix, got:\n%s", out)
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(strings.TrimPrefix(out, prefix)), &payload); err != nil {
t.Fatalf("failed to parse dry-run payload: %v\nstdout: %s", err, out)
}
return payload
}
// --- api command ---
func TestIntegration_Api_BusinessError_OutputsEnvelope(t *testing.T) {
@@ -402,7 +388,25 @@ func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *
}
}
func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentity(t *testing.T) {
func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
},
})
}
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
@@ -410,16 +414,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentit
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
})
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got: %s", stderr.String())
}
payload := parseDryRunJSON(t, stdout)
if got := payload["as"]; got != "bot" {
t.Fatalf("dry-run as = %v, want bot", got)
}
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
},
})
}
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
@@ -439,7 +441,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
})
}
func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t *testing.T) {
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
@@ -447,16 +449,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
})
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got: %s", stderr.String())
}
payload := parseDryRunJSON(t, stdout)
if got := payload["as"]; got != "bot" {
t.Fatalf("dry-run as = %v, want bot", got)
}
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
},
})
}
// --- shortcut command ---

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// rendersHelp runs the wrapped help func and returns stdout.
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
t.Helper()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.HelpFunc()(cmd, nil)
return buf.String()
}
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root)
child := &cobra.Command{Use: "delete", Short: "delete a file"}
cmdutil.SetRisk(child, "high-risk-write")
root.AddCommand(child)
out := rendersHelp(t, child)
if !strings.Contains(out, "Risk: high-risk-write") {
t.Errorf("expected Risk line in help output, got:\n%s", out)
}
}
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root)
child := &cobra.Command{Use: "list", Short: "list items"}
root.AddCommand(child)
out := rendersHelp(t, child)
if strings.Contains(out, "Risk:") {
t.Errorf("expected no Risk line when annotation is absent, got:\n%s", out)
}
}
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root)
child := &cobra.Command{Use: "delete", Short: "delete a file"}
cmdutil.SetRisk(child, "high-risk-write")
cmdutil.SetTips(child, []string{"use --yes to confirm"})
root.AddCommand(child)
out := rendersHelp(t, child)
riskIdx := strings.Index(out, "Risk:")
tipsIdx := strings.Index(out, "Tips:")
if riskIdx == -1 || tipsIdx == -1 {
t.Fatalf("expected both Risk and Tips sections, got:\n%s", out)
}
if riskIdx >= tipsIdx {
t.Errorf("expected Risk to precede Tips; got Risk@%d, Tips@%d", riskIdx, tipsIdx)
}
}

View File

@@ -198,7 +198,7 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
}
func TestConfigureFlagCompletions(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) })
tests := []struct {
name string
@@ -213,10 +213,10 @@ func TestConfigureFlagCompletions(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmdutil.SetFlagCompletionsDisabled(!tc.wantDisabled)
cmdutil.SetFlagCompletionsEnabled(tc.wantDisabled)
configureFlagCompletions(tc.args)
if got := cmdutil.FlagCompletionsDisabled(); got != tc.wantDisabled {
t.Fatalf("FlagCompletionsDisabled() = %v, want %v", got, tc.wantDisabled)
if got := !cmdutil.FlagCompletionsEnabled(); got != tc.wantDisabled {
t.Fatalf("FlagCompletionsEnabled() = %v, want disabled=%v", !got, tc.wantDisabled)
}
})
}

View File

@@ -140,6 +140,7 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
desc := registry.GetStrFromMap(method, "description")
httpMethod := registry.GetStrFromMap(method, "httpMethod")
risk := registry.GetStrFromMap(method, "risk")
specName := registry.GetStrFromMap(spec, "name")
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
@@ -179,6 +180,9 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
if risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}
// Conditionally register --file for methods with file-type fields.
fileFields := detectFileFields(method)
@@ -194,6 +198,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
})
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
cmdutil.SetRisk(cmd, risk)
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
}
@@ -249,6 +254,12 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
return serviceDryRun(f, request, config, opts.Format)
}
if registry.GetStrFromMap(opts.Method, "risk") == "high-risk-write" {
if yes, _ := opts.Cmd.Flags().GetBool("yes"); !yes {
return cmdutil.RequireConfirmation(opts.SchemaPath)
}
}
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
return err

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
)
// highRiskDeleteMethod mirrors a simple DELETE API with a required path
// parameter and risk metadata. The returned map is what service registration
// reads; the test exercises --yes registration and the gate behavior.
func highRiskDeleteMethod() map[string]interface{} {
return map[string]interface{}{
"path": "files/{file_token}",
"httpMethod": "DELETE",
"risk": "high-risk-write",
"parameters": map[string]interface{}{
"file_token": map[string]interface{}{
"type": "string", "location": "path", "required": true,
},
},
}
}
func writeMethodNoRisk() map[string]interface{} {
return map[string]interface{}{
"path": "files/{file_token}",
"httpMethod": "DELETE",
"parameters": map[string]interface{}{
"file_token": map[string]interface{}{
"type": "string", "location": "path", "required": true,
},
},
}
}
func TestServiceMethod_YesFlagRegisteredForHighRisk(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
if cmd.Flags().Lookup("yes") == nil {
t.Error("expected --yes flag registered for risk=high-risk-write")
}
}
func TestServiceMethod_YesFlagNotRegisteredForWrite(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), writeMethodNoRisk(), "delete", "files", nil)
if cmd.Flags().Lookup("yes") != nil {
t.Error("expected --yes flag NOT registered when risk is unset")
}
}
func TestServiceMethod_RiskAnnotationSet(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
level, ok := cmdutil.GetRisk(cmd)
if !ok {
t.Fatal("expected Risk annotation to be set")
}
if level != "high-risk-write" {
t.Errorf("level = %q, want high-risk-write", level)
}
}
func TestServiceMethod_RiskAnnotationAbsentForUnsetRisk(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), writeMethodNoRisk(), "delete", "files", nil)
if _, ok := cmdutil.GetRisk(cmd); ok {
t.Error("expected no Risk annotation when meta risk is unset")
}
}
func TestServiceMethod_GateBlocksWithoutYes(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
// --as bot skips the scope check so we reach the gate without external creds.
cmd.SetArgs([]string{"--as", "bot", "--params", `{"file_token":"tok_abc"}`})
err := cmd.Execute()
if err == nil {
t.Fatal("expected confirmation error, got nil")
}
if !strings.Contains(err.Error(), "requires confirmation") {
t.Errorf("expected 'requires confirmation' in error, got: %v", err)
}
if !strings.Contains(err.Error(), "drive.files.delete") {
t.Errorf("expected schema path in error action, got: %v", err)
}
}
func TestServiceMethod_DryRunBypassesGate(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), highRiskDeleteMethod(), "delete", "files", nil)
cmd.SetArgs([]string{
"--as", "bot",
"--params", `{"file_token":"tok_abc"}`,
"--dry-run",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run should not hit confirmation gate; got: %v", err)
}
if !strings.Contains(stdout.String(), "files/tok_abc") {
t.Errorf("expected dry-run output to contain URL, got:\n%s", stdout.String())
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/event"
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
)
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
type ImMessageReceiveOutput struct {
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_" kind:"message_id"`
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Message struct {
MessageID string `json:"message_id"`
ChatID string `json:"chat_id"`
ChatType string `json:"chat_type"`
MessageType string `json:"message_type"`
Content string `json:"content"`
CreateTime string `json:"create_time"`
Mentions []interface{} `json:"mentions"`
} `json:"message"`
Sender struct {
SenderID struct {
OpenID string `json:"open_id"`
} `json:"sender_id"`
} `json:"sender"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
msg := envelope.Event.Message
content := msg.Content
if msg.MessageType != "interactive" {
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
RawContent: msg.Content,
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),
})
}
timestamp := envelope.Header.CreateTime
if timestamp == "" {
timestamp = msg.CreateTime
}
out := &ImMessageReceiveOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: timestamp,
ID: msg.MessageID,
MessageID: msg.MessageID,
CreateTime: msg.CreateTime,
ChatID: msg.ChatID,
ChatType: msg.ChatType,
MessageType: msg.MessageType,
SenderID: envelope.Event.Sender.SenderID.OpenID,
Content: content,
}
return json.Marshal(out)
}

View File

@@ -0,0 +1,190 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestMain(m *testing.M) {
for _, k := range Keys() {
event.RegisterKey(k)
}
os.Exit(m.Run())
}
func TestIMKeys_ProcessedReceiveRegistered(t *testing.T) {
def, ok := event.Lookup("im.message.receive_v1")
if !ok {
t.Fatal("im.message.receive_v1 should be registered via Keys()")
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for Processed key")
}
if len(def.Scopes) == 0 {
t.Error("Scopes must not be empty — preflightScopes would bypass validation")
}
}
func TestIMKeys_NativeEventsRegistered(t *testing.T) {
want := []string{
"im.message.message_read_v1",
"im.message.reaction.created_v1",
"im.message.reaction.deleted_v1",
"im.chat.member.bot.added_v1",
"im.chat.member.bot.deleted_v1",
"im.chat.member.user.added_v1",
"im.chat.member.user.withdrawn_v1",
"im.chat.member.user.deleted_v1",
"im.chat.updated_v1",
"im.chat.disbanded_v1",
}
for _, k := range want {
def, ok := event.Lookup(k)
if !ok {
t.Errorf("%s should be registered via Keys()", k)
continue
}
if def.Schema.Native == nil {
t.Errorf("%s: Schema.Native must be set for native key", k)
}
if def.Schema.Custom != nil {
t.Errorf("%s: Native key must not set Schema.Custom", k)
}
if def.Process != nil {
t.Errorf("%s: Native key must not set Process", k)
}
if def.Schema.Native != nil && def.Schema.Native.Type == nil {
t.Errorf("%s: Schema.Native.Type must reference an SDK type", k)
}
}
}
func TestProcessImMessageReceive_Text(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_test_text",
"event_type": "im.message.receive_v1",
"create_time": "1776409469273",
"app_id": "cli_test"
},
"event": {
"sender": {
"sender_id": {"open_id": "ou_sender"}
},
"message": {
"message_id": "om_text_001",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"create_time": "1776409468987",
"content": "{\"text\":\"hello there\"}"
}
}
}`
out := runReceive(t, payload)
if out.Type != "im.message.receive_v1" {
t.Errorf("Type = %q", out.Type)
}
if out.MessageID != "om_text_001" || out.ID != "om_text_001" {
t.Errorf("MessageID/ID = %q/%q", out.MessageID, out.ID)
}
if out.ChatType != "p2p" || out.ChatID != "oc_chat" {
t.Errorf("chat_id/chat_type = %q/%q", out.ChatID, out.ChatType)
}
if out.SenderID != "ou_sender" {
t.Errorf("SenderID = %q", out.SenderID)
}
if out.Content != "hello there" {
t.Errorf("Content = %q, want \"hello there\"", out.Content)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
}
func TestProcessImMessageReceive_Interactive(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_test_card",
"event_type": "im.message.receive_v1",
"create_time": "1776409469274",
"app_id": "cli_test"
},
"event": {
"sender": {
"sender_id": {"open_id": "ou_sender"}
},
"message": {
"message_id": "om_card_001",
"chat_id": "oc_chat",
"chat_type": "group",
"message_type": "interactive",
"create_time": "1776409468987",
"content": "{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"A card\"}}}"
}
}
}`
out := runReceive(t, payload)
if out.Type != "im.message.receive_v1" {
t.Errorf("Type = %q", out.Type)
}
if out.MessageType != "interactive" {
t.Errorf("MessageType = %q", out.MessageType)
}
if out.ChatType != "group" {
t.Errorf("ChatType = %q", out.ChatType)
}
}
func TestProcessImMessageReceive_MalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: "im.message.receive_v1",
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: "im.message.receive_v1",
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out ImMessageReceiveOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid ImMessageReceiveOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}

184
events/im/native.go Normal file
View File

@@ -0,0 +1,184 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"reflect"
"github.com/larksuite/cli/internal/event/schemas"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
)
// nativeIMKey curates metadata for a Native IM event; fieldOverrides paths are JSON Pointer anchored at the V2-wrapped schema (start with /event/...).
type nativeIMKey struct {
key string
title string
description string
scopes []string
bodyType reflect.Type
fieldOverrides map[string]schemas.FieldMeta
}
// userIDOv returns open_id/union_id/user_id overrides for a UserID object at prefix.
func userIDOv(prefix string) map[string]schemas.FieldMeta {
return map[string]schemas.FieldMeta{
prefix + "/open_id": {Kind: "open_id"},
prefix + "/union_id": {Kind: "union_id"},
prefix + "/user_id": {Kind: "user_id"},
}
}
// mergeOv merges FieldMeta maps left-to-right (later wins).
func mergeOv(ms ...map[string]schemas.FieldMeta) map[string]schemas.FieldMeta {
out := map[string]schemas.FieldMeta{}
for _, m := range ms {
for k, v := range m {
out[k] = v
}
}
return out
}
var nativeIMKeys = []nativeIMKey{
{
key: "im.message.message_read_v1",
title: "Message read",
description: "Triggered after a user reads a P2P message sent by the bot",
scopes: []string{"im:message:readonly", "im:message"},
bodyType: reflect.TypeOf(larkim.P2MessageReadV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/reader/reader_id"),
map[string]schemas.FieldMeta{
"/event/reader/read_time": {Kind: "timestamp_ms"},
"/event/message_id_list/*": {Kind: "message_id"},
},
),
},
{
key: "im.message.reaction.created_v1",
title: "Reaction added",
description: "Triggered when a reaction is added to a message",
scopes: []string{"im:message:readonly", "im:message.reactions:read"},
bodyType: reflect.TypeOf(larkim.P2MessageReactionCreatedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/user_id"),
map[string]schemas.FieldMeta{
"/event/message_id": {Kind: "message_id"},
"/event/action_time": {Kind: "timestamp_ms"},
},
),
},
{
key: "im.message.reaction.deleted_v1",
title: "Reaction removed",
description: "Triggered when a reaction is removed from a message",
scopes: []string{"im:message:readonly", "im:message.reactions:read"},
bodyType: reflect.TypeOf(larkim.P2MessageReactionDeletedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/user_id"),
map[string]schemas.FieldMeta{
"/event/message_id": {Kind: "message_id"},
"/event/action_time": {Kind: "timestamp_ms"},
},
),
},
{
key: "im.chat.member.bot.added_v1",
title: "Bot added to chat",
description: "Triggered when the bot is added to a chat",
scopes: []string{"im:chat.members:bot_access"},
bodyType: reflect.TypeOf(larkim.P2ChatMemberBotAddedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/operator_id"),
map[string]schemas.FieldMeta{
"/event/chat_id": {Kind: "chat_id"},
},
),
},
{
key: "im.chat.member.bot.deleted_v1",
title: "Bot removed from chat",
description: "Triggered after the bot is removed from a chat",
scopes: []string{"im:chat.members:bot_access"},
bodyType: reflect.TypeOf(larkim.P2ChatMemberBotDeletedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/operator_id"),
map[string]schemas.FieldMeta{
"/event/chat_id": {Kind: "chat_id"},
},
),
},
{
key: "im.chat.member.user.added_v1",
title: "User added to chat",
description: "Triggered when a new user joins a chat (including topic chats)",
scopes: []string{"im:chat.members:read"},
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserAddedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/operator_id"),
userIDOv("/event/users/*/user_id"),
map[string]schemas.FieldMeta{
"/event/chat_id": {Kind: "chat_id"},
},
),
},
{
key: "im.chat.member.user.withdrawn_v1",
title: "User invite withdrawn",
description: "Triggered after a pending user invite is withdrawn",
scopes: []string{"im:chat.members:read"},
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserWithdrawnV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/operator_id"),
userIDOv("/event/users/*/user_id"),
map[string]schemas.FieldMeta{
"/event/chat_id": {Kind: "chat_id"},
},
),
},
{
key: "im.chat.member.user.deleted_v1",
title: "User left chat",
description: "Triggered when a user leaves or is removed from a chat",
scopes: []string{"im:chat.members:read"},
bodyType: reflect.TypeOf(larkim.P2ChatMemberUserDeletedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/operator_id"),
userIDOv("/event/users/*/user_id"),
map[string]schemas.FieldMeta{
"/event/chat_id": {Kind: "chat_id"},
},
),
},
{
key: "im.chat.updated_v1",
title: "Chat updated",
description: "Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated",
scopes: []string{"im:chat:read"},
bodyType: reflect.TypeOf(larkim.P2ChatUpdatedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/operator_id"),
userIDOv("/event/before_change/owner_id"),
userIDOv("/event/after_change/owner_id"),
userIDOv("/event/moderator_list/added_member_list/*/user_id"),
userIDOv("/event/moderator_list/removed_member_list/*/user_id"),
map[string]schemas.FieldMeta{
"/event/chat_id": {Kind: "chat_id"},
},
),
},
{
key: "im.chat.disbanded_v1",
title: "Chat disbanded",
description: "Triggered after a chat is disbanded",
scopes: []string{"im:chat:read"},
bodyType: reflect.TypeOf(larkim.P2ChatDisbandedV1Data{}),
fieldOverrides: mergeOv(
userIDOv("/event/operator_id"),
map[string]schemas.FieldMeta{
"/event/chat_id": {Kind: "chat_id"},
},
),
},
}

49
events/im/register.go Normal file
View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package im registers IM-domain EventKeys.
package im
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
// Keys returns all IM-domain EventKey definitions.
func Keys() []event.KeyDefinition {
out := []event.KeyDefinition{
{
Key: "im.message.receive_v1",
DisplayName: "Receive message",
Description: "Receive IM messages",
EventType: "im.message.receive_v1",
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ImMessageReceiveOutput{})},
},
Process: processImMessageReceive,
// Narrowest grant; kept single-element since MissingScopes uses AND semantics.
Scopes: []string{"im:message.p2p_msg:readonly"},
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{"im.message.receive_v1"},
},
}
for _, rk := range nativeIMKeys {
out = append(out, event.KeyDefinition{
Key: rk.key,
DisplayName: rk.title,
Description: rk.description,
EventType: rk.key,
Schema: event.SchemaDef{
Native: &event.SchemaSpec{Type: rk.bodyType},
FieldOverrides: rk.fieldOverrides,
},
Scopes: rk.scopes,
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{rk.key},
})
}
return out
}

107
events/lint_test.go Normal file
View File

@@ -0,0 +1,107 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package events
import (
"encoding/json"
"reflect"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
func TestAllKeys_FieldOverridePointersResolve(t *testing.T) {
for _, def := range event.ListAll() {
if len(def.Schema.FieldOverrides) == 0 {
continue
}
raw := renderDefSchemaForLint(t, def)
if raw == nil {
t.Errorf("%s: FieldOverrides set but Schema has no Native/Custom spec", def.Key)
continue
}
var parsed map[string]interface{}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Errorf("%s: parse schema: %v", def.Key, err)
continue
}
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
if len(orphans) > 0 {
t.Errorf("%s: orphan FieldOverrides paths (typo or SDK drift): %v", def.Key, orphans)
}
}
}
func renderDefSchemaForLint(t *testing.T, def *event.KeyDefinition) json.RawMessage {
t.Helper()
spec, isNative := pickSpec(def.Schema)
if spec == nil {
return nil
}
raw := renderSpec(t, spec)
if raw == nil {
return nil
}
if isNative {
raw = schemas.WrapV2Envelope(raw)
}
return raw
}
func pickSpec(s event.SchemaDef) (*event.SchemaSpec, bool) {
if s.Native != nil {
return s.Native, true
}
if s.Custom != nil {
return s.Custom, false
}
return nil, false
}
func renderSpec(t *testing.T, s *event.SchemaSpec) json.RawMessage {
t.Helper()
if s.Type != nil {
return schemas.FromType(s.Type)
}
if len(s.Raw) > 0 {
return append(json.RawMessage{}, s.Raw...)
}
return nil
}
// Proves the pipeline catches orphan FieldOverrides paths, so TestAllKeys_FieldOverridePointersResolve isn't vacuous.
func TestOrphanDetectionMechanism(t *testing.T) {
type synthetic struct {
ValidField string `json:"valid_field"`
}
spec := &event.SchemaSpec{Type: reflect.TypeOf(synthetic{})}
raw := renderSpec(t, spec)
if raw == nil {
t.Fatal("renderSpec returned nil for synthetic type")
}
var parsed map[string]interface{}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
overrides := map[string]schemas.FieldMeta{
"/valid_field": {Kind: "open_id"},
"/broken_typo": {Kind: "chat_id"},
"/valid_field/x": {Kind: "email"},
}
orphans := schemas.ApplyFieldOverrides(parsed, overrides)
wantOrphans := map[string]bool{"/broken_typo": true, "/valid_field/x": true}
if len(orphans) != len(wantOrphans) {
t.Fatalf("orphans = %v, want exactly %v", orphans, wantOrphans)
}
for _, o := range orphans {
if !wantOrphans[o] {
t.Errorf("unexpected orphan %q", o)
}
}
vf := parsed["properties"].(map[string]interface{})["valid_field"].(map[string]interface{})
if vf["format"] != "open_id" {
t.Errorf("valid path not applied: %v", vf)
}
}

22
events/register.go Normal file
View File

@@ -0,0 +1,22 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package events wires domain EventKey definitions into the global registry. Blank-import to populate.
package events
import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/internal/event"
)
// Mail is intentionally omitted: only IM is wired up this phase.
func init() {
all := [][]event.KeyDefinition{
im.Keys(),
}
for _, keys := range all {
for _, k := range keys {
event.RegisterKey(k)
}
}
}

3
go.mod
View File

@@ -3,12 +3,13 @@ module github.com/larksuite/cli
go 1.23.0
require (
github.com/Microsoft/go-winio v0.6.2
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1
github.com/spf13/cobra v1.10.2

6
go.sum
View File

@@ -1,5 +1,7 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -69,8 +71,8 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package appmeta exposes read-only views of a Feishu app's published version, subscribed event types, and scopes.
package appmeta
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/event"
)
// APIClient aliases event.APIClient so one concrete adapter satisfies event, appmeta, and consume.
type APIClient = event.APIClient
// AppVersion is the projected subset of one /app_versions item preflight cares about.
type AppVersion struct {
VersionID string
Version string
EventTypes []string
TenantScopes []string
}
const appVersionStatusPublished = 1
// FetchCurrentPublished returns the most recently published version of appID, or (nil, nil) if never published.
// page_size=2 suffices: Feishu disallows a new version while an in-progress one exists, so the first status==1 item with publish_time is the live one.
func FetchCurrentPublished(ctx context.Context, client APIClient, appID string) (*AppVersion, error) {
path := fmt.Sprintf(
"/open-apis/application/v6/applications/%s/app_versions?lang=zh_cn&page_size=2",
appID,
)
raw, err := client.CallAPI(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var envelope struct {
Data struct {
Items []struct {
VersionID string `json:"version_id"`
Version string `json:"version"`
Status int `json:"status"`
PublishTime json.RawMessage `json:"publish_time"`
EventInfos []struct {
EventType string `json:"event_type"`
} `json:"event_infos"`
Scopes []struct {
Scope string `json:"scope"`
TokenTypes []string `json:"token_types"`
} `json:"scopes"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("decode app_versions response: %w", err)
}
for _, it := range envelope.Data.Items {
if it.Status != appVersionStatusPublished || !publishTimeSet(it.PublishTime) {
continue
}
v := &AppVersion{
VersionID: it.VersionID,
Version: it.Version,
}
for _, e := range it.EventInfos {
if e.EventType != "" {
v.EventTypes = append(v.EventTypes, e.EventType)
}
}
for _, s := range it.Scopes {
if s.Scope != "" && containsString(s.TokenTypes, "tenant") {
v.TenantScopes = append(v.TenantScopes, s.Scope)
}
}
return v, nil
}
return nil, nil
}
// publishTimeSet rejects null and empty-string; any other value is a real publish_time.
func publishTimeSet(raw json.RawMessage) bool {
s := string(raw)
return s != "" && s != "null" && s != `""`
}
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package appmeta
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/event/testutil"
)
const respFourVersions = `{
"code": 0,
"data": {
"has_more": false,
"items": [
{"version_id": "oav_draft", "version": "1.0.3", "status": 4, "publish_time": null,
"event_infos": [{"event_type": "im.message.receive_v1"}, {"event_type": "mail.user_mailbox.event.message_received_v1"}],
"scopes": [{"scope": "draft:only", "token_types": ["tenant"]}]
},
{"version_id": "oav_latest", "version": "1.0.2", "status": 1, "publish_time": "1776684746",
"event_infos": [
{"event_type": "im.message.receive_v1"},
{"event_type": "im.message.message_read_v1"}
],
"scopes": [
{"scope": "im:message", "token_types": ["tenant", "user"]},
{"scope": "im:message.group_at_msg", "token_types": ["tenant"]},
{"scope": "contact:user:readonly", "token_types": ["user"]}
]
}
]
}
}`
func TestFetchCurrentPublished_SelectsLatestPublished(t *testing.T) {
c := &testutil.StubAPIClient{Body: respFourVersions}
v, err := FetchCurrentPublished(context.Background(), c, "cli_test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v == nil {
t.Fatal("expected a version, got nil")
}
if v.VersionID != "oav_latest" {
t.Errorf("VersionID = %q, want oav_latest", v.VersionID)
}
if v.Version != "1.0.2" {
t.Errorf("Version = %q, want 1.0.2", v.Version)
}
wantEvents := map[string]bool{"im.message.receive_v1": true, "im.message.message_read_v1": true}
if len(v.EventTypes) != len(wantEvents) {
t.Fatalf("EventTypes = %v, want %v", v.EventTypes, wantEvents)
}
for _, e := range v.EventTypes {
if !wantEvents[e] {
t.Errorf("unexpected event type %q in %v", e, v.EventTypes)
}
}
wantTenant := map[string]bool{"im:message": true, "im:message.group_at_msg": true}
if len(v.TenantScopes) != len(wantTenant) {
t.Fatalf("TenantScopes = %v, want %v", v.TenantScopes, wantTenant)
}
for _, s := range v.TenantScopes {
if !wantTenant[s] {
t.Errorf("unexpected tenant scope %q in %v", s, v.TenantScopes)
}
}
}
func TestFetchCurrentPublished_PathContainsQuery(t *testing.T) {
c := &testutil.StubAPIClient{Body: respFourVersions}
_, _ = FetchCurrentPublished(context.Background(), c, "cli_x")
for _, want := range []string{
"/open-apis/application/v6/applications/cli_x/app_versions",
"lang=zh_cn",
"page_size=2",
} {
if !strings.Contains(c.GotPath, want) {
t.Errorf("path %q missing %q", c.GotPath, want)
}
}
}
func TestFetchCurrentPublished_NoPublishedYet(t *testing.T) {
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"items":[
{"version_id":"oav_draft","status":4,"publish_time":null,"event_infos":[],"scopes":[]}
]}}`}
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v != nil {
t.Errorf("want nil (app never published), got %+v", v)
}
}
func TestFetchCurrentPublished_EmptyItems(t *testing.T) {
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"items":[]}}`}
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v != nil {
t.Errorf("want nil for empty items, got %+v", v)
}
}
func TestFetchCurrentPublished_APIErrorPropagated(t *testing.T) {
want := errors.New("insufficient permission level")
c := &testutil.StubAPIClient{Err: want}
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
if !errors.Is(err, want) {
t.Errorf("err = %v, want wrapping %v", err, want)
}
if v != nil {
t.Errorf("want nil version on error, got %+v", v)
}
}
func TestFetchCurrentPublished_PublishTimeEmptyStringTreatedAsUnpublished(t *testing.T) {
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"items":[
{"version_id":"oav_x","status":1,"publish_time":"","event_infos":[],"scopes":[]}
]}}`}
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v != nil {
t.Errorf("want nil (empty publish_time), got %+v", v)
}
}

View File

@@ -208,7 +208,7 @@ func TestPaginateAll_PageLimitStopsPagination(t *testing.T) {
ac, errBuf := newTestAPIClient(t, rt)
_, err := ac.PaginateAll(context.Background(), RawApiRequest{
result, err := ac.PaginateAll(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "bot",
@@ -223,6 +223,57 @@ func TestPaginateAll_PageLimitStopsPagination(t *testing.T) {
if !strings.Contains(errBuf.String(), "reached page limit (2), stopping. Use --page-all --page-limit 0 to fetch all pages.") {
t.Errorf("expected page limit log, got: %s", errBuf.String())
}
// Truncation must surface in the merged output: has_more stays true so
// callers can detect loss. page_token is intentionally dropped from the
// aggregate view — to fetch more, re-run with a larger --page-limit.
resultMap, _ := result.(map[string]interface{})
data, _ := resultMap["data"].(map[string]interface{})
if hasMore, _ := data["has_more"].(bool); !hasMore {
t.Errorf("expected has_more=true when page limit truncates, got false")
}
if _, exists := data["page_token"]; exists {
t.Errorf("expected page_token to be dropped from merged output, got %v", data["page_token"])
}
}
func TestPaginateAll_NaturalEndClearsPageToken(t *testing.T) {
apiCalls := 0
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
apiCalls++
hasMore := apiCalls < 2
body := map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": apiCalls}},
"has_more": hasMore,
},
}
if hasMore {
body["data"].(map[string]interface{})["page_token"] = "next"
}
return jsonResponse(body), nil
})
ac, _ := newTestAPIClient(t, rt)
result, err := ac.PaginateAll(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "bot",
}, PaginationOptions{PageLimit: 10, PageDelay: 0})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
resultMap, _ := result.(map[string]interface{})
data, _ := resultMap["data"].(map[string]interface{})
if hasMore, _ := data["has_more"].(bool); hasMore {
t.Errorf("expected has_more=false at natural end, got true")
}
if _, exists := data["page_token"]; exists {
t.Errorf("expected page_token absent at natural end, got %v", data["page_token"])
}
}
func TestBuildApiReq_QueryParams(t *testing.T) {

View File

@@ -71,7 +71,18 @@ func mergePagedResults(w io.Writer, results []interface{}) interface{} {
mergedData[k] = v
}
mergedData[arrayField] = merged
mergedData["has_more"] = false
// Surface the last page's real has_more so callers can detect truncation
// when --page-limit stops the loop before the API is exhausted. Page tokens
// are intentionally dropped: the merged view is an aggregate, not a resume
// cursor — to fetch more, re-run with a larger --page-limit.
lastHasMore := false
if lastMap, ok := results[len(results)-1].(map[string]interface{}); ok {
if lastData, ok := lastMap["data"].(map[string]interface{}); ok {
lastHasMore, _ = lastData["has_more"].(bool)
}
}
mergedData["has_more"] = lastHasMore
delete(mergedData, "page_token")
delete(mergedData, "next_page_token")

View File

@@ -11,26 +11,27 @@ import (
// Cobra keeps completion callbacks in a package-global map keyed by
// *pflag.Flag with no removal path, so registrations made for a *cobra.Command
// outlive the command itself. Skip registration when the current invocation
// will not serve a completion request.
var flagCompletionsDisabled atomic.Bool
// outlive the command itself. Default to disabled (zero value = false) and let
// callers that actually serve a completion request opt in via
// SetFlagCompletionsEnabled(true).
var flagCompletionsEnabled atomic.Bool
// SetFlagCompletionsDisabled switches RegisterFlagCompletion between
// registering and no-op. Typically set once at process start.
func SetFlagCompletionsDisabled(disabled bool) {
flagCompletionsDisabled.Store(disabled)
// SetFlagCompletionsEnabled toggles whether RegisterFlagCompletion actually
// registers callbacks with cobra. Typically set once at process start.
func SetFlagCompletionsEnabled(enabled bool) {
flagCompletionsEnabled.Store(enabled)
}
// FlagCompletionsDisabled reports the current switch state.
func FlagCompletionsDisabled() bool {
return flagCompletionsDisabled.Load()
// FlagCompletionsEnabled reports the current switch state.
func FlagCompletionsEnabled() bool {
return flagCompletionsEnabled.Load()
}
// RegisterFlagCompletion wraps (*cobra.Command).RegisterFlagCompletionFunc
// and honors the package switch. The underlying error is swallowed to match
// the `_ = cmd.RegisterFlagCompletionFunc(...)` style already used here.
func RegisterFlagCompletion(cmd *cobra.Command, flagName string, fn cobra.CompletionFunc) {
if flagCompletionsDisabled.Load() {
if !flagCompletionsEnabled.Load() {
return
}
_ = cmd.RegisterFlagCompletionFunc(flagName, fn)

View File

@@ -12,18 +12,18 @@ import (
"github.com/spf13/cobra"
)
func TestSetFlagCompletionsDisabled_RoundTrip(t *testing.T) {
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
func TestSetFlagCompletionsEnabled_RoundTrip(t *testing.T) {
t.Cleanup(func() { SetFlagCompletionsEnabled(false) })
if FlagCompletionsDisabled() {
t.Fatal("expected default false")
if FlagCompletionsEnabled() {
t.Fatal("expected default false (completions disabled by default)")
}
SetFlagCompletionsDisabled(true)
if !FlagCompletionsDisabled() {
SetFlagCompletionsEnabled(true)
if !FlagCompletionsEnabled() {
t.Fatal("expected true after Set(true)")
}
SetFlagCompletionsDisabled(false)
if FlagCompletionsDisabled() {
SetFlagCompletionsEnabled(false)
if FlagCompletionsEnabled() {
t.Fatal("expected false after Set(false)")
}
}
@@ -31,8 +31,8 @@ func TestSetFlagCompletionsDisabled_RoundTrip(t *testing.T) {
// When disabled, a *cobra.Command must be collectable after the caller drops
// its reference — i.e. the wrapper did not touch cobra's global map.
func TestRegisterFlagCompletion_Disabled_DoesNotRetainCommand(t *testing.T) {
SetFlagCompletionsDisabled(true)
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
SetFlagCompletionsEnabled(false)
t.Cleanup(func() { SetFlagCompletionsEnabled(false) })
const N = 5
var collected atomic.Int32
@@ -58,7 +58,8 @@ func TestRegisterFlagCompletion_Disabled_DoesNotRetainCommand(t *testing.T) {
// When enabled, the registered completion must be reachable via cobra.
func TestRegisterFlagCompletion_Enabled_DoesRegister(t *testing.T) {
SetFlagCompletionsDisabled(false)
SetFlagCompletionsEnabled(true)
t.Cleanup(func() { SetFlagCompletionsEnabled(false) })
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("foo", "", "")

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"fmt"
"github.com/larksuite/cli/internal/output"
)
// RequireConfirmation constructs a confirmation_required error with exit code
// ExitConfirmationRequired and a structured Risk envelope. Used by both
// shortcut and service command execution paths when a statically
// high-risk-write operation has not been confirmed with --yes.
//
// action identifies the operation for the agent (e.g. "mail +send",
// "drive.files.delete"). The envelope does not carry a pre-built retry
// command: agents already know their original invocation and only need to
// append --yes per the hint, which keeps the protocol free of shell-quoting
// pitfalls.
func RequireConfirmation(action string) error {
return &output.ExitError{
Code: output.ExitConfirmationRequired,
Detail: &output.ErrDetail{
Type: "confirmation_required",
Message: fmt.Sprintf("%s requires confirmation", action),
Hint: "add --yes to confirm",
Risk: &output.RiskDetail{
Level: "high-risk-write",
Action: action,
},
},
}
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
)
func TestRequireConfirmation_EnvelopeShape(t *testing.T) {
err := RequireConfirmation("drive +delete")
if err == nil {
t.Fatal("expected non-nil error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitConfirmationRequired {
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitConfirmationRequired)
}
if exitErr.Detail == nil {
t.Fatal("Detail is nil")
}
d := exitErr.Detail
if d.Type != "confirmation_required" {
t.Errorf("Type = %q, want confirmation_required", d.Type)
}
if !strings.Contains(d.Message, "drive +delete") || !strings.Contains(d.Message, "requires confirmation") {
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", d.Message)
}
if d.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want 'add --yes to confirm'", d.Hint)
}
if d.Risk == nil {
t.Fatal("Risk is nil")
}
if d.Risk.Level != "high-risk-write" {
t.Errorf("Risk.Level = %q, want high-risk-write", d.Risk.Level)
}
if d.Risk.Action != "drive +delete" {
t.Errorf("Risk.Action = %q, want drive +delete", d.Risk.Action)
}
}
func TestRequireConfirmation_JSONShape(t *testing.T) {
err := RequireConfirmation("mail +send")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
raw, mErr := json.Marshal(exitErr.Detail)
if mErr != nil {
t.Fatalf("marshal: %v", mErr)
}
var back map[string]interface{}
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// No fix_command field leaks into the envelope: the protocol avoids
// shell-quoting hazards by delegating retry to agent-side logic.
if _, has := back["fix_command"]; has {
t.Errorf("unexpected fix_command present in JSON: %s", raw)
}
risk, ok := back["risk"].(map[string]interface{})
if !ok {
t.Fatalf("risk block missing in JSON: %s", raw)
}
if risk["level"] != "high-risk-write" {
t.Errorf("risk.level in JSON = %v", risk["level"])
}
if risk["action"] != "mail +send" {
t.Errorf("risk.action in JSON = %v", risk["action"])
}
// Action-only protocol: no UpgradedBy / fix_command / upgraded_by leak.
if _, has := risk["upgraded_by"]; has {
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
}
}

View File

@@ -60,20 +60,22 @@ func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
func (f *Factory) ResolveAs(ctx context.Context, cmd *cobra.Command, flagAs core.Identity) core.Identity {
f.IdentityAutoDetected = false
// Strict mode: force identity regardless of flags or config.
if forced := f.ResolveStrictMode(ctx).ForcedIdentity(); forced != "" {
f.ResolvedIdentity = forced
return forced
}
if cmd != nil && cmd.Flags().Changed("as") {
if flagAs != "auto" {
if flagAs != core.AsAuto {
f.ResolvedIdentity = flagAs
return flagAs
}
// --as auto: fall through to auto-detect
}
mode := f.ResolveStrictMode(ctx)
// Strict mode forces implicit identity choices. Explicit --as user/bot is
// preserved above so CheckStrictMode can reject incompatible requests.
if forced := mode.ForcedIdentity(); forced != "" {
f.ResolvedIdentity = forced
return forced
}
hint := f.resolveIdentityHint(ctx)
if cmd == nil || !cmd.Flags().Changed("as") {
if defaultAs := resolveDefaultAsFromHint(hint); defaultAs != "" && defaultAs != core.AsAuto {
@@ -199,3 +201,29 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient
Credential: f.Credential,
}, nil
}
// RequireBuiltinCredentialProvider returns a structured error (exit 2, code
// "external_provider") when an extension provider is actively managing credentials.
// Intended for use as PersistentPreRunE on the auth and config parent commands.
//
// Returns nil when:
// - f.Credential is nil (test environments without credential setup)
// - No extension provider is active (built-in keychain/config path is used)
func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command string) error {
if f.Credential == nil {
return nil
}
provName, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return err
}
if provName == "" {
return nil
}
return output.ErrWithHint(
output.ExitValidation,
"external_provider",
fmt.Sprintf("%q is not supported: credentials are provided externally and do not support interactive management", command),
"If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.",
)
}

View File

@@ -5,13 +5,17 @@ package cmdutil
import (
"context"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/output"
)
// newCmdWithAsFlag creates a cobra.Command with a --as string flag for testing.
@@ -346,6 +350,42 @@ func TestResolveAs_StrictModeUser_ForceUser(t *testing.T) {
}
}
func TestResolveAs_StrictModeUser_PreservesExplicitBot(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("bot", true)
got := f.ResolveAs(context.Background(), cmd, core.AsBot)
if got != core.AsBot {
t.Errorf("explicit bot should be preserved for strict-mode validation, got %s", got)
}
if err := f.CheckStrictMode(context.Background(), got); err == nil {
t.Fatal("expected strict-mode error for explicit bot in user mode")
}
}
func TestResolveAs_StrictModeBot_PreservesExplicitUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("user", true)
got := f.ResolveAs(context.Background(), cmd, core.AsUser)
if got != core.AsUser {
t.Errorf("explicit user should be preserved for strict-mode validation, got %s", got)
}
if err := f.CheckStrictMode(context.Background(), got); err == nil {
t.Fatal("expected strict-mode error for explicit user in bot mode")
}
}
func TestResolveAs_StrictModeUser_ExplicitAutoForcesUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", true)
got := f.ResolveAs(context.Background(), cmd, core.AsAuto)
if got != core.AsUser {
t.Errorf("--as auto should use strict-mode user identity, got %s", got)
}
}
func TestResolveAs_StrictModeBot_IgnoresDefaultAsUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", DefaultAs: "user", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
@@ -355,3 +395,79 @@ func TestResolveAs_StrictModeBot_IgnoresDefaultAsUser(t *testing.T) {
t.Errorf("bot mode should override default-as user, got %s", got)
}
}
// stubExtProvider is a minimal extcred.Provider for testing external-provider guards.
type stubExtProvider struct {
name string
acct *extcred.Account
err error
}
func (s *stubExtProvider) Name() string { return s.name }
func (s *stubExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
return s.acct, s.err
}
func (s *stubExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) {
stub := &stubExtProvider{name: "env", acct: &extcred.Account{AppID: "app"}}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
t.Errorf("error type field = %v, want %q", exitErr.Detail, "external_provider")
}
if exitErr.Detail.Message == "" {
t.Error("expected non-empty message")
}
if exitErr.Detail.Hint == "" {
t.Error("expected non-empty hint")
}
}
func TestRequireBuiltinCredentialProvider_AllowsBuiltinProvider(t *testing.T) {
// No extension providers → built-in path → no error
f, _, _, _ := TestFactory(t, nil)
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRequireBuiltinCredentialProvider_NilCredential(t *testing.T) {
f, _, _, _ := TestFactory(t, nil)
f.Credential = nil
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if err != nil {
t.Fatalf("unexpected error with nil Credential: %v", err)
}
}
func TestRequireBuiltinCredentialProvider_PropagatesProviderError(t *testing.T) {
sentinel := errors.New("provider unavailable")
stub := &stubExtProvider{name: "env", err: sentinel}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
if !errors.Is(err, sentinel) {
t.Fatalf("error = %v, want sentinel", err)
}
}

33
internal/cmdutil/risk.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import "github.com/spf13/cobra"
const riskLevelAnnotationKey = "risk_level"
// SetRisk stores a command's static risk level on cobra annotations so the
// help renderer (cmd/root.go) can surface a Risk: line without importing
// shortcuts/common. Levels follow the three-tier convention: "read" | "write"
// | "high-risk-write". Framework-level confirmation gating only acts on
// "high-risk-write".
func SetRisk(cmd *cobra.Command, level string) {
if level == "" {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[riskLevelAnnotationKey] = level
}
// GetRisk returns the static risk level. ok is true when the command has a
// risk annotation.
func GetRisk(cmd *cobra.Command) (level string, ok bool) {
if cmd.Annotations == nil {
return "", false
}
level, ok = cmd.Annotations[riskLevelAnnotationKey]
return level, ok && level != ""
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"testing"
"github.com/spf13/cobra"
)
func TestSetRisk_EmptyLevelShortCircuits(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
SetRisk(cmd, "")
if cmd.Annotations != nil {
t.Errorf("expected annotations untouched for empty level, got %v", cmd.Annotations)
}
}
func TestSetRisk_PopulatesLevel(t *testing.T) {
cases := []string{"read", "write", "high-risk-write"}
for _, level := range cases {
t.Run(level, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
SetRisk(cmd, level)
got, ok := GetRisk(cmd)
if !ok {
t.Fatal("expected ok=true after SetRisk")
}
if got != level {
t.Errorf("level = %q, want %q", got, level)
}
})
}
}
func TestSetRisk_PreservesExistingAnnotations(t *testing.T) {
cmd := &cobra.Command{
Use: "test",
Annotations: map[string]string{"other": "val"},
}
SetRisk(cmd, "high-risk-write")
if cmd.Annotations["other"] != "val" {
t.Error("existing annotation should be preserved")
}
if level, ok := GetRisk(cmd); !ok || level != "high-risk-write" {
t.Errorf("risk not written: level=%q ok=%v", level, ok)
}
}
func TestSetRisk_InitializesNilAnnotations(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
if cmd.Annotations != nil {
t.Fatal("precondition: Annotations should be nil on a fresh command")
}
SetRisk(cmd, "write")
if cmd.Annotations == nil {
t.Fatal("SetRisk should lazily initialize Annotations")
}
}
func TestGetRisk_NilAnnotations(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
level, ok := GetRisk(cmd)
if ok {
t.Error("expected ok=false for nil Annotations")
}
if level != "" {
t.Errorf("expected empty level, got %q", level)
}
}
func TestGetRisk_NoRiskKey(t *testing.T) {
cmd := &cobra.Command{
Use: "test",
Annotations: map[string]string{"unrelated": "x"},
}
if _, ok := GetRisk(cmd); ok {
t.Error("expected ok=false when risk key is absent")
}
}
func TestGetRisk_EmptyValueReturnsNotOK(t *testing.T) {
cmd := &cobra.Command{
Use: "test",
Annotations: map[string]string{riskLevelAnnotationKey: ""},
}
level, ok := GetRisk(cmd)
if ok {
t.Error("expected ok=false for empty level value")
}
if level != "" {
t.Errorf("expected empty level, got %q", level)
}
}

View File

@@ -331,6 +331,43 @@ func (p *CredentialProvider) ResolveToken(ctx context.Context, req TokenSpec) (*
return nil, &TokenUnavailableError{Type: req.Type}
}
// ActiveExtensionProviderName reports whether an extension provider is managing
// credentials. It probes p.providers (extension providers only, not defaultAcct)
// and returns the name of the first engaged provider.
//
// "Engaged" means: ResolveAccount returns a non-nil account, OR returns a
// *extcred.BlockError (provider configured but misconfigured — still counts as
// external). Any other error is propagated to the caller.
//
// Returns ("", nil) when no extension provider is active (built-in keychain path).
// Safe to call multiple times — probes providers directly without the sync.Once cache.
func (p *CredentialProvider) ActiveExtensionProviderName(ctx context.Context) (string, error) {
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)
if err != nil {
var blockErr *extcred.BlockError
if errors.As(err, &blockErr) {
name := blockErr.Provider
if name == "" {
name = prov.Name()
}
if name == "" {
name = "external"
}
return name, nil
}
return "", err
}
if acct != nil {
if name := prov.Name(); name != "" {
return name, nil
}
return "external", nil
}
}
return "", nil
}
func convertAccount(ext *extcred.Account) *Account {
return &Account{
AppID: ext.AppID,

View File

@@ -422,3 +422,72 @@ func TestCredentialProvider_ResolveTokenDoesNotBypassFailedDefaultAccountResolut
t.Fatalf("ResolveToken() error = %v, want config unavailable", err)
}
}
func TestActiveExtensionProviderName_ExtActive(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{AppID: "app"}}},
nil, nil, nil,
)
name, err := cp.ActiveExtensionProviderName(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if name != "env" {
t.Errorf("got %q, want %q", name, "env")
}
}
func TestActiveExtensionProviderName_BlockError(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{
name: "env",
accountErr: &extcred.BlockError{Provider: "env", Reason: "APP_ID missing"},
}},
nil, nil, nil,
)
name, err := cp.ActiveExtensionProviderName(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if name != "env" {
t.Errorf("got %q, want %q", name, "env")
}
}
func TestActiveExtensionProviderName_NoExtProvider(t *testing.T) {
cp := NewCredentialProvider(nil, nil, nil, nil)
name, err := cp.ActiveExtensionProviderName(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if name != "" {
t.Errorf("got %q, want empty string", name)
}
}
func TestActiveExtensionProviderName_UnexpectedError(t *testing.T) {
sentinel := errors.New("network timeout")
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "env", accountErr: sentinel}},
nil, nil, nil,
)
_, err := cp.ActiveExtensionProviderName(context.Background())
if !errors.Is(err, sentinel) {
t.Errorf("got %v, want sentinel error", err)
}
}
func TestActiveExtensionProviderName_SkipsNilProvider(t *testing.T) {
// nil account + nil error = provider not applicable; fallback returns ""
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "sidecar"}}, // no account set → returns nil, nil
nil, nil, nil,
)
name, err := cp.ActiveExtensionProviderName(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if name != "" {
t.Errorf("got %q, want empty string", name)
}
}

24
internal/event/appid.go Normal file
View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import "strings"
// SanitizeAppID replaces ".." / path separators / NUL with "_" to guard filepath.Join; empty/dot-only collapses to "_".
func SanitizeAppID(appID string) string {
if appID == "" {
return "_"
}
repl := strings.NewReplacer(
"/", "_",
"\\", "_",
"\x00", "_",
"..", "_",
)
out := repl.Replace(appID)
if out == "" || out == "." {
return "_"
}
return out
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"path/filepath"
"strings"
"testing"
)
func TestSanitizeAppID_RejectsPathTraversal(t *testing.T) {
cases := []struct {
name string
input string
wantClean string
forbidChars string
}{
{"happy path", "cli_XXXXXXXXXXXXXXXX", "cli_XXXXXXXXXXXXXXXX", "/\\\x00"},
{"empty", "", "_", ""},
{"dot", ".", "_", ""},
{"double-dot only", "..", "_", ".."},
{"leading traversal", "../etc/passwd", "__etc_passwd", "/"},
{"traversal inside", "cli_../../etc", "cli_____etc", "/"},
{"backslash traversal", "..\\windows\\system32", "__windows_system32", "\\"},
{"nul injection", "cli_\x00backdoor", "cli__backdoor", "\x00"},
{"pure slashes", "///", "___", "/"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := SanitizeAppID(tc.input)
if got != tc.wantClean {
t.Errorf("SanitizeAppID(%q) = %q, want %q", tc.input, got, tc.wantClean)
}
for _, c := range tc.forbidChars {
if strings.ContainsRune(got, c) {
t.Errorf("SanitizeAppID(%q) = %q contains forbidden rune %q", tc.input, got, c)
}
}
joined := filepath.ToSlash(filepath.Join("/root/events", got, "bus.log"))
if strings.Contains(joined, "..") {
t.Errorf("joined path %q contains .. after sanitization", joined)
}
if !strings.HasPrefix(joined, "/root/events/") {
t.Errorf("joined path %q escaped /root/events/ parent", joined)
}
})
}
}

362
internal/event/bus/bus.go Normal file
View File

@@ -0,0 +1,362 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package bus implements the per-AppID event-bus daemon; lifecycle is driven by consumer presence (idle timeout) and explicit shutdown.
package bus
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"log"
"net"
"os"
"path/filepath"
"sync"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/source"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/lockfile"
)
const (
idleTimeout = 30 * time.Second
)
// Bus is the central event bus daemon.
type Bus struct {
appID string
appSecret string
domain string
transport transport.IPC
hub *Hub
dedup *event.DedupFilter
listener net.Listener
logger *log.Logger
startTime time.Time
mu sync.Mutex
conns map[*Conn]struct{}
idleTimer *time.Timer
shutdownCh chan struct{}
// pidHandle pins the alive.lock fd to the bus lifetime; OS releases on exit.
pidHandle *busdiscover.Handle
}
func NewBus(appID, appSecret, domain string, tr transport.IPC, logger *log.Logger) *Bus {
return &Bus{
appID: appID,
appSecret: appSecret,
domain: domain,
transport: tr,
hub: NewHub(),
dedup: event.NewDedupFilter(),
logger: logger,
startTime: time.Now(),
conns: make(map[*Conn]struct{}),
// Buffered so shutdown and source-exit paths never drop the signal.
shutdownCh: make(chan struct{}, 1),
}
}
// Run binds the IPC socket, starts event sources, and blocks in the accept loop until shutdown.
func (b *Bus) Run(ctx context.Context) error {
addr := b.transport.Address(b.appID)
// alive.lock before bind: closes the cleanup-TOCTOU race where two newly forked
// buses each unlink and rebind the socket. Brief retry covers stop-then-restart.
eventsDir := filepath.Join(core.GetConfigDir(), "events", event.SanitizeAppID(b.appID))
pidHandle, pidErr := acquireAliveLock(eventsDir)
if pidErr != nil {
if errors.Is(pidErr, lockfile.ErrHeld) {
b.logger.Printf("Another bus already holds %s/bus.alive.lock, exiting", eventsDir)
return nil
}
b.logger.Printf("[bus] pid file write failed: %v (status discovery may miss this bus)", pidErr)
} else {
b.pidHandle = pidHandle
}
ln, err := b.transport.Listen(addr)
if err != nil {
if probe, dialErr := b.transport.Dial(addr); dialErr == nil {
probe.Close()
b.logger.Printf("Another bus is already running for %s, exiting", b.appID)
return nil
}
b.transport.Cleanup(addr)
ln, err = b.transport.Listen(addr)
if err != nil {
return fmt.Errorf("bus listen: %w", err)
}
}
b.listener = ln
b.logger.Printf("Bus started for app=%s pid=%d addr=%s", b.appID, os.Getpid(), addr)
b.idleTimer = time.NewTimer(idleTimeout)
sourceCtx, sourceCancel := context.WithCancel(ctx)
defer sourceCancel()
b.startSources(sourceCtx)
acceptDone := make(chan struct{})
go func() {
defer close(acceptDone)
b.acceptLoop(ctx)
}()
// Re-check live conn count under lock: a stale idle tick can linger past a concurrent Stop+Reset.
for {
select {
case <-ctx.Done():
b.logger.Printf("Bus shutting down (context cancelled)")
case <-b.idleTimer.C:
b.mu.Lock()
active := len(b.conns)
if active > 0 {
b.idleTimer.Reset(idleTimeout)
b.mu.Unlock()
continue
}
b.mu.Unlock()
b.logger.Printf("Bus shutting down (idle %v, no active connections)", idleTimeout)
case <-b.shutdownCh:
b.logger.Printf("Bus shutting down (shutdown command received)")
}
break
}
b.listener.Close()
// Don't delete the socket: Run() handles stale sockets on startup, and deletion races a new bus.
shutdownConns(b)
<-acceptDone
b.logger.Printf("Bus exited cleanly")
return nil
}
// shutdownConns snapshots b.conns under lock then releases before Close() — Close→onClose reacquires b.mu.
func shutdownConns(b *Bus) {
b.mu.Lock()
conns := make([]*Conn, 0, len(b.conns))
for c := range b.conns {
conns = append(conns, c)
}
b.mu.Unlock()
for _, c := range conns {
c.Close()
}
}
// startSources launches registered sources (or a default FeishuSource); any source exit triggers full bus shutdown.
func (b *Bus) startSources(ctx context.Context) {
sources := source.All()
if len(sources) == 0 {
sources = []source.Source{&source.FeishuSource{
AppID: b.appID,
AppSecret: b.appSecret,
Domain: b.domain,
Logger: b.logger,
}}
}
eventTypes := subscribedEventTypes()
b.hub.SetLogger(b.logger)
for _, src := range sources {
go func(s source.Source) {
b.logger.Printf("Starting source: %s", s.Name())
err := s.Start(ctx, eventTypes, func(raw *event.RawEvent) {
b.logger.Printf("Event received: type=%s id=%s", raw.EventType, raw.EventID)
if b.dedup.IsDuplicate(raw.EventID) {
b.logger.Printf("Event deduplicated: id=%s", raw.EventID)
return
}
b.hub.Publish(raw)
}, func(state, detail string) {
b.hub.BroadcastSourceStatus(s.Name(), state, detail)
})
if ctx.Err() != nil {
return
}
if err != nil {
b.logger.Printf("Source %s exited with error: %v — shutting down bus", s.Name(), err)
} else {
b.logger.Printf("Source %s exited without error before shutdown — shutting down bus", s.Name())
}
select {
case b.shutdownCh <- struct{}{}:
default:
}
}(src)
}
}
// subscribedEventTypes returns the deduplicated union of EventTypes from every registered EventKey.
func subscribedEventTypes() []string {
seen := make(map[string]struct{})
var types []string
for _, def := range event.ListAll() {
if _, ok := seen[def.EventType]; ok {
continue
}
seen[def.EventType] = struct{}{}
types = append(types, def.EventType)
}
return types
}
// acceptLoop accepts IPC connections until the listener is closed.
func (b *Bus) acceptLoop(ctx context.Context) {
for {
conn, err := b.listener.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
select {
case <-ctx.Done():
return
default:
}
b.logger.Printf("Accept error: %v", err)
return
}
go b.handleConn(conn)
}
}
// handleConn reads the first protocol message and dispatches; the bufio.Reader is handed to Conn so buffered bytes carry over.
func (b *Bus) handleConn(conn net.Conn) {
br := bufio.NewReader(conn)
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
line, err := protocol.ReadFrame(br)
if err != nil {
conn.Close()
return
}
conn.SetReadDeadline(time.Time{})
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
if err != nil {
conn.Close()
return
}
switch m := msg.(type) {
case *protocol.Hello:
b.handleHello(conn, br, m)
case *protocol.StatusQuery:
b.handleStatusQuery(conn)
case *protocol.Shutdown:
b.handleShutdown(conn)
default:
conn.Close()
}
}
// handleHello registers a consume connection with the hub; reader carries bytes already pulled off conn.
func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.Hello) {
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID)
bc.SetLogger(b.logger)
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
firstForKey := b.hub.RegisterAndIsFirst(bc)
bc.SetCheckLastForKey(func(eventKey string) bool {
return b.hub.AcquireCleanupLock(eventKey)
})
bc.SetOnClose(func(c *Conn) {
b.hub.UnregisterAndIsLast(c)
// Release is idempotent and must fire on every disconnect path so waiters don't block forever.
b.hub.ReleaseCleanupLock(c.EventKey())
b.mu.Lock()
delete(b.conns, c)
remaining := len(b.conns)
b.mu.Unlock()
b.logger.Printf("Consumer disconnected: pid=%d key=%s (remaining=%d)", c.PID(), c.EventKey(), remaining)
if remaining == 0 {
// Stop+drain before Reset (Go docs) to avoid a stale fire in .C.
if !b.idleTimer.Stop() {
select {
case <-b.idleTimer.C:
default:
}
}
b.idleTimer.Reset(idleTimeout)
}
})
b.mu.Lock()
b.conns[bc] = struct{}{}
// Stop+drain under mu so a fire can't slip past a fresh registration.
if !b.idleTimer.Stop() {
select {
case <-b.idleTimer.C:
default:
}
}
b.mu.Unlock()
ack := protocol.NewHelloAck("v1", firstForKey)
// writeFrame shares writeMu with every other write; bc.Close on failure unwinds hub+bus registration via onClose.
if err := bc.writeFrame(ack); err != nil {
b.logger.Printf("WARN: hello_ack write to pid=%d key=%q failed: %v (rejecting connection)",
hello.PID, hello.EventKey, err)
bc.Close()
return
}
// Quote untrusted fields to prevent log forging via embedded newlines.
b.logger.Printf("Consumer connected: pid=%d key=%q event_types=%q first=%v",
hello.PID, hello.EventKey, hello.EventTypes, firstForKey)
bc.Start()
}
// handleStatusQuery replies with status and closes.
func (b *Bus) handleStatusQuery(conn net.Conn) {
defer conn.Close()
resp := protocol.NewStatusResponse(
os.Getpid(),
int(time.Since(b.startTime).Seconds()),
b.hub.ConnCount(),
b.hub.Consumers(),
)
_ = protocol.EncodeWithDeadline(conn, resp, protocol.WriteTimeout)
}
// handleShutdown signals Run() to exit.
func (b *Bus) handleShutdown(conn net.Conn) {
defer conn.Close()
b.logger.Printf("Received shutdown command")
select {
case b.shutdownCh <- struct{}{}:
default:
}
}
const (
aliveLockMaxWait = 2 * time.Second
aliveLockPollInterval = 50 * time.Millisecond
)
// acquireAliveLock retries on ErrHeld so a stop-then-immediate-restart finds the lock free.
func acquireAliveLock(eventsDir string) (*busdiscover.Handle, error) {
deadline := time.Now().Add(aliveLockMaxWait)
for {
h, err := busdiscover.WritePIDFile(eventsDir, os.Getpid())
if err == nil {
return h, nil
}
if !errors.Is(err, lockfile.ErrHeld) || time.Now().After(deadline) {
return nil, err
}
time.Sleep(aliveLockPollInterval)
}
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package bus
import (
"io"
"log"
"net"
"testing"
"time"
)
// Reproduces Run × onClose re-entrant deadlock if b.mu is held across Close.
func TestRunShutdownWithMultipleConns(t *testing.T) {
logger := log.New(io.Discard, "", 0)
hub := NewHub()
b := &Bus{
hub: hub,
logger: logger,
conns: make(map[*Conn]struct{}),
}
const N = 3
pipes := make([]net.Conn, 0, N*2)
t.Cleanup(func() {
for _, p := range pipes {
p.Close()
}
})
for i := 0; i < N; i++ {
server, client := net.Pipe()
pipes = append(pipes, server, client)
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i)
bc.SetLogger(logger)
hub.RegisterAndIsFirst(bc)
bc.SetOnClose(func(c *Conn) {
b.hub.UnregisterAndIsLast(c)
b.mu.Lock()
delete(b.conns, c)
b.mu.Unlock()
})
b.mu.Lock()
b.conns[bc] = struct{}{}
b.mu.Unlock()
}
done := make(chan struct{})
go func() {
shutdownConns(b)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("shutdownConns deadlocked: did not complete within 2s")
}
if got := hub.ConnCount(); got != 0 {
t.Errorf("expected 0 subscribers in hub after shutdown, got %d", got)
}
b.mu.Lock()
remaining := len(b.conns)
b.mu.Unlock()
if remaining != 0 {
t.Errorf("expected 0 conns in Bus after shutdown, got %d", remaining)
}
}
// shutdownCh must be buffered so a signal sent before Run's select loop is still delivered.
func TestShutdownSignalNotDroppedBeforeRunSelects(t *testing.T) {
b := NewBus("test-app", "test-secret", "", nil, log.New(io.Discard, "", 0))
select {
case b.shutdownCh <- struct{}{}:
default:
t.Fatal("handleShutdown's send took default branch — signal would be lost")
}
select {
case <-b.shutdownCh:
case <-time.After(200 * time.Millisecond):
t.Fatal("shutdown signal was not latched")
}
}

199
internal/event/bus/conn.go Normal file
View File

@@ -0,0 +1,199 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package bus
import (
"bufio"
"bytes"
"log"
"net"
"sync"
"sync/atomic"
"time"
"github.com/larksuite/cli/internal/event/protocol"
)
const (
sendChCap = 100
writeTimeout = 5 * time.Second
)
// Conn represents a single consume client connection in the Bus.
type Conn struct {
conn net.Conn
reader *bufio.Reader
sendCh chan interface{}
sendMu sync.Mutex // serialises drop+push atomically
writeMu sync.Mutex // serialises all net.Conn writes (Encode+SetWriteDeadline is a 2-call sequence)
eventKey string
eventTypes []string
pid int
onClose func(*Conn)
checkLastForKey func(eventKey string) bool
logger *log.Logger
closed chan struct{}
closeOnce sync.Once
received atomic.Int64 // events fanned out to us (post-filter)
seqCounter atomic.Uint64 // per-conn monotonic seq assigned by Hub.Publish
dropped atomic.Int64 // events evicted via drop-oldest backpressure
}
// NewConn creates a Conn; pass a reader with pre-buffered bytes (handoff from Bus.handleConn) or nil for a fresh one.
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int) *Conn {
if reader == nil {
reader = bufio.NewReader(conn)
}
return &Conn{
conn: conn,
reader: reader,
sendCh: make(chan interface{}, sendChCap),
eventKey: eventKey,
eventTypes: eventTypes,
pid: pid,
closed: make(chan struct{}),
}
}
func (c *Conn) SetOnClose(fn func(*Conn)) { c.onClose = fn }
// SetCheckLastForKey: returning true means "you are the last subscriber, run cleanup".
func (c *Conn) SetCheckLastForKey(fn func(string) bool) { c.checkLastForKey = fn }
// SetLogger attaches a logger (nil tolerated).
func (c *Conn) SetLogger(l *log.Logger) { c.logger = l }
func (c *Conn) EventKey() string { return c.eventKey }
func (c *Conn) EventTypes() []string { return c.eventTypes }
func (c *Conn) SendCh() chan interface{} { return c.sendCh }
func (c *Conn) PID() int { return c.pid }
func (c *Conn) IncrementReceived() { c.received.Add(1) }
func (c *Conn) Received() int64 { return c.received.Load() }
// NextSeq returns the next monotonic seq for this conn (first call returns 1).
func (c *Conn) NextSeq() uint64 { return c.seqCounter.Add(1) }
func (c *Conn) DroppedCount() int64 { return c.dropped.Load() }
func (c *Conn) IncrementDropped() { c.dropped.Add(1) }
// Start launches the sender and reader goroutines; call exactly once.
func (c *Conn) Start() {
go c.SenderLoop()
go c.ReaderLoop()
}
// writeFrame is the sole write path, serialised via writeMu.
func (c *Conn) writeFrame(msg interface{}) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
if err := c.conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil {
return err
}
return protocol.Encode(c.conn, msg)
}
// SenderLoop exits on closed (not sendCh close) so Hub.Publish can send without panic risk.
func (c *Conn) SenderLoop() {
for {
select {
case <-c.closed:
return
case msg := <-c.sendCh:
if err := c.writeFrame(msg); err != nil {
if c.logger != nil {
c.logger.Printf("WARN: write to pid=%d failed: %v", c.pid, err)
}
c.shutdown()
return
}
}
}
}
// ReaderLoop reads control messages (Bye, PreShutdownCheck) until EOF.
func (c *Conn) ReaderLoop() {
for {
line, err := protocol.ReadFrame(c.reader)
if err != nil {
break
}
line = bytes.TrimRight(line, "\n")
if len(line) == 0 {
continue
}
msg, err := protocol.Decode(line)
if err != nil {
continue
}
c.handleControlMessage(msg)
}
c.shutdown()
}
func (c *Conn) handleControlMessage(msg interface{}) {
switch m := msg.(type) {
case *protocol.Bye:
c.shutdown()
case *protocol.PreShutdownCheck:
lastForKey := true
if c.checkLastForKey != nil {
lastForKey = c.checkLastForKey(m.EventKey)
}
ack := protocol.NewPreShutdownAck(lastForKey)
if err := c.writeFrame(ack); err != nil && c.logger != nil {
c.logger.Printf("WARN: pre_shutdown_ack to pid=%d failed: %v", c.pid, err)
}
}
}
func (c *Conn) shutdown() {
c.closeOnce.Do(func() {
close(c.closed)
c.conn.Close()
// sendCh is NOT closed: would race with Hub.Publish holding SendCh() after RUnlock.
if c.onClose != nil {
c.onClose(c)
}
})
}
// TrySend enqueues non-evictively under sendMu so it respects PushDropOldest's atomicity contract.
func (c *Conn) TrySend(msg interface{}) bool {
c.sendMu.Lock()
defer c.sendMu.Unlock()
select {
case c.sendCh <- msg:
return true
default:
return false
}
}
// PushDropOldest enqueues msg; on full channel evicts one oldest and retries, atomically under sendMu.
// Returns (enqueued, dropped). A rare concurrent drain may make drop unnecessary — still succeeds with dropped=false.
func (c *Conn) PushDropOldest(msg interface{}) (enqueued, dropped bool) {
c.sendMu.Lock()
defer c.sendMu.Unlock()
select {
case c.sendCh <- msg:
return true, false
default:
}
select {
case <-c.sendCh:
dropped = true
default:
}
select {
case c.sendCh <- msg:
return true, dropped
default:
return false, dropped
}
}
// Close is idempotent.
func (c *Conn) Close() {
c.shutdown()
}

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package bus
import (
"bufio"
"bytes"
"io"
"net"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/internal/event/protocol"
)
func TestConn_SenderWritesEvents(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345)
go bc.SenderLoop()
bc.SendCh() <- &protocol.Event{
Type: protocol.MsgTypeEvent,
EventType: "im.message.receive_v1",
}
scanner := bufio.NewScanner(client)
client.SetReadDeadline(time.Now().Add(time.Second))
if !scanner.Scan() {
t.Fatalf("expected to read a line: %v", scanner.Err())
}
line := scanner.Bytes()
if !bytes.Contains(line, []byte(`"event"`)) {
t.Errorf("unexpected line: %s", line)
}
}
type serializingDetector struct {
net.Conn
inFlight atomic.Int32
violated atomic.Bool
}
func (s *serializingDetector) Write(b []byte) (int, error) {
if s.inFlight.Add(1) > 1 {
s.violated.Store(true)
}
time.Sleep(500 * time.Microsecond)
defer s.inFlight.Add(-1)
return s.Conn.Write(b)
}
// Two goroutines writing frames (event + ack) must not overlap on the underlying net.Conn.
func TestConn_ConcurrentWritesSerialised(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
det := &serializingDetector{Conn: server}
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345)
go func() { _, _ = io.Copy(io.Discard, client) }()
go bc.SenderLoop()
var wg sync.WaitGroup
const workers = 8
const perWorker = 20
deadline := time.Now().Add(2 * time.Second)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < perWorker && time.Now().Before(deadline); j++ {
bc.SendCh() <- &protocol.Event{Type: protocol.MsgTypeEvent, EventType: "im.msg"}
}
}()
}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < perWorker && time.Now().Before(deadline); j++ {
bc.handleControlMessage(&protocol.PreShutdownCheck{EventKey: "im.msg"})
}
}()
}
wg.Wait()
bc.Close()
if det.violated.Load() {
t.Error("concurrent Write on net.Conn detected: SenderLoop and handleControlMessage " +
"overlapped without serialisation (framing / deadline race)")
}
}
func TestConn_TrySend_NonEvicting(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
for i := 0; i < sendChCap; i++ {
if !bc.TrySend(i) {
t.Fatalf("TrySend returned false at iteration %d; expected all sendChCap (%d) to fit", i, sendChCap)
}
}
if bc.TrySend("overflow") {
t.Fatal("TrySend on full channel returned true: TrySend must be non-evicting")
}
first := <-bc.SendCh()
if first != 0 {
t.Errorf("first drained item = %v, want 0", first)
}
}
func TestConn_ReaderDetectsEOF(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
done := make(chan struct{})
go func() {
bc.ReaderLoop()
close(done)
}()
client.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("ReaderLoop did not exit on EOF")
}
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package bus
import (
"bufio"
"io"
"log"
"net"
"testing"
"time"
"github.com/larksuite/cli/internal/event/protocol"
)
// HelloAck write failure must unregister the conn from hub and bus before returning.
func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) {
logger := log.New(io.Discard, "", 0)
hub := NewHub()
b := &Bus{
hub: hub,
logger: logger,
conns: make(map[*Conn]struct{}),
idleTimer: time.NewTimer(30 * time.Second),
shutdownCh: make(chan struct{}, 1),
}
server, client := net.Pipe()
client.Close()
defer server.Close()
hello := &protocol.Hello{
PID: 9999,
EventKey: "im.msg",
EventTypes: []string{"im.message.receive_v1"},
}
br := bufio.NewReader(server)
done := make(chan struct{})
go func() {
b.handleHello(server, br, hello)
close(done)
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("handleHello did not return within 3s: stuck on write or not handling the error path")
}
if got := hub.ConnCount(); got != 0 {
t.Errorf("hub.ConnCount after failed HelloAck = %d, want 0 (connection must be unregistered)", got)
}
if got := hub.EventKeyCount("im.msg"); got != 0 {
t.Errorf("hub.EventKeyCount(im.msg) after failed HelloAck = %d, want 0", got)
}
b.mu.Lock()
remaining := len(b.conns)
b.mu.Unlock()
if remaining != 0 {
t.Errorf("b.conns after failed HelloAck = %d entries, want 0", remaining)
}
}

215
internal/event/bus/hub.go Normal file
View File

@@ -0,0 +1,215 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package bus
import (
"fmt"
"log"
"sync"
"sync/atomic"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
)
// Subscriber is the interface a connection must satisfy for Hub registration.
type Subscriber interface {
EventKey() string
EventTypes() []string
SendCh() chan interface{}
PID() int
IncrementReceived()
Received() int64
// PushDropOldest enqueues atomically with drop-oldest backpressure.
PushDropOldest(msg interface{}) (enqueued, dropped bool)
// TrySend is non-evictive but shares PushDropOldest's mutex.
TrySend(msg interface{}) bool
DroppedCount() int64
IncrementDropped()
// NextSeq returns a monotonic per-subscriber seq; tests may return 0.
NextSeq() uint64
}
type Hub struct {
mu sync.RWMutex
subscribers map[Subscriber]struct{}
keyCounts map[string]int
// cleanupInProgress[key] holds a channel closed on release; presence means a cleanup lock is held.
cleanupInProgress map[string]chan struct{}
logger atomic.Pointer[log.Logger]
}
func NewHub() *Hub {
return &Hub{
subscribers: make(map[Subscriber]struct{}),
keyCounts: make(map[string]int),
cleanupInProgress: make(map[string]chan struct{}),
}
}
// SetLogger attaches a logger (nil tolerated).
func (h *Hub) SetLogger(l *log.Logger) { h.logger.Store(l) }
// UnregisterAndIsLast removes s and reports whether it was last for its EventKey; stale unregisters are no-ops.
func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
h.mu.Lock()
defer h.mu.Unlock()
if _, registered := h.subscribers[s]; !registered {
return false
}
delete(h.subscribers, s)
h.keyCounts[s.EventKey()]--
isLast := h.keyCounts[s.EventKey()] == 0
if isLast {
delete(h.keyCounts, s.EventKey())
}
return isLast
}
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for eventKey and no lock is held.
// Count==0 is rejected (would block future Register calls). On true return, caller MUST Release.
func (h *Hub) AcquireCleanupLock(eventKey string) bool {
h.mu.Lock()
defer h.mu.Unlock()
if h.keyCounts[eventKey] != 1 {
return false
}
if _, alreadyLocked := h.cleanupInProgress[eventKey]; alreadyLocked {
return false
}
h.cleanupInProgress[eventKey] = make(chan struct{})
return true
}
// ReleaseCleanupLock is idempotent; OnClose calls unconditionally.
func (h *Hub) ReleaseCleanupLock(eventKey string) {
h.mu.Lock()
ch := h.cleanupInProgress[eventKey]
delete(h.cleanupInProgress, eventKey)
h.mu.Unlock()
if ch != nil {
close(ch)
}
}
// RegisterAndIsFirst adds s to the hub and reports whether it's the first
// subscriber for its EventKey. If a cleanup is in progress for
// s.EventKey() (another conn holds the cleanup lock), this waits until
// cleanup releases before registering — closing the PreShutdownCheck ×
// Hello TOCTOU race. The wait releases h.mu before blocking on the
// channel, so concurrent operations on other keys aren't stalled.
func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
for {
h.mu.Lock()
ch, locked := h.cleanupInProgress[s.EventKey()]
if locked {
h.mu.Unlock()
<-ch // wait for release, then re-check (defensive against races)
continue
}
isFirst := h.keyCounts[s.EventKey()] == 0
h.subscribers[s] = struct{}{}
h.keyCounts[s.EventKey()]++
h.mu.Unlock()
return isFirst
}
}
// Publish fans out a RawEvent to all matching subscribers (non-blocking).
//
// A fresh *protocol.Event is allocated per subscriber so each consumer sees
// its own monotonically-increasing Seq (assigned via Conn.NextSeq) — sharing
// a single msg struct across subscribers would alias Seq and defeat the
// gap-detection at the consume side. The extra allocation per fan-out is
// cheap compared to the socket write that follows.
func (h *Hub) Publish(raw *event.RawEvent) {
h.mu.RLock()
matches := make([]Subscriber, 0, len(h.subscribers))
for s := range h.subscribers {
for _, et := range s.EventTypes() {
if et == raw.EventType {
matches = append(matches, s)
break
}
}
}
h.mu.RUnlock()
// Resolve source time once per Publish (not per subscriber) — same value
// across the fan-out. Prefer the upstream header create_time
// (raw.SourceTime) over the local arrival timestamp so consumers see
// original publisher intent; fall back to Timestamp when SourceTime
// wasn't populated (e.g. test-only sources, pre-4.4 RawEvent producers).
sourceTime := raw.SourceTime
if sourceTime == "" && !raw.Timestamp.IsZero() {
sourceTime = fmt.Sprintf("%d", raw.Timestamp.UnixMilli())
}
for _, s := range matches {
msg := protocol.NewEvent(
raw.EventType,
raw.EventID,
sourceTime,
s.NextSeq(),
raw.Payload,
)
enqueued, dropped := s.PushDropOldest(msg)
if dropped {
s.IncrementDropped()
if lg := h.logger.Load(); lg != nil {
lg.Printf("WARN: backpressure on conn pid=%d event_key=%s dropped_total=%d",
s.PID(), s.EventKey(), s.DroppedCount())
}
}
if enqueued {
s.IncrementReceived()
}
}
}
// ConnCount returns the current number of registered subscribers.
func (h *Hub) ConnCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.subscribers)
}
// EventKeyCount returns the number of subscribers registered for eventKey.
func (h *Hub) EventKeyCount(eventKey string) int {
h.mu.RLock()
defer h.mu.RUnlock()
return h.keyCounts[eventKey]
}
// BroadcastSourceStatus fans out a source-level status change to every
// subscriber. Best-effort: channel full → drop silently (status isn't
// worth applying back-pressure for). Routes through Subscriber.TrySend
// so the send shares PushDropOldest's sendMu — without this a status
// broadcast could slip into the tiny window between another
// goroutine's drop and its retry push and break the atomicity contract.
func (h *Hub) BroadcastSourceStatus(source, state, detail string) {
msg := protocol.NewSourceStatus(source, state, detail)
h.mu.RLock()
defer h.mu.RUnlock()
for s := range h.subscribers {
s.TrySend(msg)
}
}
// Consumers returns info about all connected consumers.
func (h *Hub) Consumers() []protocol.ConsumerInfo {
h.mu.RLock()
defer h.mu.RUnlock()
result := make([]protocol.ConsumerInfo, 0, len(h.subscribers))
for s := range h.subscribers {
result = append(result, protocol.ConsumerInfo{
PID: s.PID(),
EventKey: s.EventKey(),
Received: s.Received(),
Dropped: s.DroppedCount(),
})
}
return result
}

View File

@@ -0,0 +1,134 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package bus
import (
"net"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
)
func TestHubDroppedCountIncrements(t *testing.T) {
h := NewHub()
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c)
h.Publish(&event.RawEvent{EventType: "t"})
h.Publish(&event.RawEvent{EventType: "t"})
h.Publish(&event.RawEvent{EventType: "t"})
if got := c.DroppedCount(); got != 2 {
t.Errorf("expected 2 drops, got %d", got)
}
}
func TestPublishAssignsIncrementalSeq(t *testing.T) {
h := NewHub()
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c.sendCh = make(chan interface{}, 10)
h.RegisterAndIsFirst(c)
for i := 0; i < 5; i++ {
h.Publish(&event.RawEvent{EventType: "t"})
}
for i := uint64(1); i <= 5; i++ {
msg := <-c.SendCh()
ev, ok := msg.(*protocol.Event)
if !ok {
t.Fatalf("iter %d: expected *protocol.Event, got %T", i, msg)
}
if ev.Seq != i {
t.Errorf("iter %d: expected seq %d, got %d", i, i, ev.Seq)
}
}
}
func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) {
h := NewHub()
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c)
const eid = "test-event-id-123"
h.Publish(&event.RawEvent{
EventID: eid,
EventType: "t",
Timestamp: time.UnixMilli(1234567890123),
})
msg := <-c.SendCh()
ev := msg.(*protocol.Event)
if ev.EventID != eid {
t.Errorf("expected EventID %q, got %q", eid, ev.EventID)
}
if ev.SourceTime != "1234567890123" {
t.Errorf("expected SourceTime \"1234567890123\", got %q", ev.SourceTime)
}
}
// Explicit SourceTime (upstream header.create_time) must win over local Timestamp.
func TestPublishSourceTimeTakesPrecedence(t *testing.T) {
h := NewHub()
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c)
const upstreamTs = "1700000000000"
h.Publish(&event.RawEvent{
EventID: "evt-1",
EventType: "t",
SourceTime: upstreamTs,
Timestamp: time.UnixMilli(1999999999999),
})
msg := <-c.SendCh()
ev := msg.(*protocol.Event)
if ev.SourceTime != upstreamTs {
t.Errorf("SourceTime: got %q, want %q", ev.SourceTime, upstreamTs)
}
}
func TestPublishSourceTimeFallback(t *testing.T) {
h := NewHub()
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c)
h.Publish(&event.RawEvent{
EventID: "evt-2",
EventType: "t",
Timestamp: time.UnixMilli(42),
})
msg := <-c.SendCh()
ev := msg.(*protocol.Event)
if ev.SourceTime != "42" {
t.Errorf("SourceTime fallback: got %q, want %q", ev.SourceTime, "42")
}
}
func testNetPipe(t *testing.T) (net.Conn, net.Conn) {
t.Helper()
return net.Pipe()
}

View File

@@ -0,0 +1,198 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package bus
import (
"encoding/json"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
// Under concurrent Publish with a tiny channel, Received must equal actual enqueues (sendMu + enqueued gate).
func TestPublishRaceBookkeepingAccurate(t *testing.T) {
h := NewHub()
sub := newRaceSubscriber("race.key", []string{"race.type"}, 2)
h.RegisterAndIsFirst(sub)
const publishers = 50
const perPublisher = 500
const N = publishers * perPublisher
var wg sync.WaitGroup
for i := 0; i < publishers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < perPublisher; j++ {
h.Publish(&event.RawEvent{
EventType: "race.type",
Payload: json.RawMessage(`{}`),
})
}
}()
}
const trySenders = 20
for i := 0; i < trySenders; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < perPublisher; j++ {
sub.TrySend("source-status")
}
}()
}
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("publishers did not complete in 10s")
}
received := sub.Received()
enqueued := atomic.LoadInt64(&sub.actualEnqueued)
returnedFalse := atomic.LoadInt64(&sub.returnedFalse)
if received != enqueued {
t.Errorf("counter drift: Received=%d actual_enqueued=%d (diff=%d)",
received, enqueued, received-enqueued)
}
if received > int64(N) {
t.Errorf("Received=%d > N=%d", received, N)
}
if returnedFalse > 0 {
t.Errorf("PushDropOldest returned enqueued=false %d times — sendMu missing or broken",
returnedFalse)
}
totalPublishes := int64(N)
if enqueued+returnedFalse != totalPublishes {
t.Errorf("publish accounting drift: enqueued=%d + returnedFalse=%d != total=%d",
enqueued, returnedFalse, totalPublishes)
}
}
// Hub.Publish must gate IncrementReceived on enqueued=true.
func TestPublishDoesNotIncrementWhenPushDropOldestFails(t *testing.T) {
h := NewHub()
sub := &alwaysFailSubscriber{
eventKey: "fail.key",
eventTypes: []string{"fail.type"},
sendCh: make(chan interface{}, 1),
}
h.RegisterAndIsFirst(sub)
for i := 0; i < 100; i++ {
h.Publish(&event.RawEvent{
EventType: "fail.type",
Payload: json.RawMessage(`{}`),
})
}
if got := sub.Received(); got != 0 {
t.Errorf("Received=%d after 100 Publishes that all failed to enqueue", got)
}
}
type alwaysFailSubscriber struct {
eventKey string
eventTypes []string
sendCh chan interface{}
received atomic.Int64
dropped atomic.Int64
}
func (s *alwaysFailSubscriber) EventKey() string { return s.eventKey }
func (s *alwaysFailSubscriber) EventTypes() []string { return s.eventTypes }
func (s *alwaysFailSubscriber) SendCh() chan interface{} { return s.sendCh }
func (s *alwaysFailSubscriber) PID() int { return 0 }
func (s *alwaysFailSubscriber) IncrementReceived() { s.received.Add(1) }
func (s *alwaysFailSubscriber) Received() int64 { return s.received.Load() }
func (s *alwaysFailSubscriber) DroppedCount() int64 { return s.dropped.Load() }
func (s *alwaysFailSubscriber) IncrementDropped() { s.dropped.Add(1) }
func (s *alwaysFailSubscriber) NextSeq() uint64 { return 0 }
func (s *alwaysFailSubscriber) TrySend(msg interface{}) bool {
select {
case s.sendCh <- msg:
return true
default:
return false
}
}
func (s *alwaysFailSubscriber) PushDropOldest(msg interface{}) (enqueued, dropped bool) {
return false, false
}
type raceSubscriber struct {
eventKey string
eventTypes []string
sendCh chan interface{}
pid int
received atomic.Int64
actualEnqueued int64
returnedFalse int64
dropped atomic.Int64
sendMu sync.Mutex
}
func newRaceSubscriber(key string, types []string, capacity int) *raceSubscriber {
return &raceSubscriber{
eventKey: key,
eventTypes: types,
sendCh: make(chan interface{}, capacity),
pid: 1,
}
}
func (s *raceSubscriber) EventKey() string { return s.eventKey }
func (s *raceSubscriber) EventTypes() []string { return s.eventTypes }
func (s *raceSubscriber) SendCh() chan interface{} { return s.sendCh }
func (s *raceSubscriber) PID() int { return s.pid }
func (s *raceSubscriber) IncrementReceived() { s.received.Add(1) }
func (s *raceSubscriber) Received() int64 { return s.received.Load() }
func (s *raceSubscriber) DroppedCount() int64 { return s.dropped.Load() }
func (s *raceSubscriber) IncrementDropped() { s.dropped.Add(1) }
func (s *raceSubscriber) NextSeq() uint64 { return 0 }
func (s *raceSubscriber) TrySend(msg interface{}) bool {
s.sendMu.Lock()
defer s.sendMu.Unlock()
select {
case s.sendCh <- msg:
return true
default:
return false
}
}
func (s *raceSubscriber) PushDropOldest(msg interface{}) (enqueued, dropped bool) {
s.sendMu.Lock()
defer s.sendMu.Unlock()
select {
case s.sendCh <- msg:
atomic.AddInt64(&s.actualEnqueued, 1)
return true, false
default:
}
select {
case <-s.sendCh:
dropped = true
default:
}
select {
case s.sendCh <- msg:
atomic.AddInt64(&s.actualEnqueued, 1)
return true, dropped
default:
atomic.AddInt64(&s.returnedFalse, 1)
return false, dropped
}
}

View File

@@ -0,0 +1,277 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package bus
import (
"encoding/json"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
)
func TestHub_Subscribe(t *testing.T) {
h := NewHub()
c := newTestConn("mail.user_mailbox.event.message_received_v1", []string{"mail.event.v1"})
h.RegisterAndIsFirst(c)
if h.ConnCount() != 1 {
t.Errorf("expected 1 conn, got %d", h.ConnCount())
}
}
func TestHub_Publish_RoutesToSubscriber(t *testing.T) {
h := NewHub()
c := newTestConn("im.msg", []string{"im.message.receive_v1"})
h.RegisterAndIsFirst(c)
raw := &event.RawEvent{
EventID: "evt-1",
EventType: "im.message.receive_v1",
Payload: json.RawMessage(`{}`),
}
h.Publish(raw)
select {
case msg := <-c.sendCh:
evt, ok := msg.(*protocol.Event)
if !ok {
t.Fatalf("expected *Event, got %T", msg)
}
if evt.EventType != "im.message.receive_v1" {
t.Errorf("got event_type %q", evt.EventType)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timeout waiting for event")
}
}
func TestHub_Publish_SkipsUnmatchedSubscriber(t *testing.T) {
h := NewHub()
c := newTestConn("mail.new", []string{"mail.event.v1"})
h.RegisterAndIsFirst(c)
raw := &event.RawEvent{
EventID: "evt-1",
EventType: "im.message.receive_v1",
Payload: json.RawMessage(`{}`),
}
h.Publish(raw)
select {
case <-c.sendCh:
t.Fatal("should not receive unmatched event")
case <-time.After(50 * time.Millisecond):
}
}
func TestHub_Publish_NonBlocking(t *testing.T) {
h := NewHub()
c := newTestConn("im", []string{"im.message.receive_v1"})
c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c)
c.sendCh <- &protocol.Event{}
done := make(chan struct{})
go func() {
raw := &event.RawEvent{
EventType: "im.message.receive_v1",
Payload: json.RawMessage(`{}`),
}
h.Publish(raw)
close(done)
}()
select {
case <-done:
case <-time.After(100 * time.Millisecond):
t.Fatal("Publish blocked on full channel")
}
}
func TestHub_Unregister(t *testing.T) {
h := NewHub()
c := newTestConn("im", []string{"im.msg"})
h.RegisterAndIsFirst(c)
h.UnregisterAndIsLast(c)
if h.ConnCount() != 0 {
t.Errorf("expected 0 conns, got %d", h.ConnCount())
}
}
func TestHub_UnregisterAndIsLast_NeverRegistered(t *testing.T) {
h := NewHub()
real := newTestConn("im", []string{"im.msg"})
h.RegisterAndIsFirst(real)
ghost := newTestConn("im", []string{"im.msg"})
if h.UnregisterAndIsLast(ghost) {
t.Error("ghost unregister returned true: must be false when subscriber never registered")
}
if got := h.EventKeyCount("im"); got != 1 {
t.Errorf("keyCount for 'im' = %d after ghost unregister; want 1 (real still registered)", got)
}
if !h.UnregisterAndIsLast(real) {
t.Error("real unregister returned false; expected true (sole subscriber)")
}
}
func TestHub_UnregisterAndIsLast_DoubleUnregister(t *testing.T) {
h := NewHub()
c := newTestConn("im", []string{"im.msg"})
h.RegisterAndIsFirst(c)
if !h.UnregisterAndIsLast(c) {
t.Fatal("first unregister returned false; expected true (sole subscriber)")
}
if h.UnregisterAndIsLast(c) {
t.Error("second unregister returned true: duplicate unregister must report false")
}
}
func TestHub_EventKeyCount(t *testing.T) {
h := NewHub()
c1 := newTestConn("mail.user_mailbox.event.message_received_v1", []string{"mail.v1"})
c2 := newTestConn("mail.user_mailbox.event.message_received_v1", []string{"mail.v1"})
h.RegisterAndIsFirst(c1)
h.RegisterAndIsFirst(c2)
if h.EventKeyCount("mail.user_mailbox.event.message_received_v1") != 2 {
t.Errorf("expected 2, got %d", h.EventKeyCount("mail.user_mailbox.event.message_received_v1"))
}
h.UnregisterAndIsLast(c1)
if h.EventKeyCount("mail.user_mailbox.event.message_received_v1") != 1 {
t.Errorf("expected 1 after unregister, got %d", h.EventKeyCount("mail.user_mailbox.event.message_received_v1"))
}
}
func TestHub_RegisterAndIsFirst_Concurrent(t *testing.T) {
h := NewHub()
const N = 200
eventKey := "mail.user_mailbox.event.message_received_v1"
var firstCount int32
var wg sync.WaitGroup
wg.Add(N)
start := make(chan struct{})
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
<-start
c := newTestConn(eventKey, []string{"mail.v1"})
if h.RegisterAndIsFirst(c) {
atomic.AddInt32(&firstCount, 1)
}
}()
}
close(start)
wg.Wait()
if got := atomic.LoadInt32(&firstCount); got != 1 {
t.Errorf("RegisterAndIsFirst returned true %d times across %d concurrent registrants; want exactly 1", got, N)
}
if got := h.EventKeyCount(eventKey); got != N {
t.Errorf("EventKeyCount = %d, want %d", got, N)
}
}
func TestHub_UnregisterAndIsLast_Concurrent(t *testing.T) {
h := NewHub()
const N = 200
eventKey := "im.message.receive_v1"
conns := make([]*testConn, N)
for i := 0; i < N; i++ {
conns[i] = newTestConn(eventKey, []string{"im.v1"})
h.RegisterAndIsFirst(conns[i])
}
var lastCount int32
var wg sync.WaitGroup
wg.Add(N)
start := make(chan struct{})
for i := 0; i < N; i++ {
c := conns[i]
go func() {
defer wg.Done()
<-start
if h.UnregisterAndIsLast(c) {
atomic.AddInt32(&lastCount, 1)
}
}()
}
close(start)
wg.Wait()
if got := atomic.LoadInt32(&lastCount); got != 1 {
t.Errorf("UnregisterAndIsLast returned true %d times; want exactly 1", got)
}
if got := h.EventKeyCount(eventKey); got != 0 {
t.Errorf("EventKeyCount after all unregister = %d, want 0", got)
}
}
type testConn struct {
eventKey string
eventTypes []string
sendCh chan interface{}
pid int
received atomic.Int64
}
func newTestConn(eventKey string, eventTypes []string) *testConn {
return &testConn{
eventKey: eventKey,
eventTypes: eventTypes,
sendCh: make(chan interface{}, 100),
pid: 1,
}
}
func (c *testConn) EventKey() string { return c.eventKey }
func (c *testConn) EventTypes() []string { return c.eventTypes }
func (c *testConn) SendCh() chan interface{} { return c.sendCh }
func (c *testConn) PID() int { return c.pid }
func (c *testConn) IncrementReceived() { c.received.Add(1) }
func (c *testConn) Received() int64 { return c.received.Load() }
func (c *testConn) DroppedCount() int64 { return 0 }
func (c *testConn) IncrementDropped() {}
func (c *testConn) NextSeq() uint64 { return 0 }
func (c *testConn) PushDropOldest(msg interface{}) (enqueued, dropped bool) {
select {
case c.sendCh <- msg:
return true, false
default:
}
select {
case <-c.sendCh:
dropped = true
default:
}
select {
case c.sendCh <- msg:
return true, dropped
default:
return false, dropped
}
}
func (c *testConn) TrySend(msg interface{}) bool {
select {
case c.sendCh <- msg:
return true
default:
return false
}
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package bus
import (
"testing"
"time"
)
// While a subscriber holds the cleanup lock for its key, Register for same key must block until release.
func TestConcurrentPreShutdownAndHelloRaceFree(t *testing.T) {
h := NewHub()
subA := newTestConn("mail.key", []string{"mail.receive"})
subA.pid = 1001
h.RegisterAndIsFirst(subA)
if !h.AcquireCleanupLock("mail.key") {
t.Fatal("A should acquire cleanup lock — it's the only subscriber")
}
subB := newTestConn("mail.key", []string{"mail.receive"})
subB.pid = 1002
registered := make(chan bool, 1)
go func() {
isFirst := h.RegisterAndIsFirst(subB)
registered <- isFirst
}()
select {
case <-registered:
t.Fatal("B registered DURING A's cleanup — TOCTOU race not fixed")
case <-time.After(200 * time.Millisecond):
}
h.ReleaseCleanupLock("mail.key")
select {
case isFirst := <-registered:
_ = isFirst
case <-time.After(500 * time.Millisecond):
t.Fatal("B never registered after cleanup released")
}
}
func TestAcquireCleanupLockRejectsIfMultipleSubscribers(t *testing.T) {
h := NewHub()
subA := newTestConn("shared.key", []string{"t"})
subA.pid = 1
subB := newTestConn("shared.key", []string{"t"})
subB.pid = 2
h.RegisterAndIsFirst(subA)
h.RegisterAndIsFirst(subB)
if h.AcquireCleanupLock("shared.key") {
t.Fatal("AcquireCleanupLock should reject when >1 subscribers exist")
}
}
func TestAcquireCleanupLockRejectsIfAlreadyLocked(t *testing.T) {
h := NewHub()
sub := newTestConn("exclusive.key", []string{"t"})
sub.pid = 1
h.RegisterAndIsFirst(sub)
if !h.AcquireCleanupLock("exclusive.key") {
t.Fatal("first acquire should succeed")
}
if h.AcquireCleanupLock("exclusive.key") {
t.Fatal("second acquire should fail — already locked")
}
h.ReleaseCleanupLock("exclusive.key")
if !h.AcquireCleanupLock("exclusive.key") {
t.Fatal("re-acquire after release should succeed")
}
}
func TestReleaseCleanupLockIsIdempotent(t *testing.T) {
h := NewHub()
h.ReleaseCleanupLock("never.locked.key")
h.ReleaseCleanupLock("never.locked.key")
}
func TestAcquireCleanupLockRejectsIfZeroSubscribers(t *testing.T) {
h := NewHub()
if h.AcquireCleanupLock("never.registered.key") {
t.Error("AcquireCleanupLock should reject for a never-registered key (count==0)")
}
sub := newTestConn("transient.key", []string{"t"})
sub.pid = 1
h.RegisterAndIsFirst(sub)
h.UnregisterAndIsLast(sub)
if h.AcquireCleanupLock("transient.key") {
t.Error("AcquireCleanupLock should reject after all subscribers have unregistered (count==0)")
}
}

40
internal/event/bus/log.go Normal file
View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package bus
import (
"log"
"os"
"path/filepath"
"github.com/larksuite/cli/internal/vfs"
)
const (
maxLogSize = 5 * 1024 * 1024 // 5 MB
logFileName = "bus.log"
logBackupName = "bus.log.1"
)
// SetupBusLogger writes to eventsDir/bus.log with one-shot size-based rotation at startup only.
func SetupBusLogger(eventsDir string) (*log.Logger, error) {
if err := vfs.MkdirAll(eventsDir, 0700); err != nil {
return nil, err
}
logPath := filepath.Join(eventsDir, logFileName)
backupPath := filepath.Join(eventsDir, logBackupName)
if info, err := vfs.Stat(logPath); err == nil && info.Size() > maxLogSize {
_ = vfs.Remove(backupPath)
_ = vfs.Rename(logPath, backupPath)
}
f, err := vfs.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return nil, err
}
return log.New(f, "", log.LstdFlags), nil
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package busctl is the wire-level control client for the event bus daemon.
package busctl
import (
"bufio"
"bytes"
"fmt"
"time"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/transport"
)
const readTimeout = 5 * time.Second // matches protocol.WriteTimeout
func QueryStatus(tr transport.IPC, appID string) (*protocol.StatusResponse, error) {
conn, err := tr.Dial(tr.Address(appID))
if err != nil {
return nil, err
}
defer conn.Close()
if err := protocol.EncodeWithDeadline(conn, protocol.NewStatusQuery(), protocol.WriteTimeout); err != nil {
return nil, err
}
if err := conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil {
return nil, err
}
line, err := protocol.ReadFrame(bufio.NewReader(conn))
if err != nil {
return nil, err
}
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
if err != nil {
return nil, err
}
resp, ok := msg.(*protocol.StatusResponse)
if !ok {
return nil, fmt.Errorf("unexpected response type from bus: %T", msg)
}
return resp, nil
}
// SendShutdown sends a Shutdown command; caller polls Dial to confirm exit.
func SendShutdown(tr transport.IPC, appID string) error {
conn, err := tr.Dial(tr.Address(appID))
if err != nil {
return err
}
defer conn.Close()
return protocol.EncodeWithDeadline(conn, protocol.NewShutdown(), protocol.WriteTimeout)
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package busdiscover enumerates live bus daemons via per-AppID PID files protected by a process-lifetime advisory lock.
package busdiscover
import (
"path/filepath"
"time"
"github.com/larksuite/cli/internal/core"
)
type Process struct {
PID int
AppID string
StartTime time.Time
}
type Scanner interface {
ScanBusProcesses() ([]Process, error)
}
func Default() Scanner {
return &fsScanner{eventsDir: filepath.Join(core.GetConfigDir(), "events")}
}
type fsScanner struct {
eventsDir string
}
func (s *fsScanner) ScanBusProcesses() ([]Process, error) {
return scanLiveBuses(s.eventsDir)
}

View File

@@ -0,0 +1,129 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package busdiscover
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/internal/lockfile"
"github.com/larksuite/cli/internal/vfs"
)
const (
pidFileName = "bus.pid"
aliveLockFileName = "bus.alive.lock"
)
// Handle keeps the lifetime lock fd alive; OS releases on process exit.
type Handle struct {
lock *lockfile.LockFile
}
// Release is for tests only; production lets process exit release the lock.
func (h *Handle) Release() error {
if h == nil || h.lock == nil {
return nil
}
return h.lock.Unlock()
}
// WritePIDFile takes the alive lock and atomically writes pid + RFC3339 start time.
// Returns lockfile.ErrHeld if another bus holds the lock.
func WritePIDFile(eventsDir string, pid int) (*Handle, error) {
if err := vfs.MkdirAll(eventsDir, 0700); err != nil {
return nil, fmt.Errorf("busdiscover: mkdir %s: %w", eventsDir, err)
}
lock := lockfile.New(filepath.Join(eventsDir, aliveLockFileName))
if err := lock.TryLock(); err != nil {
return nil, err
}
pidPath := filepath.Join(eventsDir, pidFileName)
tmpPath := pidPath + ".tmp"
payload := fmt.Sprintf("%d\n%s\n", pid, time.Now().UTC().Format(time.RFC3339))
if err := vfs.WriteFile(tmpPath, []byte(payload), 0600); err != nil {
_ = lock.Unlock()
return nil, fmt.Errorf("busdiscover: write pid tmp: %w", err)
}
if err := vfs.Rename(tmpPath, pidPath); err != nil {
_ = vfs.Remove(tmpPath)
_ = lock.Unlock()
return nil, fmt.Errorf("busdiscover: rename pid file: %w", err)
}
return &Handle{lock: lock}, nil
}
func readPIDFile(eventsDir string) (int, time.Time, error) {
pidPath := filepath.Join(eventsDir, pidFileName)
data, err := vfs.ReadFile(pidPath)
if err != nil {
return 0, time.Time{}, err
}
lines := strings.SplitN(strings.TrimSpace(string(data)), "\n", 2)
if len(lines) < 2 {
return 0, time.Time{}, fmt.Errorf("busdiscover: malformed pid file %s", pidPath)
}
pid, err := strconv.Atoi(strings.TrimSpace(lines[0]))
if err != nil {
return 0, time.Time{}, fmt.Errorf("busdiscover: malformed pid in %s: %w", pidPath, err)
}
startTime, err := time.Parse(time.RFC3339, strings.TrimSpace(lines[1]))
if err != nil {
return 0, time.Time{}, fmt.Errorf("busdiscover: malformed timestamp in %s: %w", pidPath, err)
}
return pid, startTime, nil
}
// isBusAlive: try-lock the alive file. ErrHeld = live holder; success = stale (release immediately).
func isBusAlive(appDir string) bool {
lockPath := filepath.Join(appDir, aliveLockFileName)
if _, err := vfs.Stat(lockPath); err != nil {
return false
}
probe := lockfile.New(lockPath)
err := probe.TryLock()
if errors.Is(err, lockfile.ErrHeld) {
return true
}
if err != nil {
fmt.Fprintf(os.Stderr, "[busdiscover] probe %s: %v\n", lockPath, err) //nolint:forbidigo // internal diagnostic; scanner has no IOStreams plumbing
return false
}
_ = probe.Unlock()
return false
}
func scanLiveBuses(eventsDir string) ([]Process, error) {
entries, err := vfs.ReadDir(eventsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("busdiscover: read events dir: %w", err)
}
var result []Process
for _, e := range entries {
if !e.IsDir() {
continue
}
appID := e.Name()
appDir := filepath.Join(eventsDir, appID)
if !isBusAlive(appDir) {
continue
}
pid, startTime, err := readPIDFile(appDir)
if err != nil {
fmt.Fprintf(os.Stderr, "[busdiscover] live bus at %s but pid file unreadable: %v\n", appDir, err) //nolint:forbidigo // internal diagnostic; scanner has no IOStreams plumbing
result = append(result, Process{PID: 0, AppID: appID})
continue
}
result = append(result, Process{PID: pid, AppID: appID, StartTime: startTime})
}
return result, nil
}

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package busdiscover
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/lockfile"
)
func TestWritePIDFile_WritesPIDAndTimestamp(t *testing.T) {
dir := t.TempDir()
h, err := WritePIDFile(dir, 4242)
if err != nil {
t.Fatalf("WritePIDFile: %v", err)
}
t.Cleanup(func() { _ = h.Release() })
data, err := os.ReadFile(filepath.Join(dir, "bus.pid"))
if err != nil {
t.Fatalf("read pid file: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d: %q", len(lines), string(data))
}
if lines[0] != "4242" {
t.Errorf("pid line = %q, want %q", lines[0], "4242")
}
ts, err := time.Parse(time.RFC3339, lines[1])
if err != nil {
t.Errorf("timestamp parse: %v (line: %q)", err, lines[1])
}
if time.Since(ts) > time.Minute {
t.Errorf("timestamp = %v, expected within last minute", ts)
}
}
func TestWritePIDFile_SecondCallReturnsErrHeld(t *testing.T) {
dir := t.TempDir()
h1, err := WritePIDFile(dir, 1111)
if err != nil {
t.Fatalf("first WritePIDFile: %v", err)
}
t.Cleanup(func() { _ = h1.Release() })
_, err = WritePIDFile(dir, 2222)
if !errors.Is(err, lockfile.ErrHeld) {
t.Errorf("second WritePIDFile err = %v, want lockfile.ErrHeld", err)
}
}
func TestWritePIDFile_ReleaseAllowsReacquire(t *testing.T) {
dir := t.TempDir()
h1, err := WritePIDFile(dir, 1111)
if err != nil {
t.Fatalf("first WritePIDFile: %v", err)
}
if err := h1.Release(); err != nil {
t.Fatalf("Release: %v", err)
}
h2, err := WritePIDFile(dir, 2222)
if err != nil {
t.Fatalf("re-acquire after Release: %v", err)
}
t.Cleanup(func() { _ = h2.Release() })
}
func TestScanLiveBuses_ReturnsLiveBusOnly(t *testing.T) {
root := t.TempDir()
liveDir := filepath.Join(root, "cli_live")
hLive, err := WritePIDFile(liveDir, 7777)
if err != nil {
t.Fatalf("WritePIDFile live: %v", err)
}
t.Cleanup(func() { _ = hLive.Release() })
deadDir := filepath.Join(root, "cli_dead")
hDead, err := WritePIDFile(deadDir, 8888)
if err != nil {
t.Fatalf("WritePIDFile dead: %v", err)
}
if err := hDead.Release(); err != nil {
t.Fatalf("Release dead: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "empty"), 0700); err != nil {
t.Fatalf("mkdir empty: %v", err)
}
procs, err := scanLiveBuses(root)
if err != nil {
t.Fatalf("scanLiveBuses: %v", err)
}
if len(procs) != 1 {
t.Fatalf("expected 1 live proc, got %d: %+v", len(procs), procs)
}
if procs[0].AppID != "cli_live" {
t.Errorf("AppID = %q, want %q", procs[0].AppID, "cli_live")
}
if procs[0].PID != 7777 {
t.Errorf("PID = %d, want 7777", procs[0].PID)
}
}
func TestScanLiveBuses_MissingDirIsNotError(t *testing.T) {
procs, err := scanLiveBuses(filepath.Join(t.TempDir(), "does-not-exist"))
if err != nil {
t.Errorf("err = %v, want nil", err)
}
if len(procs) != 0 {
t.Errorf("expected empty result, got %+v", procs)
}
}
func TestScanLiveBuses_LiveBusWithCorruptPIDFileSurfaced(t *testing.T) {
root := t.TempDir()
appDir := filepath.Join(root, "cli_corrupt")
if err := os.MkdirAll(appDir, 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
lock := lockfile.New(filepath.Join(appDir, aliveLockFileName))
if err := lock.TryLock(); err != nil {
t.Fatalf("TryLock: %v", err)
}
t.Cleanup(func() { _ = lock.Unlock() })
if err := os.WriteFile(filepath.Join(appDir, pidFileName), []byte("garbage"), 0600); err != nil {
t.Fatalf("write corrupt pid: %v", err)
}
procs, err := scanLiveBuses(root)
if err != nil {
t.Fatalf("scanLiveBuses: %v", err)
}
if len(procs) != 1 {
t.Fatalf("expected 1 entry (live bus surfaced anonymously), got %d: %+v", len(procs), procs)
}
if procs[0].AppID != "cli_corrupt" {
t.Errorf("AppID = %q, want %q", procs[0].AppID, "cli_corrupt")
}
if procs[0].PID != 0 {
t.Errorf("PID = %d, want 0 (anonymous)", procs[0].PID)
}
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"context"
"io"
"sync/atomic"
"testing"
"time"
)
func TestBoundedLoop_MaxEvents(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var emitted atomic.Int64
opts := Options{MaxEvents: 3, ErrOut: io.Discard}
for i := 0; i < 5; i++ {
emitted.Add(1)
stopNow := checkMaxEvents(opts, &emitted)
if (i + 1) >= 3 {
if !stopNow {
t.Fatalf("checkMaxEvents should return true at emit %d (max=3)", i+1)
}
} else {
if stopNow {
t.Fatalf("checkMaxEvents should not return true at emit %d (max=3)", i+1)
}
}
}
_ = ctx
}
func TestBoundedLoop_NoLimitWhenZero(t *testing.T) {
var emitted atomic.Int64
opts := Options{MaxEvents: 0, ErrOut: io.Discard}
for i := 0; i < 100; i++ {
emitted.Add(1)
if checkMaxEvents(opts, &emitted) {
t.Fatalf("checkMaxEvents should never return true when MaxEvents=0; returned true at emit %d", i+1)
}
}
}
func TestExitReason_Limit(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
opts := Options{MaxEvents: 5, Timeout: 0}
reason := exitReason(ctx, 5, opts)
if reason != "limit" {
t.Errorf("reason = %q, want \"limit\"", reason)
}
}
func TestExitReason_Timeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
time.Sleep(5 * time.Millisecond)
opts := Options{MaxEvents: 5, Timeout: 1 * time.Millisecond}
reason := exitReason(ctx, 0, opts)
if reason != "timeout" {
t.Errorf("reason = %q, want \"timeout\"", reason)
}
}
func TestExitReason_Signal(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
opts := Options{MaxEvents: 0, Timeout: 0}
reason := exitReason(ctx, 0, opts)
if reason != "signal" {
t.Errorf("reason = %q, want \"signal\"", reason)
}
}

View File

@@ -0,0 +1,227 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package consume drives the consume-side half of the events pipeline.
package consume
import (
"context"
"fmt"
"io"
"os"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/transport"
)
type Options struct {
EventKey string
Params map[string]string
JQExpr string
Quiet bool
OutputDir string
Runtime event.APIClient
Out io.Writer // nil falls back to os.Stdout
ErrOut io.Writer
RemoteAPIClient APIClient // nil disables remote-connection preflight
MaxEvents int // 0 = unlimited
Timeout time.Duration // 0 = no timeout
IsTTY bool
}
// Run ensures bus is up, performs hello handshake, runs PreConsume for first subscriber,
// enters the consume loop, and runs cleanup on exit if we were the last subscriber.
func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain string, opts Options) error {
errOut := opts.ErrOut
if errOut == nil {
errOut = os.Stderr //nolint:forbidigo // library-caller fallback
}
keyDef, ok := event.Lookup(opts.EventKey)
if !ok {
return fmt.Errorf("unknown EventKey: %s\nRun 'lark-cli event list' to see available keys", opts.EventKey)
}
if err := validateParams(keyDef, opts.Params); err != nil {
return err
}
// Validate jq before any side effects (bus daemon, PreConsume server-side subscriptions).
if opts.JQExpr != "" {
if _, err := CompileJQ(opts.JQExpr); err != nil {
return err
}
}
if opts.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
defer cancel()
}
if !opts.Quiet {
if profileName != "" {
fmt.Fprintf(errOut, "[event] consuming as %s (%s)\n", profileName, appID)
} else {
fmt.Fprintf(errOut, "[event] consuming as %s\n", appID)
}
}
conn, err := EnsureBus(ctx, tr, appID, profileName, domain, opts.RemoteAPIClient, errOut)
if err != nil {
return err
}
defer conn.Close()
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
if err != nil {
return fmt.Errorf("handshake failed: %w", err)
}
var cleanup func()
if ack.FirstForKey && keyDef.PreConsume != nil {
if !opts.Quiet {
fmt.Fprintf(errOut, "[event] running pre-consume setup...\n")
}
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
if err != nil {
return fmt.Errorf("pre-consume failed: %w", err)
}
}
lastForKey := false
var emitted atomic.Int64
startTime := time.Now()
// On panic, run cleanup unconditionally — leaking server state is worse than
// unsubscribing a still-live co-consumer (recoverable).
defer func() {
r := recover()
if cleanup != nil {
switch {
case r != nil:
fmt.Fprintf(errOut, "WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n", opts.EventKey)
cleanup()
case lastForKey:
if !opts.Quiet {
fmt.Fprintf(errOut, "[event] running cleanup...\n")
}
cleanup()
if !opts.Quiet {
fmt.Fprintf(errOut, "[event] cleanup done.\n")
}
}
}
if !opts.Quiet && r == nil {
reason := exitReason(ctx, emitted.Load(), opts)
fmt.Fprintf(errOut, "[event] exited — received %d event(s) in %s (reason: %s)\n",
emitted.Load(), truncateDuration(time.Since(startTime)), reason)
}
if r != nil {
panic(r)
}
}()
if !opts.Quiet {
fmt.Fprintln(errOut, listeningText(opts))
if !opts.IsTTY {
fmt.Fprintln(errOut, stopHintText())
}
}
writeReadyMarker(errOut, opts)
return consumeLoop(ctx, conn, br, keyDef, opts, &lastForKey, &emitted)
}
func truncateDuration(d time.Duration) time.Duration {
return d.Truncate(time.Second)
}
func validateParams(def *event.KeyDefinition, params map[string]string) error {
for _, p := range def.Params {
if _, ok := params[p.Name]; !ok && p.Default != "" {
params[p.Name] = p.Default
}
}
for _, p := range def.Params {
if p.Required {
if _, ok := params[p.Name]; !ok {
return fmt.Errorf("required param %q missing for EventKey %s. Run 'lark-cli event schema %s' for details",
p.Name, def.Key, def.Key)
}
}
}
known := make(map[string]bool, len(def.Params))
validNames := make([]string, 0, len(def.Params))
for _, p := range def.Params {
known[p.Name] = true
validNames = append(validNames, p.Name)
}
sort.Strings(validNames)
for k := range params {
if known[k] {
continue
}
if len(validNames) == 0 {
return fmt.Errorf("unknown param %q: EventKey %s accepts no params. Run 'lark-cli event schema %s' for details",
k, def.Key, def.Key)
}
return fmt.Errorf("unknown param %q for EventKey %s. valid params: %s. Run 'lark-cli event schema %s' for details",
k, def.Key, strings.Join(validNames, ", "), def.Key)
}
return nil
}
func checkMaxEvents(opts Options, emitted *atomic.Int64) bool {
if opts.MaxEvents <= 0 {
return false
}
return emitted.Load() >= int64(opts.MaxEvents)
}
func listeningText(opts Options) string {
base := fmt.Sprintf("[event] listening for events (key=%s)", opts.EventKey)
if opts.IsTTY {
return base + ", ctrl+c to stop"
}
switch {
case opts.MaxEvents > 0 && opts.Timeout > 0:
return fmt.Sprintf("%s; will exit after %d event(s) or %s timeout", base, opts.MaxEvents, opts.Timeout)
case opts.MaxEvents > 0:
return fmt.Sprintf("%s; will exit after %d event(s)", base, opts.MaxEvents)
case opts.Timeout > 0:
return fmt.Sprintf("%s; will exit after %s timeout", base, opts.Timeout)
default:
return base + "; send SIGTERM or close stdin to stop"
}
}
// exitReason: count-first; --max-events races --timeout via inner-vs-outer ctx, do not reorder.
func exitReason(ctx context.Context, emitted int64, opts Options) string {
if opts.MaxEvents > 0 && emitted >= int64(opts.MaxEvents) {
return "limit"
}
if ctx.Err() == context.DeadlineExceeded {
return "timeout"
}
return "signal"
}
func stopHintText() string {
return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " +
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
}
// writeReadyMarker emits the stable AI-facing "ready" contract line; do not add fields.
func writeReadyMarker(w io.Writer, opts Options) {
if opts.Quiet {
return
}
fmt.Fprintf(w, "[event] ready event_key=%s\n", opts.EventKey)
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"bufio"
"bytes"
"fmt"
"net"
"os"
"time"
"github.com/larksuite/cli/internal/event/protocol"
)
const helloAckTimeout = 5 * time.Second // symmetric with bus-side hello read deadline
// doHello returns a bufio.Reader holding any bytes already pulled off conn so events
// buffered with the ack in one TCP segment aren't dropped.
func doHello(conn net.Conn, eventKey string, eventTypes []string) (*protocol.HelloAck, *bufio.Reader, error) {
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1")
if err := protocol.EncodeWithDeadline(conn, hello, protocol.WriteTimeout); err != nil {
return nil, nil, err
}
if err := conn.SetReadDeadline(time.Now().Add(helloAckTimeout)); err != nil {
return nil, nil, fmt.Errorf("set hello_ack deadline: %w", err)
}
br := bufio.NewReader(conn)
line, err := protocol.ReadFrame(br)
if err != nil {
return nil, nil, fmt.Errorf("no hello_ack received: %w", err)
}
// best-effort clear; if the conn is already broken, the loop's first read will surface it
_ = conn.SetReadDeadline(time.Time{})
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
if err != nil {
return nil, nil, err
}
ack, ok := msg.(*protocol.HelloAck)
if !ok {
return nil, nil, fmt.Errorf("expected hello_ack, got %T", msg)
}
return ack, br, nil
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"net"
"testing"
"time"
)
// doHello must apply a read deadline on HelloAck so a wedged bus doesn't hang the consumer.
func TestDoHello_ReadDeadline(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
go func() {
buf := make([]byte, 4096)
for {
if _, err := server.Read(buf); err != nil {
return
}
}
}()
start := time.Now()
done := make(chan error, 1)
go func() {
_, _, err := doHello(client, "im.msg", []string{"im.msg"})
done <- err
}()
select {
case err := <-done:
elapsed := time.Since(start)
if err == nil {
t.Fatal("doHello returned nil error when server never replied; must fail with deadline-driven error")
}
if elapsed > helloAckTimeout+2*time.Second {
t.Errorf("doHello returned %v after %v; deadline should fire within ~%v", err, elapsed, helloAckTimeout)
}
case <-time.After(helloAckTimeout + 3*time.Second):
t.Fatal("doHello hung past deadline + 3s slack: read deadline is missing or not being honoured")
}
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"encoding/json"
"fmt"
"github.com/itchyny/gojq"
)
// CompileJQ compiles once for hot-path reuse; exported so callers can preflight before side effects.
func CompileJQ(expr string) (*gojq.Code, error) {
query, err := gojq.Parse(expr)
if err != nil {
return nil, fmt.Errorf("invalid jq expression: %w", err)
}
code, err := gojq.Compile(query)
if err != nil {
return nil, fmt.Errorf("jq compile error: %w", err)
}
return code, nil
}
// applyJQ returns (nil, nil) when the expression filters out the event (e.g. select).
func applyJQ(code *gojq.Code, data json.RawMessage) (json.RawMessage, error) {
var input interface{}
if err := json.Unmarshal(data, &input); err != nil {
return nil, fmt.Errorf("jq: unmarshal input: %w", err)
}
iter := code.Run(input)
v, ok := iter.Next()
if !ok {
return nil, nil
}
if err, isErr := v.(error); isErr {
return nil, fmt.Errorf("jq: %w", err)
}
result, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("jq: marshal result: %w", err)
}
return json.RawMessage(result), nil
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"bytes"
"testing"
"time"
)
func TestListeningText_TTY(t *testing.T) {
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: true})
want := "[event] listening for events (key=im.message.receive_v1), ctrl+c to stop"
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
}
func TestListeningText_NonTTY_Default(t *testing.T) {
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: false})
want := "[event] listening for events (key=im.message.receive_v1); send SIGTERM or close stdin to stop"
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
}
func TestListeningText_NonTTY_MaxEvents(t *testing.T) {
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: false, MaxEvents: 1})
want := "[event] listening for events (key=im.message.receive_v1); will exit after 1 event(s)"
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
}
func TestListeningText_NonTTY_Timeout(t *testing.T) {
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: false, Timeout: 30 * time.Second})
want := "[event] listening for events (key=im.message.receive_v1); will exit after 30s timeout"
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
}
func TestListeningText_NonTTY_MaxEventsAndTimeout(t *testing.T) {
got := listeningText(Options{EventKey: "im.message.receive_v1", IsTTY: false, MaxEvents: 1, Timeout: 30 * time.Second})
want := "[event] listening for events (key=im.message.receive_v1); will exit after 1 event(s) or 30s timeout"
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
}
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
func TestStopHintText_Content(t *testing.T) {
got := stopHintText()
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
for _, s := range mustContain {
if !bytes.Contains([]byte(got), []byte(s)) {
t.Errorf("stopHintText missing %q; got %q", s, got)
}
}
}
func TestReadyMarker_EmittedAfterListening(t *testing.T) {
var buf bytes.Buffer
writeReadyMarker(&buf, Options{EventKey: "im.message.receive_v1"})
got := buf.String()
want := "[event] ready event_key=im.message.receive_v1\n"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestReadyMarker_SuppressedWhenQuiet(t *testing.T) {
var buf bytes.Buffer
writeReadyMarker(&buf, Options{EventKey: "im.message.receive_v1", Quiet: true})
if buf.Len() != 0 {
t.Errorf("Quiet=true must suppress ready marker; got %q", buf.String())
}
}

View File

@@ -0,0 +1,256 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/itchyny/gojq"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
)
// consumeLoop reads events and dispatches to workers; cancels on terminal sink errors.
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, lastForKey *bool, emitted *atomic.Int64) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
sink, err := newSink(opts)
if err != nil {
return err
}
// Compile before worker goroutines start to avoid a data race on jqCode.
var jqCode *gojq.Code
if opts.JQExpr != "" {
jqCode, err = CompileJQ(opts.JQExpr)
if err != nil {
return err
}
}
bufSize := keyDef.BufferSize
if bufSize <= 0 {
bufSize = event.DefaultBufferSize
}
socketCh := make(chan *protocol.Event, bufSize)
// stopReader lets shutdown preempt the reader so PreShutdownCheck can reuse conn.
stopReader := make(chan struct{})
readerDone := make(chan struct{})
// ReadBytes (not Scanner) so mid-frame read deadlines don't drop buffered bytes.
go func() {
defer close(readerDone)
defer close(socketCh)
var buf []byte
var lastSeq uint64 // per-conn monotonic; gaps = bus drop-oldest backpressure
for {
select {
case <-stopReader:
return
default:
}
conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
chunk, err := br.ReadBytes('\n')
if len(chunk) > 0 {
// Cap accumulator: dribbling multi-MB lines past 200ms deadlines could grow buf unbounded.
if len(buf)+len(chunk) > protocol.MaxFrameBytes {
if !opts.Quiet {
fmt.Fprintf(opts.ErrOut,
"WARN: dropping oversized frame (>%d bytes) from bus\n", protocol.MaxFrameBytes)
}
buf = nil
continue
}
buf = append(buf, chunk...)
}
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
continue
}
return
}
line := buf
if n := len(line); n > 0 && line[n-1] == '\n' {
line = line[:n-1]
}
buf = nil
msg, decErr := protocol.Decode(line)
if decErr != nil {
continue
}
switch m := msg.(type) {
case *protocol.Event:
if lastSeq > 0 && m.Seq > 0 && m.Seq > lastSeq+1 {
gap := m.Seq - lastSeq - 1
if !opts.Quiet {
fmt.Fprintf(opts.ErrOut,
"WARN: event seq gap %d->%d, missed %d events (dropped by bus backpressure)\n",
lastSeq, m.Seq, gap)
}
}
// Only advance forward — concurrent publishers can deliver out-of-order.
if m.Seq > lastSeq {
lastSeq = m.Seq
}
select {
case socketCh <- m:
default:
// drop-oldest back-pressure
select {
case <-socketCh:
default:
}
select {
case socketCh <- m:
default:
}
if !opts.Quiet {
fmt.Fprintf(opts.ErrOut, "WARN: consume backpressure, dropped oldest event\n")
}
}
case *protocol.SourceStatus:
if !opts.Quiet {
if m.Detail != "" {
fmt.Fprintf(opts.ErrOut, "[source] %s: %s (%s)\n", m.Source, m.State, m.Detail)
} else {
fmt.Fprintf(opts.ErrOut, "[source] %s: %s\n", m.Source, m.State)
}
}
default:
// forward-compatible: ignore unknown message types
}
}
}()
workers := keyDef.Workers
if workers <= 0 {
workers = 1
}
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
for evt := range socketCh {
wrote, err := processAndOutput(ctx, keyDef, evt, opts, sink, jqCode)
if wrote {
emitted.Add(1)
// cancel inner ctx so shutdown goes through normal cleanup, not conn rip.
if checkMaxEvents(opts, emitted) {
cancel()
return
}
}
if err != nil {
if isTerminalSinkError(err) {
if !opts.Quiet {
fmt.Fprintf(opts.ErrOut, "consume: output pipe closed (%v), shutting down\n", err)
}
cancel()
return
}
if !opts.Quiet {
fmt.Fprintf(opts.ErrOut, "WARN: sink write failed, skipping event: %v\n", err)
}
}
}
}()
}
allDone := make(chan struct{})
go func() {
wg.Wait()
close(allDone)
}()
select {
case <-ctx.Done():
// Drain reader so PreShutdownCheck has exclusive conn.
close(stopReader)
<-readerDone
conn.SetReadDeadline(time.Time{})
*lastForKey = checkLastForKey(conn, opts.EventKey)
conn.Close()
case <-allDone:
// bus-side close; can't query, assume last
*lastForKey = true
}
wg.Wait()
return nil
}
// processAndOutput returns (wrote, err); err non-nil only for sink.Write failures.
func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) {
var result json.RawMessage
if keyDef.Process != nil {
raw := &event.RawEvent{
EventType: evt.EventType,
Payload: evt.Payload,
}
var err error
result, err = keyDef.Process(ctx, opts.Runtime, raw, opts.Params)
if err != nil {
if !opts.Quiet {
fmt.Fprintf(opts.ErrOut, "WARN: Process error: %v\n", err)
}
return false, nil
}
if result == nil {
return false, nil
}
} else {
result = evt.Payload
}
if jqCode != nil {
filtered, err := applyJQ(jqCode, result)
if err != nil {
if !opts.Quiet {
fmt.Fprintf(opts.ErrOut, "WARN: JQ error: %v\n", err)
}
return false, nil
}
if filtered == nil {
return false, nil
}
result = filtered
}
if err := sink.Write(result); err != nil {
return false, err
}
return true, nil
}
// isTerminalSinkError reports if the output channel is permanently broken (EPIPE/ErrClosed).
func isTerminalSinkError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, syscall.EPIPE) {
return true
}
if errors.Is(err, fs.ErrClosed) {
return true
}
return false
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
)
func TestCompileJQReportsErrorEarly(t *testing.T) {
_, err := CompileJQ("invalid{{{")
if err == nil {
t.Fatal("expected compile error for invalid jq expression")
}
msg := err.Error()
if !strings.Contains(msg, "compile") && !strings.Contains(msg, "parse") && !strings.Contains(msg, "invalid") {
t.Errorf("error should mention compile/parse/invalid, got: %v", err)
}
}
func TestCompileJQReturnsUsableCode(t *testing.T) {
code, err := CompileJQ(".foo")
if err != nil {
t.Fatal(err)
}
if code == nil {
t.Fatal("expected non-nil code")
}
input := json.RawMessage(`{"foo":"bar"}`)
result, err := applyJQ(code, input)
if err != nil {
t.Fatal(err)
}
if string(result) != `"bar"` {
t.Errorf("expected \"bar\", got %s", string(result))
}
}
func TestApplyJQReusesCompiledCode(t *testing.T) {
code, err := CompileJQ(".foo")
if err != nil {
t.Fatal(err)
}
data := json.RawMessage(`{"foo":"bar"}`)
for i := 0; i < 10000; i++ {
result, err := applyJQ(code, data)
if err != nil {
t.Fatalf("iteration %d: %v", i, err)
}
if string(result) != `"bar"` {
t.Fatalf("iteration %d: unexpected result %s", i, string(result))
}
}
}
func TestApplyJQFilterReturnsNilOnNoOutput(t *testing.T) {
code, err := CompileJQ(`select(.type == "match")`)
if err != nil {
t.Fatal(err)
}
result, err := applyJQ(code, json.RawMessage(`{"type":"nomatch"}`))
if err != nil {
t.Fatalf("should not error on filter-out: %v", err)
}
if result != nil {
t.Errorf("expected nil result for filtered-out event, got %s", string(result))
}
}
func TestApplyJQConcurrentSafe(t *testing.T) {
code, err := CompileJQ(".value")
if err != nil {
t.Fatal(err)
}
const goroutines = 32
const iterationsPerGoroutine = 1000
var wg sync.WaitGroup
errs := make(chan error, goroutines)
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func(gid int) {
defer wg.Done()
for i := 0; i < iterationsPerGoroutine; i++ {
input := json.RawMessage(fmt.Sprintf(`{"value":"goroutine-%d-iter-%d"}`, gid, i))
result, err := applyJQ(code, input)
if err != nil {
errs <- fmt.Errorf("goroutine %d iter %d: %w", gid, i, err)
return
}
expected := fmt.Sprintf(`"goroutine-%d-iter-%d"`, gid, i)
if string(result) != expected {
errs <- fmt.Errorf("goroutine %d iter %d: expected %s, got %s", gid, i, expected, string(result))
return
}
}
}(g)
}
wg.Wait()
close(errs)
for err := range errs {
t.Error(err)
}
}

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"bytes"
"fmt"
"io"
"strings"
"testing"
"github.com/larksuite/cli/internal/event/protocol"
)
// Mirrors the inline gap-detection logic from consumeLoop's reader; keep in sync with loop.go.
type seqGapDetector struct {
lastSeq uint64
errOut io.Writer
quiet bool
}
func (d *seqGapDetector) observe(m *protocol.Event) {
if d.lastSeq > 0 && m.Seq > 0 && m.Seq > d.lastSeq+1 {
gap := m.Seq - d.lastSeq - 1
if !d.quiet {
fmt.Fprintf(d.errOut, "WARN: event seq gap %d->%d, missed %d events (dropped by bus backpressure)\n",
d.lastSeq, m.Seq, gap)
}
}
// CRITICAL: only advance forward — concurrent Publishers may deliver Seq out-of-order.
if m.Seq > d.lastSeq {
d.lastSeq = m.Seq
}
}
func TestSeqGapDetectorNoWarningOnFirstEvent(t *testing.T) {
var buf bytes.Buffer
d := &seqGapDetector{errOut: &buf}
d.observe(&protocol.Event{Seq: 5})
if strings.Contains(buf.String(), "gap") {
t.Errorf("unexpected gap warning on first event: %s", buf.String())
}
}
func TestSeqGapDetectorNoWarningOnContiguous(t *testing.T) {
var buf bytes.Buffer
d := &seqGapDetector{errOut: &buf}
for i := uint64(1); i <= 10; i++ {
d.observe(&protocol.Event{Seq: i})
}
if buf.Len() > 0 {
t.Errorf("unexpected output on contiguous seqs: %s", buf.String())
}
}
func TestSeqGapDetectorWarnsOnActualGap(t *testing.T) {
var buf bytes.Buffer
d := &seqGapDetector{errOut: &buf}
d.observe(&protocol.Event{Seq: 1})
d.observe(&protocol.Event{Seq: 5})
out := buf.String()
if !strings.Contains(out, "gap 1->5") {
t.Errorf("expected 'gap 1->5' in output, got: %s", out)
}
if !strings.Contains(out, "missed 3 events") {
t.Errorf("expected 'missed 3 events' in output, got: %s", out)
}
}
func TestSeqGapDetectorHandlesOutOfOrderWithoutFalsePositive(t *testing.T) {
var buf bytes.Buffer
d := &seqGapDetector{errOut: &buf}
d.observe(&protocol.Event{Seq: 6})
d.observe(&protocol.Event{Seq: 5})
d.observe(&protocol.Event{Seq: 7})
if buf.Len() > 0 {
t.Errorf("unexpected warning for out-of-order (no actual gap): %s", buf.String())
}
}
func TestSeqGapDetectorQuietMode(t *testing.T) {
var buf bytes.Buffer
d := &seqGapDetector{errOut: &buf, quiet: true}
d.observe(&protocol.Event{Seq: 1})
d.observe(&protocol.Event{Seq: 10})
if buf.Len() > 0 {
t.Errorf("quiet mode should suppress warnings, got: %s", buf.String())
}
}
func TestSeqGapDetectorZeroSeqIgnored(t *testing.T) {
var buf bytes.Buffer
d := &seqGapDetector{errOut: &buf}
d.observe(&protocol.Event{Seq: 5})
d.observe(&protocol.Event{Seq: 0})
d.observe(&protocol.Event{Seq: 6})
if buf.Len() > 0 {
t.Errorf("unexpected warning across legacy zero-seq event: %s", buf.String())
}
if d.lastSeq != 6 {
t.Errorf("expected lastSeq=6 after legacy skip, got %d", d.lastSeq)
}
}

View File

@@ -0,0 +1,223 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
)
func echoKeyDef(key string) *event.KeyDefinition {
return &event.KeyDefinition{
Key: key,
EventType: key,
BufferSize: 32,
Workers: 1,
Process: func(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
return raw.Payload, nil
},
}
}
func busSide(t *testing.T, server net.Conn, events []*protocol.Event, ackLast bool) {
t.Helper()
for _, evt := range events {
if err := protocol.Encode(server, evt); err != nil {
return
}
}
br := bufio.NewReader(server)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
_ = server.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
line, err := protocol.ReadFrame(br)
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
continue
}
return
}
msg, decErr := protocol.Decode(bytes.TrimRight(line, "\n"))
if decErr != nil {
continue
}
if _, ok := msg.(*protocol.PreShutdownCheck); ok {
_ = protocol.Encode(server, protocol.NewPreShutdownAck(ackLast))
return
}
}
}
func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
events := []*protocol.Event{
protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"n":1}`)),
protocol.NewEvent("test.evt", "e2", "", 2, json.RawMessage(`{"n":2}`)),
}
go busSide(t, server, events, true)
var stdout bytes.Buffer
opts := Options{
EventKey: "test.key",
Out: &stdout,
ErrOut: io.Discard,
Quiet: true,
MaxEvents: 2,
}
var lastForKey bool
var emitted atomic.Int64
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
if err != nil {
t.Fatalf("consumeLoop: %v", err)
}
if got := emitted.Load(); got != 2 {
t.Errorf("emitted = %d, want 2", got)
}
if !lastForKey {
t.Error("lastForKey = false, want true (bus acked LastForKey=true)")
}
out := stdout.String()
for _, want := range []string{`{"n":1}`, `{"n":2}`} {
if !strings.Contains(out, want) {
t.Errorf("stdout missing %q; full:\n%s", want, out)
}
}
}
func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
events := []*protocol.Event{
protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"n":1}`)),
protocol.NewEvent("test.evt", "e5", "", 5, json.RawMessage(`{"n":5}`)),
}
go busSide(t, server, events, true)
var stdout, stderr bytes.Buffer
opts := Options{
EventKey: "test.key",
Out: &stdout,
ErrOut: &stderr,
Quiet: false,
MaxEvents: 2,
}
var lastForKey bool
var emitted atomic.Int64
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
t.Fatalf("consumeLoop: %v", err)
}
if got := emitted.Load(); got != 2 {
t.Errorf("emitted = %d, want 2", got)
}
if !strings.Contains(stderr.String(), "WARN: event seq gap 1->5") {
t.Errorf("stderr missing seq-gap warning; got:\n%s", stderr.String())
}
}
func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
events := []*protocol.Event{
protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"keep":true,"n":1}`)),
protocol.NewEvent("test.evt", "e2", "", 2, json.RawMessage(`{"keep":false,"n":2}`)),
}
go busSide(t, server, events, true)
var stdout bytes.Buffer
opts := Options{
EventKey: "test.key",
Out: &stdout,
ErrOut: io.Discard,
Quiet: true,
JQExpr: "select(.keep) | .n",
MaxEvents: 1,
}
var lastForKey bool
var emitted atomic.Int64
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
t.Fatalf("consumeLoop: %v", err)
}
if got := emitted.Load(); got != 1 {
t.Errorf("emitted = %d, want 1", got)
}
out := strings.TrimSpace(stdout.String())
if out != "1" {
t.Errorf("stdout = %q, want %q", out, "1")
}
}
func TestConsumeLoop_CompileJQFailsEarly(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
opts := Options{
EventKey: "test.key",
Out: io.Discard,
ErrOut: io.Discard,
Quiet: true,
JQExpr: "not a valid jq expression (((",
}
var lastForKey bool
var emitted atomic.Int64
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
if err == nil {
t.Fatal("consumeLoop should fail immediately on bad jq expression")
}
}
func TestIsTerminalSinkError(t *testing.T) {
for _, tc := range []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"EPIPE raw", syscall.EPIPE, true},
{"EPIPE wrapped", fmt.Errorf("write: %w", syscall.EPIPE), true},
{"ErrClosed", io.ErrClosedPipe, false},
{"transient disk full", errors.New("no space left on device"), false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := isTerminalSinkError(tc.err); got != tc.want {
t.Errorf("isTerminalSinkError(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/event"
)
type APIClient = event.APIClient
// CheckRemoteConnections returns the count of active WebSocket connections for this app.
func CheckRemoteConnections(ctx context.Context, client APIClient) (int, error) {
raw, err := client.CallAPI(ctx, "GET", "/open-apis/event/v1/connection", nil)
if err != nil {
return 0, fmt.Errorf("connection check: %w", err)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
OnlineInstanceCnt int `json:"online_instance_cnt"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return 0, fmt.Errorf("connection check: decode: %w (body=%s)", err, truncateForError(raw))
}
// Distinguish "verified zero" from "check failed" — non-zero code decodes Cnt=0.
if result.Code != 0 {
return 0, fmt.Errorf("connection check: api error code=%d msg=%q", result.Code, result.Msg)
}
return result.Data.OnlineInstanceCnt, nil
}
// truncateForError bounds length and collapses control chars to defang log injection.
func truncateForError(b []byte) string {
const max = 256
s := string(b)
if len(s) > max {
s = s[:max] + "…(truncated)"
}
out := make([]byte, 0, len(s))
for _, r := range s {
if r == '\n' || r == '\r' || r == '\t' {
out = append(out, ' ')
continue
}
out = append(out, string(r)...)
}
return string(out)
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/event/testutil"
)
func TestCheckRemoteConnections_Success(t *testing.T) {
c := &testutil.StubAPIClient{Body: `{"code":0,"msg":"success","data":{"online_instance_cnt":1}}`}
count, err := CheckRemoteConnections(context.Background(), c)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 1 {
t.Errorf("count = %d, want 1", count)
}
if c.GotMethod != "GET" || c.GotPath != "/open-apis/event/v1/connection" {
t.Errorf("wrong request: %s %s", c.GotMethod, c.GotPath)
}
}
func TestCheckRemoteConnections_ZeroConnections(t *testing.T) {
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"online_instance_cnt":0}}`}
count, err := CheckRemoteConnections(context.Background(), c)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 0 {
t.Errorf("count = %d, want 0", count)
}
}
func TestCheckRemoteConnections_APIErrorPropagated(t *testing.T) {
want := errors.New("API GET /open-apis/event/v1/connection: [99991663] token is invalid")
c := &testutil.StubAPIClient{Err: want}
_, err := CheckRemoteConnections(context.Background(), c)
if !errors.Is(err, want) {
t.Errorf("err = %v, want wrapping %v", err, want)
}
}
func TestCheckRemoteConnections_MalformedJSON(t *testing.T) {
c := &testutil.StubAPIClient{Body: `not json at all`}
_, err := CheckRemoteConnections(context.Background(), c)
if err == nil {
t.Fatal("expected decode error")
}
}
// Non-zero OAPI business code must surface as error so callers don't mistake it for "verified zero remote buses".
func TestCheckRemoteConnections_NonZeroAPICodeSurfaced(t *testing.T) {
c := &testutil.StubAPIClient{Body: `{"code":99991663,"msg":"token is invalid","data":{}}`}
count, err := CheckRemoteConnections(context.Background(), c)
if err == nil {
t.Fatal("expected error for non-zero OAPI code, got nil")
}
if count != 0 {
t.Errorf("count = %d, want 0 on error", count)
}
msg := err.Error()
if !strings.Contains(msg, "99991663") {
t.Errorf("error message missing code 99991663: %q", msg)
}
if !strings.Contains(msg, "token is invalid") {
t.Errorf("error message missing msg field: %q", msg)
}
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"bufio"
"bytes"
"net"
"time"
"github.com/larksuite/cli/internal/event/protocol"
)
const preShutdownAckTimeout = 2 * time.Second
// checkLastForKey atomically reserves a cleanup lock; on any error defaults to true
// (cleanup-on-error is safer than leaking server state). Discards non-ack frames in flight.
func checkLastForKey(conn net.Conn, eventKey string) bool {
msg := protocol.NewPreShutdownCheck(eventKey)
if err := protocol.EncodeWithDeadline(conn, msg, protocol.WriteTimeout); err != nil {
return true
}
if err := conn.SetReadDeadline(time.Now().Add(preShutdownAckTimeout)); err != nil {
return true
}
br := bufio.NewReader(conn)
for {
line, err := protocol.ReadFrame(br)
if err != nil {
return true
}
resp, err := protocol.Decode(bytes.TrimRight(line, "\n"))
if err != nil {
continue
}
if ack, ok := resp.(*protocol.PreShutdownAck); ok {
return ack.LastForKey
}
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"encoding/json"
"io"
"net"
"testing"
"time"
"github.com/larksuite/cli/internal/event/protocol"
)
// checkLastForKey must skip non-ack frames buffered before PreShutdownAck.
func TestCheckLastForKey_IgnoresNonAckFrames(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
errs := make(chan error, 2)
go func() {
buf := make([]byte, 4096)
if _, err := server.Read(buf); err != nil && err != io.EOF {
errs <- err
return
}
evt := protocol.NewEvent("im.msg", "evt_1", "", 1, json.RawMessage(`{}`))
if err := protocol.Encode(server, evt); err != nil {
errs <- err
return
}
ack := protocol.NewPreShutdownAck(false)
if err := protocol.Encode(server, ack); err != nil {
errs <- err
return
}
}()
got := checkLastForKey(client, "im.msg")
if got != false {
t.Errorf("checkLastForKey = %v, want false", got)
}
select {
case err := <-errs:
t.Fatalf("server goroutine error: %v", err)
default:
}
}
func TestCheckLastForKey_ReturnsAckValue(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
go func() {
buf := make([]byte, 4096)
_, _ = server.Read(buf)
ack := protocol.NewPreShutdownAck(true)
_ = protocol.Encode(server, ack)
}()
got := checkLastForKey(client, "im.msg")
if got != true {
t.Errorf("checkLastForKey = %v, want true", got)
}
}
func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
go func() {
buf := make([]byte, 4096)
for {
if _, err := server.Read(buf); err != nil {
return
}
}
}()
start := time.Now()
got := checkLastForKey(client, "im.msg")
elapsed := time.Since(start)
if got != true {
t.Errorf("checkLastForKey = %v, want true (default on timeout)", got)
}
if elapsed > preShutdownAckTimeout+2*time.Second {
t.Errorf("elapsed = %v, expected ~%v (timeout-bounded)", elapsed, preShutdownAckTimeout)
}
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/larksuite/cli/internal/vfs"
)
type Sink interface {
Write(data json.RawMessage) error
}
func newSink(opts Options) (Sink, error) {
if opts.OutputDir != "" {
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
return nil, fmt.Errorf("create output dir: %w", err)
}
// PID disambiguates filenames across processes sharing a Dir.
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil
}
out := opts.Out
if out == nil {
out = os.Stdout //nolint:forbidigo // library-caller fallback; cmd path always sets Options.Out
}
return &WriterSink{W: out, ErrOut: opts.ErrOut}, nil
}
// WriterSink writes one JSON event per line; mu serialises concurrent worker writes.
type WriterSink struct {
W io.Writer
Pretty bool
ErrOut io.Writer
prettyWarned atomic.Bool
mu sync.Mutex
}
func (s *WriterSink) Write(data json.RawMessage) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.Pretty {
var v interface{}
if err := json.Unmarshal(data, &v); err == nil {
pretty, _ := json.MarshalIndent(v, "", " ")
_, err := fmt.Fprintln(s.W, string(pretty))
return err
}
// non-JSON payload (e.g. --jq output): fall through to raw, log once
if s.ErrOut != nil && s.prettyWarned.CompareAndSwap(false, true) {
fmt.Fprintln(s.ErrOut, "WARN: --pretty: payload is not valid JSON; falling back to raw output (this and future malformed events)")
}
}
_, err := fmt.Fprintln(s.W, string(data))
return err
}
// DirSink writes one JSON file per event; nanos+pid+seq filename avoids cross-process collisions.
type DirSink struct {
Dir string
pid int
seq atomic.Int64
}
func (s *DirSink) Write(data json.RawMessage) error {
name := fmt.Sprintf("%d_%d_%d.json", time.Now().UnixNano(), s.pid, s.seq.Add(1))
return vfs.WriteFile(filepath.Join(s.Dir, name), data, 0600) // 0600: payloads may carry PII
}

Some files were not shown because too many files have changed in this diff Show More