chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+25
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 the host alternation when adding brands.
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.
func describeAppMetaErr(err error) string {
msg := err.Error()
if url := authURLPattern.FindString(msg); url != "" {
return fmt.Sprintf("bot is missing scopes needed for app-version metadata; grant at: %s", url)
}
const maxErrLen = 200
if len(msg) > maxErrLen {
return msg[:maxErrLen] + "…"
}
return msg
}
+54
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)
}
}
+79
View File
@@ -0,0 +1,79 @@
// 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/errs"
"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 errs.NewInternalError(errs.SubtypeFileIO,
"set up bus logger: %s", err).WithCause(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():
}
}()
if err := b.Run(ctx); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus daemon exited: %s", err).WithCause(err)
}
return nil
},
}
cmd.Flags().StringVar(&domain, "domain", "", "API domain")
_ = cmd.Flags().MarkHidden("domain")
cmdutil.SetRisk(cmd, "write")
return cmd
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// The hidden `event _bus` daemon command must exit with a typed file_io error
// when its log directory cannot be created (the error is only visible in the
// forked process's captured stderr / bus.log).
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Block the events/ root with a regular file so MkdirAll fails.
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
cmd := NewCmdBus(f)
cmd.SetArgs([]string{})
err := cmd.Execute()
if err == nil {
t.Fatal("expected logger setup error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryInternal, errs.SubtypeFileIO)
}
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
// Landing-page contract for the scan-to-enable deep link, verified against the
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>.
// Note the param is camelCase "clientID" (not snake_case), and the value is the
// consuming app's own ID. Centralized so it can be corrected in one place.
const (
addonsLandingPath = "/page/launcher"
addonsClientIDParam = "clientID"
)
// ManifestAddons mirrors the 5 public manifest sections the launcher page accepts.
// Encoded form: JSON -> gzip -> base64url(no padding).
type ManifestAddons struct {
Scopes *AddonsScopes `json:"scopes,omitempty"`
Events *AddonsEvents `json:"events,omitempty"`
Callbacks *AddonsCallbacks `json:"callbacks,omitempty"`
}
type AddonsScopes struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsEvents struct {
Items AddonsEventItems `json:"items"`
}
type AddonsEventItems struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsCallbacks struct {
Items []string `json:"items"`
}
// encodeAddons: JSON -> gzip -> base64url(no padding). Matches the front-end decode chain.
func encodeAddons(a ManifestAddons) (string, error) {
raw, err := json.Marshal(a)
if err != nil {
return "", err
}
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
if _, err := gw.Write(raw); err != nil {
return "", err
}
if err := gw.Close(); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
}
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
encoded, err := encodeAddons(a)
if err != nil {
return "", err
}
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
}
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
func consoleLandingURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
}
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
url, err := consoleAddonsURL(brand, appID, a)
if err != nil {
return consoleLandingURL(brand, appID)
}
return url
}
// missingScopeAddons routes missing scopes into the identity-appropriate section.
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
// the addons spec treats a missing tenant/user as an empty array.
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
if identity.IsBot() {
s.Tenant = missing
} else {
s.User = missing
}
return ManifestAddons{Scopes: s}
}
// missingSubscriptionAddons routes missing events/callbacks into the right section.
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
if subType == eventlib.SubTypeCallback {
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
}
ev := &AddonsEvents{Items: AddonsEventItems{Tenant: []string{}, User: []string{}}}
if identity.IsBot() {
ev.Items.Tenant = missing
} else {
ev.Items.User = missing
}
return ManifestAddons{Events: ev}
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"io"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
t.Helper()
gz, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("base64url decode: %v", err)
}
zr, err := gzip.NewReader(bytes.NewReader(gz))
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
raw, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("gunzip: %v", err)
}
var a ManifestAddons
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("json: %v", err)
}
return a
}
func TestEncodeAddons_RoundTrip(t *testing.T) {
in := ManifestAddons{Scopes: &AddonsScopes{Tenant: []string{"im:message"}}}
encoded, err := encodeAddons(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
for _, r := range encoded {
if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
t.Fatalf("encoded contains non-base64url char %q in %q", r, encoded)
}
}
out := decodeAddons(t, encoded)
if out.Scopes == nil || len(out.Scopes.Tenant) != 1 || out.Scopes.Tenant[0] != "im:message" {
t.Errorf("roundtrip mismatch: %+v", out)
}
}
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
if err != nil {
t.Fatalf("url: %v", err)
}
host := core.ResolveEndpoints(core.BrandFeishu).Open
prefix := host + "/page/launcher?clientID=cli_x&addons="
if !strings.HasPrefix(url, prefix) {
t.Errorf("url = %q, want prefix %q", url, prefix)
}
out := decodeAddons(t, strings.TrimPrefix(url, prefix))
if out.Callbacks == nil || len(out.Callbacks.Items) != 1 || out.Callbacks.Items[0] != "card.action.trigger" {
t.Errorf("decoded callbacks mismatch: %+v", out)
}
}
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
}
user := missingScopeAddons(core.AsUser, []string{"im:message"})
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
}
}
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
}
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
}
}
func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
// Unused identity sides must encode as [] (not null) so the launcher page's
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
cases := []ManifestAddons{
missingScopeAddons(core.AsBot, []string{"im:message"}),
missingScopeAddons(core.AsUser, []string{"im:message"}),
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
}
for i, a := range cases {
raw, err := json.Marshal(a)
if err != nil {
t.Fatalf("case %d marshal: %v", i, err)
}
if bytes.Contains(raw, []byte("null")) {
t.Errorf("case %d encodes a null array, want []: %s", i, raw)
}
}
}
+425
View File
@@ -0,0 +1,425 @@
// 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/errs"
"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. Bounded runs ignore stdin EOF.")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, "read")
return cmd
}
func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consumeCmdOpts) error {
// Pipe-close (e.g. `... | head -n 1`) must reach the EPIPE error path in the loop, not SIGPIPE-kill.
ignoreBrokenPipe()
cfg, err := f.Config()
if err != nil {
return err
}
paramMap, err := parseParams(o.params)
if err != nil {
return err
}
keyDef, ok := eventlib.Lookup(eventKey)
if !ok {
return unknownEventKeyErr(eventKey)
}
identity, err := resolveIdentity(cmd, f, keyDef)
if err != nil {
return err
}
if o.jqExpr != "" {
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
WithParam("--jq").
WithCause(err).
WithHint("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")
}
// Callback subscriptions live in application/get, not app_versions; fetch the
// callback 底账 only for callback-type EventKeys. Weak dependency: on error,
// leave subscribedCallbacks nil so the callback precheck skips.
var subscribedCallbacks []string
if keyDef.SubscriptionType == eventlib.SubTypeCallback {
cbs, cbErr := appmeta.FetchSubscribedCallbacks(cmd.Context(), botRuntime, cfg.AppID)
if cbErr != nil {
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(cbErr))
} else {
subscribedCallbacks = cbs
}
}
pf := &preflightCtx{
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
subscribedCallbacks: subscribedCallbacks,
}
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 unbounded consumers use stdin EOF as shutdown for subprocess callers.
// Bounded runs already have --max-events/--timeout as their lifecycle control.
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
watchStdinEOF(os.Stdin, cancel, errOut)
}
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
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
// nil means "not fetched / unavailable" → callback precheck skips (weak dependency).
subscribedCallbacks []string
}
// 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 errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scopes for EventKey %s (as %s): %s",
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)).
WithMissingScopes(missing...).
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
// the tenant token carries them. User: the scan link only updates the app
// manifest — the user's own token still lacks the scopes until it is
// re-authorized — so direct the user to re-login instead.
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
if identity.IsBot() {
return fmt.Sprintf("grant these scopes by scanning: %s",
addonsHintURL(brand, appID, missingScopeAddons(identity, 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 console 底账 — published app_versions for event subscriptions,
// application/get subscribed_callbacks for callback subscriptions.
func preflightEventTypes(pf *preflightCtx) error {
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
return nil
}
var subscribed []string
noun := "event types"
if pf.keyDef.SubscriptionType == eventlib.SubTypeCallback {
if pf.subscribedCallbacks == nil {
return nil
}
subscribed = pf.subscribedCallbacks
noun = "callbacks"
} else {
if pf.appVer == nil {
return nil
}
subscribed = pf.appVer.EventTypes
}
have := make(map[string]bool, len(subscribed))
for _, t := range subscribed {
have[t] = true
}
var missing []string
for _, t := range pf.keyDef.RequiredConsoleEvents {
if !have[t] {
missing = append(missing, t)
}
}
if len(missing) == 0 {
return nil
}
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"EventKey %s requires %s not subscribed in console: %s",
pf.keyDef.Key, noun, strings.Join(missing, ", ")).
WithHint("subscribe these %s by scanning: %s", noun, url)
}
// 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 "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s; use a relative path like ./output instead", errOutputDirTilde).
WithParam("--output-dir").
WithCause(errOutputDirTilde)
}
safe, err := validate.SafeOutputPath(dir)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q: %s", errOutputDirUnsafe, dir, err).
WithParam("--output-dir").
WithCause(errOutputDirUnsafe)
}
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 {
if _, ok := errs.ProblemOf(err); ok {
return "", err
}
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"resolve tenant access token: %s", err).WithCause(err)
}
if result == nil || result.Token == "" {
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"no tenant access token available for app %s", appID).
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
}
return result.Token, nil
}
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
var (
errInvalidParamFormat = errors.New("invalid --param format") //nolint:forbidigo // sentinel, typed at call sites
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") //nolint:forbidigo // sentinel, typed at call sites
errOutputDirUnsafe = errors.New("unsafe --output-dir") //nolint:forbidigo // sentinel, typed at call sites
)
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, errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q: expected key=value", errInvalidParamFormat, kv).
WithParam("--param").
WithCause(errInvalidParamFormat)
}
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()
}()
}
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
return !isTerminal && maxEvents <= 0 && timeout <= 0
}
+130
View File
@@ -0,0 +1,130 @@
// 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")
}
}
func TestShouldWatchStdinEOF(t *testing.T) {
tests := []struct {
name string
isTerminal bool
maxEvents int
timeout time.Duration
want bool
}{
{
name: "terminal",
isTerminal: true,
want: false,
},
{
name: "non terminal unbounded",
want: true,
},
{
name: "non terminal negative max events is unbounded",
maxEvents: -1,
want: true,
},
{
name: "non terminal negative timeout is unbounded",
timeout: -1 * time.Second,
want: true,
},
{
name: "non terminal max events bounded",
maxEvents: 1,
want: false,
},
{
name: "non terminal timeout bounded",
timeout: 10 * time.Minute,
want: false,
},
{
name: "non terminal both bounds positive",
maxEvents: 1,
timeout: 10 * time.Minute,
want: false,
},
{
name: "non terminal bounded max events with negative timeout",
maxEvents: 1,
timeout: -1 * time.Second,
want: false,
},
{
name: "non terminal bounded timeout with negative max events",
maxEvents: -1,
timeout: 10 * time.Minute,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
if got != tt.want {
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
}
})
}
}
+221
View File
@@ -0,0 +1,221 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
)
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)
}
assertInvalidArgumentParam(t, err, "--param")
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)
}
}
})
}
}
// emptyTokenResolver resolves to a result that carries no token.
type emptyTokenResolver struct{}
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{}, nil
}
// failingTokenResolver fails outright with an untyped error.
type failingTokenResolver struct{}
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return nil, errors.New("backend unavailable")
}
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
}
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
}
var malformed *credential.MalformedTokenResultError
if !errors.As(err, &malformed) {
t.Error("empty-token failure should preserve the credential-layer cause")
}
}
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
}
if errors.Unwrap(err) == nil {
t.Error("resolver failure should preserve its cause")
}
}
// assertInvalidArgumentParam verifies err is a typed validation error with
// subtype invalid_argument naming the given flag in its param field.
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != param {
t.Errorf("param = %q, want %q", ve.Param, param)
}
}
func TestSanitizeOutputDir(t *testing.T) {
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())
}
assertInvalidArgumentParam(t, err, "--output-dir")
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == "" {
t.Errorf("expected non-empty safe path, got %q", got)
}
})
}
}
+29
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
}
+338
View File
@@ -0,0 +1,338 @@
// 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 TestWriteStatusText_ShowsSubColumn(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 2,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB column header: %s", out)
}
if !strings.Contains(out, "alice") {
t.Errorf("missing alice suffix in SUB column: %s", out)
}
if !strings.Contains(out, "bob") {
t.Errorf("missing bob suffix in SUB column: %s", out)
}
}
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 1,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB header: %s", out)
}
if !strings.Contains(out, "-") {
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
}
}
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 1,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB header: %s", out)
}
if !strings.Contains(out, "-") {
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
}
}
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
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.BareError
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.BareError); ok {
if t, ok := target.(**output.BareError); ok {
*t = e
return true
}
}
return false
}
func TestNewCmdFactories_WireFlags(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"})
t.Run("consume", func(t *testing.T) {
cmd := NewCmdConsume(f)
for _, flag := range []string{"param", "jq", "quiet", "output-dir", "max-events", "timeout", "as"} {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("consume missing --%s flag", flag)
}
}
if cmd.RunE == nil {
t.Error("consume RunE is nil")
}
})
t.Run("status", func(t *testing.T) {
cmd := NewCmdStatus(f)
for _, flag := range []string{"json", "current", "fail-on-orphan"} {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("status missing --%s flag", flag)
}
}
})
t.Run("stop", func(t *testing.T) {
cmd := NewCmdStop(f)
for _, flag := range []string{"app-id", "all", "force", "json"} {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("stop missing --%s flag", flag)
}
}
})
t.Run("list", func(t *testing.T) {
cmd := NewCmdList(f)
if cmd.Flags().Lookup("json") == nil {
t.Error("list missing --json flag")
}
})
t.Run("bus", func(t *testing.T) {
cmd := NewCmdBus(f)
if !cmd.Hidden {
t.Error("bus should be hidden (internal daemon entrypoint)")
}
if cmd.Flags().Lookup("domain") == nil {
t.Error("bus missing --domain flag")
}
})
}
+122
View File
@@ -0,0 +1,122 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"encoding/json"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
)
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "list",
Short: "List all available EventKeys",
Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --json for machine-readable output.",
RunE: func(cmd *cobra.Command, args []string) error {
return runList(f, asJSON)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
cmdutil.SetRisk(cmd, "read")
return cmd
}
func runList(f *cmdutil.Factory, asJSON bool) error {
all := eventlib.ListAll()
if asJSON {
return writeListJSON(f, all)
}
if len(all) == 0 {
// stderr so `event list | jq` doesn't ingest it as a row.
fmt.Fprintln(f.IOStreams.ErrOut, "No EventKeys registered.")
return nil
}
type group struct {
domain string
keys []*eventlib.KeyDefinition
}
order := []string{}
groups := map[string]*group{}
for _, def := range all {
domain := def.Key
if idx := strings.Index(def.Key, "."); idx > 0 {
domain = def.Key[:idx]
}
g, ok := groups[domain]
if !ok {
g = &group{domain: domain}
groups[domain] = g
order = append(order, domain)
}
g.keys = append(g.keys, def)
}
// Global widths (not per-section) keep "── domain ──" dividers aligned across groups.
headers := []string{"KEY", "AUTH", "PARAMS", "DESCRIPTION"}
rowsByDomain := make(map[string][][]string, len(order))
var allRows [][]string
for _, domain := range order {
for _, def := range groups[domain].keys {
auth := "-"
if len(def.AuthTypes) > 0 {
auth = strings.Join(def.AuthTypes, "|")
}
desc := def.Description
if desc == "" {
desc = "-"
}
row := []string{
def.Key,
auth,
fmt.Sprintf("%d", len(def.Params)),
desc,
}
rowsByDomain[domain] = append(rowsByDomain[domain], row)
allRows = append(allRows, row)
}
}
out := f.IOStreams.Out
const colGap = " "
widths := tableWidths(headers, allRows)
printTableRow(out, widths, headers, colGap)
for _, domain := range order {
fmt.Fprintf(out, "\n── %s ──\n", domain)
for _, row := range rowsByDomain[domain] {
printTableRow(out, widths, row, colGap)
}
}
// stderr keeps stdout pipe-clean for `event list | jq`.
fmt.Fprintln(f.IOStreams.ErrOut, "\nUse 'event schema <key>' for details.")
return nil
}
func writeListJSON(f *cmdutil.Factory, all []*eventlib.KeyDefinition) error {
type row struct {
*eventlib.KeyDefinition
ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"`
}
rows := make([]row, len(all))
for i, def := range all {
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return err
}
rows[i] = row{KeyDefinition: def, ResolvedSchema: resolved}
}
output.PrintJson(f.IOStreams.Out, rows)
return nil
}
+100
View File
@@ -0,0 +1,100 @@
// 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"
eventlib "github.com/larksuite/cli/internal/event"
_ "github.com/larksuite/cli/events"
)
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if _, ok := eventlib.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) should succeed", key)
}
}
}
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",
"task.task.update_user_access_v2",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_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)
}
}
}
gotKeys := map[string]map[string]interface{}{}
for _, row := range rows {
if key, ok := row["key"].(string); ok {
gotKeys[key] = row
}
}
var foundTask bool
for key, row := range gotKeys {
if key == "task.task.update_user_access_v2" {
foundTask = true
if row["single_consumer"] != true {
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
}
}
}
if !foundTask {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
for _, want := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if _, ok := gotKeys[want]; !ok {
t.Errorf("JSON list output missing %q", want)
}
}
}
+281
View File
@@ -0,0 +1,281 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
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)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
}
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
if !strings.Contains(p.Hint, wantURL) {
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.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 permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
errs.CategoryAuthorization, errs.SubtypeMissingScope)
}
wantMissing := []string{"im:message.group_at_msg"}
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
}
hint := permErr.Hint
wantSubstrings := []string{
"grant these scopes by scanning: ",
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
}
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)
}
}
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{"profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
err := preflightEventTypes(pf)
if err == nil {
t.Fatal("expected error for missing callback")
}
if !strings.Contains(err.Error(), "callbacks not subscribed") {
t.Errorf("error = %q, want mention of 'callbacks not subscribed'", err.Error())
}
if !strings.Contains(err.Error(), "card.action.trigger") {
t.Errorf("error should name the missing callback, got: %q", err.Error())
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %v, want validation/failed_precondition", p)
}
}
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
if err := preflightEventTypes(pf); err != nil {
t.Errorf("expected skip (nil), got %v", err)
}
}
func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
// fetched but zero callbacks subscribed (non-nil empty) is a definitive
// console state: a required callback IS missing and must be reported,
// not skipped as a weak dependency.
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{}, // fetched, none subscribed
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
err := preflightEventTypes(pf)
if err == nil {
t.Fatal("expected error for missing callback when none are subscribed")
}
if !strings.Contains(err.Error(), "card.action.trigger") {
t.Errorf("error should name the missing callback, got: %q", err.Error())
}
}
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
if err := preflightEventTypes(pf); err != nil {
t.Errorf("all callbacks subscribed, unexpected error: %v", err)
}
}
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
// bot: scan-to-enable link (adds scopes to app manifest)
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
t.Errorf("bot hint should give the scan link, got: %s", bot)
}
// user: re-login (scan link cannot grant scopes to the user's own token)
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
if !strings.Contains(user, "auth login --scope") {
t.Errorf("user hint should direct to auth login, got: %s", user)
}
if strings.Contains(user, "/page/launcher") {
t.Errorf("user hint must NOT use the scan link, got: %s", user)
}
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"encoding/json"
"github.com/larksuite/cli/errs"
"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 {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
"api %s %s: %s", method, path, err).WithCause(err)
}
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
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)"
}
if resp.StatusCode >= 500 {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
}
result, err := client.ParseJSONResponse(resp)
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"api %s %s: %s", method, path, err).WithCause(err)
}
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
return json.RawMessage(resp.RawBody), apiErr
}
return json.RawMessage(resp.RawBody), nil
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
// staticTokenResolver always returns a fixed token without any HTTP calls.
type staticTokenResolver struct{}
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token"}, nil
}
// stubRoundTripper intercepts every outgoing request with a canned response.
type stubRoundTripper struct {
respond func(*http.Request) (*http.Response, error)
}
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
sdk := lark.NewClient("test-app", "test-secret",
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(&http.Client{Transport: rt}),
)
return &consumeRuntime{
client: &client.APIClient{
SDK: sdk,
ErrOut: io.Discard,
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
},
accessIdentity: core.AsBot,
}
}
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
return func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(body)),
Request: r,
}, nil
}
}
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype {
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
}
}
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
if !strings.Contains(err.Error(), "returned 404") {
t.Errorf("error should echo the HTTP status, got: %v", err)
}
}
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
long := strings.Repeat("x", 300)
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
p, _ := errs.ProblemOf(err)
if !p.Retryable {
t.Fatal("5xx non-JSON response should be marked retryable")
}
if !strings.Contains(err.Error(), "…(truncated)") {
t.Errorf("long body should be truncated in the message, got: %v", err)
}
}
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
}
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
return nil, errors.New("connection refused")
}})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
}
}
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
`{"code":99991663,"msg":"app not found"}`)})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
}
}
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
`{"code":0,"data":{"ok":true}}`)})
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(string(raw), `"code":0`) {
t.Errorf("raw body should pass through, got: %s", raw)
}
}
+231
View File
@@ -0,0 +1,231 @@
// 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/errs"
"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, errs.NewInternalError(errs.SubtypeUnknown,
"parse base schema for field overrides: %s", err).WithCause(err)
}
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
out, err := json.Marshal(parsed)
if err != nil {
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"serialize schema with field overrides: %s", err).WithCause(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, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
}
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "schema <EventKey>",
Short: "Show details for an EventKey",
Long: "Display detailed information about an EventKey including type, events, parameters, and response schema. Use --json for machine-readable output.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runSchema(f, args[0], asJSON)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
cmdutil.SetRisk(cmd, "read")
return cmd
}
func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
def, ok := eventlib.Lookup(key)
if !ok {
return unknownEventKeyErr(key)
}
if asJSON {
return writeSchemaJSON(f, def)
}
out := f.IOStreams.Out
fmt.Fprintf(out, "Key: %s\n", def.Key)
if def.Description != "" {
fmt.Fprintf(out, "Description: %s\n", def.Description)
}
fmt.Fprintf(out, "Event: %s\n", def.EventType)
if def.PreConsume != nil {
fmt.Fprintf(out, "Pre-consume: yes\n")
}
if len(def.Scopes) > 0 {
fmt.Fprintf(out, "\nRequired Scopes:\n")
for _, s := range def.Scopes {
fmt.Fprintf(out, " - %s\n", s)
}
}
if len(def.RequiredConsoleEvents) > 0 {
fmt.Fprintf(out, "\nRequired Console Events (must be enabled in developer console):\n")
for _, e := range def.RequiredConsoleEvents {
fmt.Fprintf(out, " - %s\n", e)
}
}
if len(def.Params) > 0 {
fmt.Fprintf(out, "\nParameters:\n")
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n")
for _, p := range def.Params {
required := "no"
if p.Required {
required = "yes"
}
subKey := "no"
if p.SubscriptionKey {
subKey = "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\t%s\n", p.Name, p.Type, required, subKey, 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 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
}
+307
View File
@@ -0,0 +1,307 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"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 TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "task.task.update_user_access_v2", 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())
}
if payload["jq_root_path"] != ".event" {
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
}
if payload["single_consumer"] != true {
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
}
resolved := payload["resolved_output_schema"].(map[string]interface{})
props := resolved["properties"].(map[string]interface{})
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
t.Errorf("task_guid format = %v, want task_guid", got)
}
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
}
}
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
t.Run(key, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, key, 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())
}
if payload["key"] != key {
t.Errorf("key = %v, want %s", payload["key"], key)
}
resolved, ok := payload["resolved_output_schema"].(map[string]interface{})
if !ok {
t.Fatalf("resolved_output_schema missing or wrong type: %+v", payload)
}
properties, ok := resolved["properties"].(map[string]interface{})
if !ok {
t.Fatalf("resolved_output_schema.properties missing or wrong type: %+v", resolved)
}
for _, field := range []string{"type", "event_id", "timestamp", "meeting_id", "topic", "meeting_no", "start_time", "calendar_event_id"} {
if _, ok := properties[field]; !ok {
t.Errorf("resolved output schema missing field %q: %+v", field, properties)
}
}
if _, ok := properties["end_time"]; ok {
t.Errorf("resolved output schema should not include end_time for %s: %+v", key, properties)
}
})
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
{Name: "folders", Description: "filter only"},
},
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, false); err != nil {
t.Fatalf("runSchema: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "SUB-KEY") {
t.Errorf("missing SUB-KEY column header in:\n%s", out)
}
// Find the mailbox row and verify "yes" is present
var mailboxRow string
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
mailboxRow = ln
break
}
}
if !strings.Contains(mailboxRow, "yes") {
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
}
// Find the folders row and verify "no" is present
var foldersRow string
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
foldersRow = ln
break
}
}
if !strings.Contains(foldersRow, "no") {
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
}
}
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
const syntheticKey = "test.evt_json"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
if !strings.Contains(stdout.String(), `"subscription_key"`) {
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `true`) {
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
}
}
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
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)
}
}
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
_, err := renderSpec(&eventlib.SchemaSpec{})
if err == nil {
t.Fatal("expected error for spec with neither Type nor Raw")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "synthetic.invalid.base",
Schema: eventlib.SchemaDef{
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
},
}
_, _, err := resolveSchemaJSON(def)
if err == nil {
t.Fatal("expected error for unparsable base schema")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}
+17
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)
}
+9
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() {}
+335
View File
@@ -0,0 +1,335 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"fmt"
"io"
"sort"
"strings"
"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)")
cmdutil.SetRisk(cmd, "read")
return cmd
}
type busState int
const (
stateNotRunning busState = iota
stateRunning
stateOrphan
)
func (s busState) String() string {
switch s {
case stateRunning:
return "running"
case stateOrphan:
return "orphan"
default:
return "not_running"
}
}
// appStatus bundles one AppID's derived status; State picks which fields are meaningful.
type appStatus struct {
AppID string
State busState
PID int
UptimeSec int
Active int
Consumers []protocol.ConsumerInfo
}
type busQuerier interface {
QueryBusStatus(appID string) (*protocol.StatusResponse, error)
}
// singleAppScanner wraps a Scanner and filters to one AppID for --current queries.
type singleAppScanner struct {
appID string
inner busdiscover.Scanner
}
func (s singleAppScanner) ScanBusProcesses() ([]busdiscover.Process, error) {
if s.inner == nil {
return nil, nil
}
all, err := s.inner.ScanBusProcesses()
if err != nil {
return nil, err
}
out := all[:0]
for _, p := range all {
if p.AppID == s.appID {
out = append(out, p)
}
}
return out, nil
}
type transportQuerier struct {
tr transport.IPC
}
func (q *transportQuerier) QueryBusStatus(appID string) (*protocol.StatusResponse, error) {
return busctl.QueryStatus(q.tr, appID)
}
func runStatus(f *cmdutil.Factory, current, asJSON, failOnOrphan bool) error {
cfg, err := f.Config()
if err != nil {
return err
}
seeds := map[string]struct{}{}
if current {
seeds[cfg.AppID] = struct{}{}
} else {
for _, id := range discoverAppIDs() {
seeds[id] = struct{}{}
}
// Always include the current profile so a first-time user sees it as not_running.
seeds[cfg.AppID] = struct{}{}
}
seedList := make([]string, 0, len(seeds))
for id := range seeds {
seedList = append(seedList, id)
}
tr := transport.New()
// --current: scope the scanner to this AppID so unrelated orphans don't surface.
var scanner busdiscover.Scanner
if current {
scanner = singleAppScanner{appID: cfg.AppID, inner: busdiscover.Default()}
} else {
scanner = busdiscover.Default()
}
statuses := deriveStatuses(
seedList,
scanner,
&transportQuerier{tr: tr},
time.Now(),
)
if asJSON {
if err := writeStatusJSON(f.IOStreams.Out, statuses); err != nil {
return err
}
} else {
writeStatusText(f.IOStreams.Out, statuses)
}
return exitForOrphan(statuses, failOnOrphan)
}
// deriveStatuses classifies each AppID as running/orphan/not_running from socket + process-scan inputs; scanner errors are non-fatal.
func deriveStatuses(seedAppIDs []string, sc busdiscover.Scanner, q busQuerier, now time.Time) []appStatus {
procByAppID := map[string]busdiscover.Process{}
if sc != nil {
if procs, err := sc.ScanBusProcesses(); err == nil {
for _, p := range procs {
procByAppID[p.AppID] = p
}
}
}
ids := map[string]struct{}{}
for _, id := range seedAppIDs {
ids[id] = struct{}{}
}
for id := range procByAppID {
ids[id] = struct{}{}
}
sorted := make([]string, 0, len(ids))
for id := range ids {
sorted = append(sorted, id)
}
sort.Strings(sorted)
// Query in parallel so one wedged peer can't compound the per-op deadline across many apps.
type probe struct {
resp *protocol.StatusResponse
err error
}
probes := make([]probe, len(sorted))
var wg sync.WaitGroup
for i, appID := range sorted {
wg.Add(1)
go func(i int, appID string) {
defer wg.Done()
probes[i].resp, probes[i].err = q.QueryBusStatus(appID)
}(i, appID)
}
wg.Wait()
result := make([]appStatus, 0, len(sorted))
for i, appID := range sorted {
s := appStatus{AppID: appID, State: stateNotRunning}
if probes[i].err == nil {
resp := probes[i].resp
s.State = stateRunning
s.PID = resp.PID
s.UptimeSec = resp.UptimeSec
s.Active = resp.ActiveConns
s.Consumers = resp.Consumers
} else if p, ok := procByAppID[appID]; ok {
s.State = stateOrphan
s.PID = p.PID
s.UptimeSec = int(now.Sub(p.StartTime).Seconds())
}
result = append(result, s)
}
return result
}
// humanizeDuration formats d as a coarse "N unit ago" string.
func humanizeDuration(d time.Duration) string {
s := int(d.Seconds())
if s < 60 {
return fmt.Sprintf("%ds ago", s)
}
m := s / 60
if m < 60 {
return fmt.Sprintf("%dm ago", m)
}
h := m / 60
if h < 24 {
return fmt.Sprintf("%dh ago", h)
}
return fmt.Sprintf("%dd ago", h/24)
}
func writeStatusText(out io.Writer, statuses []appStatus) {
for i, s := range statuses {
if i > 0 {
fmt.Fprintln(out)
}
fmt.Fprintf(out, "── %s ──\n", s.AppID)
switch s.State {
case stateNotRunning:
fmt.Fprintln(out, " Bus: not running")
case stateRunning:
fmt.Fprintf(out, " Bus: running (PID %d, uptime %s)\n",
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
if len(s.Consumers) > 0 {
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"}
rows := make([][]string, 0, len(s.Consumers))
for _, c := range s.Consumers {
subDisplay := "-"
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
}
rows = append(rows, []string{
fmt.Sprintf("pid=%d", c.PID),
c.EventKey,
subDisplay,
fmt.Sprintf("%d", c.Received),
fmt.Sprintf("%d", c.Dropped),
})
}
widths := tableWidths(headers, rows)
const colGap = " "
fmt.Fprintln(out)
fmt.Fprint(out, " ")
printTableRow(out, widths, headers, colGap)
for _, row := range rows {
fmt.Fprint(out, " ")
printTableRow(out, widths, row, colGap)
}
}
case stateOrphan:
if s.PID == 0 {
fmt.Fprintln(out, " Bus: orphan (PID unknown — bus.pid file unreadable)")
fmt.Fprintln(out, " Issue: live bus detected but pid file is missing or corrupt")
fmt.Fprintln(out, " Action: inspect ~/.lark-cli/events/<app>/bus.pid and kill manually")
break
}
fmt.Fprintf(out, " Bus: orphan (PID %d, started %s)\n",
s.PID, humanizeDuration(time.Duration(s.UptimeSec)*time.Second))
fmt.Fprintln(out, " Issue: socket file missing — consumers cannot connect")
fmt.Fprintf(out, " Action: kill %d\n", s.PID)
}
}
}
func writeStatusJSON(w io.Writer, statuses []appStatus) error {
type jsonStatus struct {
AppID string `json:"app_id"`
Status string `json:"status"`
Running bool `json:"running"` // backward compat
PID int `json:"pid,omitempty"`
UptimeSec int `json:"uptime_sec,omitempty"`
Active int `json:"active_consumers,omitempty"`
Consumers []protocol.ConsumerInfo `json:"consumers,omitempty"`
Issue string `json:"issue,omitempty"`
SuggestedAction string `json:"suggested_action,omitempty"`
}
payload := make([]jsonStatus, 0, len(statuses))
for _, s := range statuses {
js := jsonStatus{
AppID: s.AppID,
Status: s.State.String(),
Running: s.State == stateRunning,
PID: s.PID,
UptimeSec: s.UptimeSec,
Active: s.Active,
Consumers: s.Consumers,
}
if s.State == stateOrphan {
if s.PID == 0 {
js.Issue = "live bus detected but pid file is missing or corrupt"
js.SuggestedAction = "inspect events dir and kill manually"
} else {
js.Issue = "socket file missing"
js.SuggestedAction = fmt.Sprintf("kill %d", s.PID)
}
}
payload = append(payload, js)
}
output.PrintJson(w, map[string]interface{}{"apps": payload})
return nil
}
// exitForOrphan returns ExitValidation iff failOnOrphan and any status is orphan; default exit 0 preserves observe-only semantics.
func exitForOrphan(statuses []appStatus, failOnOrphan bool) error {
if !failOnOrphan {
return nil
}
for _, s := range statuses {
if s.State == stateOrphan {
return output.ErrBare(output.ExitValidation)
}
}
return nil
}
+48
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 bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T", err)
}
if bareErr.Code != output.ExitValidation {
t.Errorf("Code = %d, want %d", bareErr.Code, output.ExitValidation)
}
}
func TestExitForOrphan_NoOrphan(t *testing.T) {
statuses := []appStatus{
{AppID: "cli_a", State: stateRunning},
{AppID: "cli_b", State: stateNotRunning},
}
if err := exitForOrphan(statuses, true); err != nil {
t.Errorf("expected nil error when no orphan; got %v", err)
}
}
func TestExitForOrphan_FlagDisabled(t *testing.T) {
statuses := []appStatus{
{AppID: "cli_b", State: stateOrphan, PID: 70926},
}
if err := exitForOrphan(statuses, false); err != nil {
t.Errorf("flag off should never return error; got %v", err)
}
}
+242
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)
}
}
}
+242
View File
@@ -0,0 +1,242 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"fmt"
"io"
"os"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/event/busctl"
"github.com/larksuite/cli/internal/event/busdiscover"
"github.com/larksuite/cli/internal/event/transport"
"github.com/larksuite/cli/internal/output"
)
// stopStatus is the outcome tag; JSON wire format is the string form — keep values stable.
type stopStatus string
const (
stopStopped stopStatus = "stopped"
stopNoBus stopStatus = "no_bus"
stopRefused stopStatus = "refused"
stopErrored stopStatus = "error"
)
type stopResult struct {
AppID string `json:"app_id"`
Status stopStatus `json:"status"`
PID int `json:"pid,omitempty"`
Reason string `json:"reason,omitempty"`
}
type stopCmdOpts struct {
appID string
all bool
force bool
asJSON bool
}
func NewCmdStop(f *cmdutil.Factory) *cobra.Command {
var o stopCmdOpts
cmd := &cobra.Command{
Use: "stop",
Short: "Stop the event bus daemon",
Long: `Stop the event bus daemon. Target is one of:
• the current profile's AppID (default)
• an explicit AppID via --app-id
• every running bus on this machine via --all
Exit code: 2 if any target was refused or errored, 0 otherwise.
--force widens two gates:
1. Allows stopping a bus that still has active consumers.
2. On shutdown-timeout (bus didn't exit within 5s), SIGKILLs the
process and cleans up the stale socket instead of returning an
error.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runStop(f, o)
},
}
cmd.Flags().StringVar(&o.appID, "app-id", "", "App ID of the bus to stop (default: current profile)")
cmd.Flags().BoolVar(&o.all, "all", false, "Stop all running bus daemons")
cmd.Flags().BoolVar(&o.force, "force", false, "Stop even with active consumers; on shutdown-timeout also SIGKILL the bus")
cmd.Flags().BoolVar(&o.asJSON, "json", false, "Emit results as JSON (for AI / scripts)")
cmdutil.SetRisk(cmd, "write")
return cmd
}
func runStop(f *cmdutil.Factory, o stopCmdOpts) error {
tr := transport.New()
var targets []string
if o.all {
targets = discoverAppIDs()
} else {
targetAppID := o.appID
if targetAppID == "" {
cfg, err := f.Config()
if err != nil {
return err
}
targetAppID = cfg.AppID
}
targets = []string{targetAppID}
}
if len(targets) == 0 {
if o.asJSON {
return writeStopJSON(f.IOStreams.Out, nil)
}
fmt.Fprintln(f.IOStreams.Out, "No event bus instances found.")
return nil
}
results := make([]stopResult, 0, len(targets))
for _, id := range targets {
results = append(results, stopBusOne(tr, id, o.force))
}
if o.asJSON {
return writeStopJSON(f.IOStreams.Out, results)
}
writeStopText(f.IOStreams.Out, f.IOStreams.ErrOut, results)
// Non-zero exit for refused/errored so non-JSON callers still get a signal.
for _, r := range results {
if r.Status == stopRefused || r.Status == stopErrored {
return output.ErrBare(output.ExitValidation)
}
}
return nil
}
// stopBusOne attempts to stop appID's bus; polls tr.Dial post-Shutdown until listener is gone or budget elapses.
func stopBusOne(tr transport.IPC, appID string, force bool) stopResult {
resp, err := busctl.QueryStatus(tr, appID)
if err != nil {
return stopResult{AppID: appID, Status: stopNoBus}
}
if resp.ActiveConns > 0 && !force {
pids := make([]int, len(resp.Consumers))
for i, c := range resp.Consumers {
pids[i] = c.PID
}
return stopResult{
AppID: appID,
Status: stopRefused,
PID: resp.PID,
Reason: fmt.Sprintf("%d active consumer(s) (pids: %v); use --force to override", resp.ActiveConns, pids),
}
}
if err := busctl.SendShutdown(tr, appID); err != nil {
return stopResult{AppID: appID, Status: stopErrored, PID: resp.PID, Reason: err.Error()}
}
const pollInterval = 100 * time.Millisecond
deadline := time.Now().Add(shutdownBudget)
for time.Now().Before(deadline) {
time.Sleep(pollInterval)
probe, dialErr := tr.Dial(tr.Address(appID))
if dialErr != nil {
return stopResult{AppID: appID, Status: stopStopped, PID: resp.PID}
}
probe.Close()
}
if !force {
return stopResult{
AppID: appID,
Status: stopErrored,
PID: resp.PID,
Reason: fmt.Sprintf("Bus did not exit within %v (pid=%d still listening); use --force to kill", shutdownBudget, resp.PID),
}
}
// --force: SIGKILL and clean up the stale socket.
if err := killProcess(resp.PID); err != nil {
if errors.Is(err, os.ErrProcessDone) {
// Bus exited between timeout and kill — treat as success.
tr.Cleanup(tr.Address(appID))
return stopResult{
AppID: appID,
Status: stopStopped,
PID: resp.PID,
Reason: "bus exited during kill attempt",
}
}
return stopResult{
AppID: appID,
Status: stopErrored,
PID: resp.PID,
Reason: fmt.Sprintf("failed to kill bus process: %v", err),
}
}
tr.Cleanup(tr.Address(appID))
return stopResult{
AppID: appID,
Status: stopStopped,
PID: resp.PID,
Reason: "killed (ungraceful) after shutdown timeout",
}
}
// killProcess is a var so tests can swap it without spawning sub-processes.
var killProcess = func(pid int) error {
p, err := os.FindProcess(pid)
if err != nil {
return err
}
return p.Kill()
}
// shutdownBudget (var so tests can shrink it) bounds the post-Shutdown exit wait.
var shutdownBudget = 5 * time.Second
func writeStopJSON(w io.Writer, results []stopResult) error {
if results == nil {
results = []stopResult{}
}
output.PrintJson(w, map[string]interface{}{"results": results})
return nil
}
func writeStopText(out, errOut io.Writer, results []stopResult) {
for _, r := range results {
switch r.Status {
case stopStopped:
fmt.Fprintf(out, "Bus stopped for %s (pid=%d)\n", r.AppID, r.PID)
case stopNoBus:
fmt.Fprintf(out, "No bus running for %s\n", r.AppID)
case stopRefused:
fmt.Fprintf(errOut, "Refused stopping %s: %s\n", r.AppID, r.Reason)
case stopErrored:
fmt.Fprintf(errOut, "Error stopping %s: %s\n", r.AppID, r.Reason)
}
}
}
// discoverAppIDs returns appIDs whose bus.alive.lock is held by a live process.
// Cross-platform via lockfile (flock on Unix, LockFileEx on Windows); ignores stale socket files.
func discoverAppIDs() []string {
procs, err := busdiscover.Default().ScanBusProcesses()
if err != nil {
return nil
}
ids := make([]string, 0, len(procs))
for _, p := range procs {
ids = append(ids, p.AppID)
}
return ids
}
+73
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)
}
}
+340
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)
}
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"fmt"
"sort"
"strings"
"github.com/larksuite/cli/errs"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/suggest"
)
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 := suggest.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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
WithHint("Run 'lark-cli event list' to see available keys.")
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"strings"
"testing"
_ "github.com/larksuite/cli/events"
)
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
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)
}
}