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
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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(¤t, "current", false, "Only show status for the current profile's app")
|
||||
cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "Exit 2 when any orphan bus is detected (default: always exit 0)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
type busState int
|
||||
|
||||
const (
|
||||
stateNotRunning busState = iota
|
||||
stateRunning
|
||||
stateOrphan
|
||||
)
|
||||
|
||||
func (s busState) String() string {
|
||||
switch s {
|
||||
case stateRunning:
|
||||
return "running"
|
||||
case stateOrphan:
|
||||
return "orphan"
|
||||
default:
|
||||
return "not_running"
|
||||
}
|
||||
}
|
||||
|
||||
// appStatus bundles one AppID's derived status; State picks which fields are meaningful.
|
||||
type appStatus struct {
|
||||
AppID string
|
||||
State busState
|
||||
PID int
|
||||
UptimeSec int
|
||||
Active int
|
||||
Consumers []protocol.ConsumerInfo
|
||||
}
|
||||
|
||||
type busQuerier interface {
|
||||
QueryBusStatus(appID string) (*protocol.StatusResponse, error)
|
||||
}
|
||||
|
||||
// singleAppScanner wraps a Scanner and filters to one AppID for --current queries.
|
||||
type singleAppScanner struct {
|
||||
appID string
|
||||
inner busdiscover.Scanner
|
||||
}
|
||||
|
||||
func (s singleAppScanner) ScanBusProcesses() ([]busdiscover.Process, error) {
|
||||
if s.inner == nil {
|
||||
return nil, nil
|
||||
}
|
||||
all, err := s.inner.ScanBusProcesses()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := all[:0]
|
||||
for _, p := range all {
|
||||
if p.AppID == s.appID {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type transportQuerier struct {
|
||||
tr transport.IPC
|
||||
}
|
||||
|
||||
func (q *transportQuerier) QueryBusStatus(appID string) (*protocol.StatusResponse, error) {
|
||||
return busctl.QueryStatus(q.tr, appID)
|
||||
}
|
||||
|
||||
func runStatus(f *cmdutil.Factory, current, asJSON, failOnOrphan bool) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seeds := map[string]struct{}{}
|
||||
if current {
|
||||
seeds[cfg.AppID] = struct{}{}
|
||||
} else {
|
||||
for _, id := range discoverAppIDs() {
|
||||
seeds[id] = struct{}{}
|
||||
}
|
||||
// Always include the current profile so a first-time user sees it as not_running.
|
||||
seeds[cfg.AppID] = struct{}{}
|
||||
}
|
||||
seedList := make([]string, 0, len(seeds))
|
||||
for id := range seeds {
|
||||
seedList = append(seedList, id)
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
// --current: scope the scanner to this AppID so unrelated orphans don't surface.
|
||||
var scanner busdiscover.Scanner
|
||||
if current {
|
||||
scanner = singleAppScanner{appID: cfg.AppID, inner: busdiscover.Default()}
|
||||
} else {
|
||||
scanner = busdiscover.Default()
|
||||
}
|
||||
statuses := deriveStatuses(
|
||||
seedList,
|
||||
scanner,
|
||||
&transportQuerier{tr: tr},
|
||||
time.Now(),
|
||||
)
|
||||
|
||||
if asJSON {
|
||||
if err := writeStatusJSON(f.IOStreams.Out, statuses); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
writeStatusText(f.IOStreams.Out, statuses)
|
||||
}
|
||||
return exitForOrphan(statuses, failOnOrphan)
|
||||
}
|
||||
|
||||
// deriveStatuses classifies each AppID as running/orphan/not_running from socket + process-scan inputs; scanner errors are non-fatal.
|
||||
func deriveStatuses(seedAppIDs []string, sc busdiscover.Scanner, q busQuerier, now time.Time) []appStatus {
|
||||
procByAppID := map[string]busdiscover.Process{}
|
||||
if sc != nil {
|
||||
if procs, err := sc.ScanBusProcesses(); err == nil {
|
||||
for _, p := range procs {
|
||||
procByAppID[p.AppID] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ids := map[string]struct{}{}
|
||||
for _, id := range seedAppIDs {
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
for id := range procByAppID {
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
sorted := make([]string, 0, len(ids))
|
||||
for id := range ids {
|
||||
sorted = append(sorted, id)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
|
||||
// Query in parallel so one wedged peer can't compound the per-op deadline across many apps.
|
||||
type probe struct {
|
||||
resp *protocol.StatusResponse
|
||||
err error
|
||||
}
|
||||
probes := make([]probe, len(sorted))
|
||||
var wg sync.WaitGroup
|
||||
for i, appID := range sorted {
|
||||
wg.Add(1)
|
||||
go func(i int, appID string) {
|
||||
defer wg.Done()
|
||||
probes[i].resp, probes[i].err = q.QueryBusStatus(appID)
|
||||
}(i, appID)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
result := make([]appStatus, 0, len(sorted))
|
||||
for i, appID := range sorted {
|
||||
s := appStatus{AppID: appID, State: stateNotRunning}
|
||||
if probes[i].err == nil {
|
||||
resp := probes[i].resp
|
||||
s.State = stateRunning
|
||||
s.PID = resp.PID
|
||||
s.UptimeSec = resp.UptimeSec
|
||||
s.Active = resp.ActiveConns
|
||||
s.Consumers = resp.Consumers
|
||||
} else if p, ok := procByAppID[appID]; ok {
|
||||
s.State = stateOrphan
|
||||
s.PID = p.PID
|
||||
s.UptimeSec = int(now.Sub(p.StartTime).Seconds())
|
||||
}
|
||||
result = append(result, s)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// humanizeDuration formats d as a coarse "N unit ago" string.
|
||||
func humanizeDuration(d time.Duration) string {
|
||||
s := int(d.Seconds())
|
||||
if s < 60 {
|
||||
return fmt.Sprintf("%ds ago", s)
|
||||
}
|
||||
m := s / 60
|
||||
if m < 60 {
|
||||
return fmt.Sprintf("%dm ago", m)
|
||||
}
|
||||
h := m / 60
|
||||
if h < 24 {
|
||||
return fmt.Sprintf("%dh ago", h)
|
||||
}
|
||||
return fmt.Sprintf("%dd ago", h/24)
|
||||
}
|
||||
|
||||
func writeStatusText(out io.Writer, statuses []appStatus) {
|
||||
for i, s := range statuses {
|
||||
if i > 0 {
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
fmt.Fprintf(out, "── %s ──\n", s.AppID)
|
||||
switch s.State {
|
||||
case stateNotRunning:
|
||||
fmt.Fprintln(out, " Bus: not running")
|
||||
case stateRunning:
|
||||
fmt.Fprintf(out, " Bus: running (PID %d, uptime %s)\n",
|
||||
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
||||
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
||||
if len(s.Consumers) > 0 {
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user