Compare commits

...

23 Commits

Author SHA1 Message Date
lvxinsheng
a4c313c8f1 test: assert app-type html filter returns exactly html apps 2026-06-15 11:08:12 +08:00
lvxinsheng
a2c820643d test: add live e2e for apps +list 2026-06-12 20:08:50 +08:00
luozhixiong01
8e60f01474 feat(im): unify sort flags into --sort field and --order direction (#1302)
The 4 im query commands had three inconsistent sort conventions and leaked upstream API jargon (ByCreateTimeAsc, member_count_desc) directly to users. This PR unifies them on a single rule — --sort selects a field, --order selects a direction, both from fixed enums — so an agent only ever picks from an enum, never constructs a string. Old flags (--sort-type, --sort-by, and --sort on messages/threads) are kept as hidden silent aliases (no deprecation warning), so existing scripts keep working byte-for-byte.
2026-06-12 15:27:54 +08:00
JackZhao10086
465c789f7c feat: add --json flag support to auth subcommands (#1431)
* feat: add --json flag support to auth subcommands

* feat(auth/logout): add json output support for logout command

* feat(auth/list): add json output support for auth list command
2026-06-12 15:04:14 +08:00
Yuxuan Zhao
2a7e9c7d0d test(drive): retry duplicate-remote push in live E2E (#1403) 2026-06-12 13:48:19 +08:00
liangshuo-1
76ba6fad4f chore: add CODEOWNERS for internal/ and new skills domains (#1420) 2026-06-12 11:19:25 +08:00
liangshuo-1
510545f1e5 refactor(vc): consolidate note handling back into the vc domain (#1417) 2026-06-12 00:44:35 +08:00
max
c11cf3b716 feat: split note domain (#1345)
Add note shortcuts for note detail and unified transcript retrieval, route vc note detail parsing through the note domain, and update note/vc/minutes skill guidance for normal versus unified transcript handling.

Includes dry-run E2E coverage for the new note shortcuts and documents the remaining live E2E fixture gap.
2026-06-11 22:38:29 +08:00
liangshuo-1
ee2c93efeb chore: release v1.0.52 (#1412) 2026-06-11 22:05:51 +08:00
wangweiming-01
33e459a4de docs: optimize lark-drive skill routing (#1284)
* docs: optimize lark-drive skill routing

Change-Id: I79cebaa3e52b9291c89bdeffb50426e8f0f3bb2b

* docs: refine lark-drive skill guidance

Change-Id: I628291d6d2b60b0baa7202dddbb9a34138a27a3d
2026-06-11 20:19:07 +08:00
dc-bytedance
5aeae2db65 fix: harden riscv64 -race guard and restore Makefile newline
The cherry-picked riscv64 commit derived RACE_FLAG from `go env GOARCH`
via a grep pipeline, which ignores a GOARCH passed on the make command
line (e.g. `make GOARCH=riscv64 unit-test`) since command-line make
variables are not visible to $(shell ...). Switch to a make-native
filter that honors both, and restore the trailing newline the same
commit dropped.
2026-06-11 19:18:33 +08:00
Rocky Zhang
9b39d10203 feat: support riscv64 prebuilt binaries in release and install pipeline 2026-06-11 19:18:33 +08:00
Rocky Zhang
8572a58fda fix: support riscv64 by making -race flag arch-conditional 2026-06-11 19:18:33 +08:00
evandance
9bc66cc445 feat(apps): emit typed error envelopes across the apps domain (#1288) 2026-06-11 19:04:34 +08:00
shifengjuan-dev
e53f9d999e feat(im): add --chat-modes filter to chat search (#1317)
Add a server-side --chat-modes filter to the im +chat-search shortcut so
users can restrict results to regular groups and/or topic groups.

Change-Id: Ia59c2c05fb2e8e45bd741c8531ca0e3ca69de2f3
2026-06-11 16:54:27 +08:00
shifengjuan-dev
ae35b35693 docs(im): document chat.user_setting batch_query/batch_update (#1339)
Add the chat.user_setting resource 

Change-Id: Ifdd163bfa1cdbfcb56cbf12a3f52e40b61d85e2d
2026-06-11 16:52:05 +08:00
fangshuyu-768
c2e617fc96 docs(skills): expand cite user guidance and fix typos (#1394) 2026-06-11 16:40:39 +08:00
liuxinyanglxy
3f77eded9d feat: per-resource subscription identity + Match hook (#1185)
Framework support for resource-scoped event subscriptions, so one
EventKey can fan out into independent per-resource subscription scopes:

- KeyDefinition gains SubscriptionKey / NormalizeParams / Match hooks
- ComputeSubscriptionID derives a dedup identity from (EventKey, sub-key
  params); plumbed through bus Hub, consume loop, and the
  Hello / PreShutdownCheck / ConsumerInfo protocol messages
- add a synchronous Match filter stage before Process
- change PreConsume cleanup to func() error and surface cleanup
  (unsubscribe) failures as WARN with an idempotency note
- adapt minutes/vc/whiteboard PreConsume to the new cleanup signature
- render SubscriptionID / SubscriptionKey in event status & schema output

No domain wires these hooks yet; covered by unit tests using bus/protocol
doubles. (Mail, the original exerciser, is intentionally not included.)

Change-Id: Ifc743f1aa0bc4dff0c8a1e35da24883694fe7699
2026-06-11 16:22:04 +08:00
shifengjuan-dev
e64610f6d2 docs(im): document chat.managers and chat.moderation API resources (#1294)
Add SKILL.md entries for the group manager and group moderation
(speaking-permission) API-meta resources:
- chat.managers.add_managers / delete_managers (指定/删除群管理员)
- chat.moderation.get / update (查询/更新群发言权限)
2026-06-11 15:12:21 +08:00
raistlin042
dfa26c38f6 feat: exclude .git directory from apps +html-publish package (#1396)
* feat: exclude .git from html-publish package walk

* docs: note .git auto-exclusion in html-publish reference

* test: update html-publish e2e for .git exclusion

* docs: simplify .git skip comment in html-publish walker
2026-06-11 14:58:58 +08:00
evandance
154ecdb90f feat(wiki): emit typed error envelopes across the wiki domain (#1350)
Emit structured validation, API, network, file, and internal error envelopes for Wiki shortcuts so users and agents can recover from failed wiki workflows using stable type, subtype, param, and code fields.

Add Wiki domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-11 14:02:29 +08:00
syh-cpdsss
483043c88b fix: parsing empty whiteboard (#1391)
Change-Id: I10082f89c36ed77e77e1d016be263e0f7369b7b3
2026-06-11 11:27:38 +08:00
linchao5102
6d8dc402ac fix: support git credential dry-run (#1390)
* fix: support git credential dry-run

* test: cover git credential dry-run output
2026-06-11 01:49:06 +08:00
147 changed files with 4178 additions and 1227 deletions

30
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,30 @@
/internal/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1
/skills/lark-approval/
/skills/lark-apps/
/skills/lark-attendance/
/skills/lark-base/
/skills/lark-calendar/
/skills/lark-contact/
/skills/lark-doc/
/skills/lark-drive/
/skills/lark-event/
/skills/lark-im/
/skills/lark-mail/
/skills/lark-markdown/
/skills/lark-minutes/
/skills/lark-okr/
/skills/lark-openapi-explorer/
/skills/lark-shared/
/skills/lark-sheets/
/skills/lark-skill-maker/
/skills/lark-slides/
/skills/lark-task/
/skills/lark-vc/
/skills/lark-vc-agent/
/skills/lark-whiteboard/
/skills/lark-wiki/
/skills/lark-workflow-meeting-summary/
/skills/lark-workflow-standup-report/

2
.gitignore vendored
View File

@@ -35,6 +35,8 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
.lark-slides/
/notes/
/minutes/
internal/registry/meta_data.json
cmd/api/download.bin
app.log

View File

@@ -73,20 +73,20 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/)
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
text: errs-no-legacy-helper
linters:
- forbidigo

View File

@@ -17,6 +17,7 @@ builds:
goarch:
- amd64
- arm64
- riscv64
archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"

View File

@@ -11,7 +11,7 @@
```bash
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make test # Full: vet + unit + integration
```

View File

@@ -2,6 +2,30 @@
All notable changes to this project will be documented in this file.
## [v1.0.52] - 2026-06-11
### Features
- **events**: Per-resource subscription identity + Match hook (#1185)
- **apps**: Emit typed error envelopes across the apps domain (#1288)
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
- **im**: Add `--chat-modes` filter to chat search (#1317)
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
### Bug Fixes
- **apps**: Support git credential dry-run (#1390)
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
- **build**: Make `-race` flag arch-conditional to support riscv64
### Documentation
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
- **skills**: Optimize lark-drive skill routing (#1284)
- **skills**: Expand cite user guidance and fix typos (#1394)
## [v1.0.51] - 2026-06-10
### Features
@@ -1106,6 +1130,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49

View File

@@ -8,6 +8,13 @@ DATE := $(shell date +%Y-%m-%d)
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
# over `go env GOARCH`, because command-line make variables are not visible to
# $(shell ...).
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
all: test
@@ -34,7 +41,7 @@ fmt-check:
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
go test -race -gcflags="all=-N -l" -count=1 \
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/...
# examples-build keeps the shipped plugin-SDK examples compilable. If this

View File

@@ -91,6 +91,29 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *CheckOptions
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -109,6 +132,27 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *LogoutOptions
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthListCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -126,6 +170,27 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ListOptions
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -145,6 +210,29 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -267,6 +355,32 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--format", "pretty", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
if gotOpts.Format != "json" {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,

View File

@@ -19,6 +19,7 @@ import (
type CheckOptions struct {
Factory *cmdutil.Factory
Scope string
JSON bool
}
// NewCmdAuthCheck creates the auth check subcommand.
@@ -37,6 +38,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
}
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.MarkFlagRequired("scope")
cmdutil.SetRisk(cmd, "read")

View File

@@ -18,6 +18,7 @@ import (
// ListOptions holds all inputs for auth list.
type ListOptions struct {
Factory *cmdutil.Factory
JSON bool
}
// NewCmdAuthList creates the auth list subcommand.
@@ -34,6 +35,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
return authListRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd
@@ -44,6 +46,14 @@ func authListRun(opts *ListOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_configured",
})
return nil
}
// auth list is a read-only probe; the "configured but no users"
// branch below already returns exit 0 with a stderr hint, so we
// keep the same contract here. We still want the hint to be
@@ -61,6 +71,14 @@ func authListRun(opts *ListOptions) error {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
return nil
}

View File

@@ -4,6 +4,7 @@
package auth
import (
"encoding/json"
"strings"
"testing"
@@ -34,6 +35,33 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
}
}
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
// reason this hint exists workspace-aware in the first place: an AI agent
// in OpenClaw / Hermes that probes auth list before binding gets routed to
@@ -57,3 +85,48 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
t.Errorf("agent hint must not mention config init: %s", out)
}
}
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "No logged-in users") {
t.Errorf("stderr = %q, want no-users hint", stderr.String())
}
}

View File

@@ -18,6 +18,7 @@ import (
// LogoutOptions holds all inputs for auth logout.
type LogoutOptions struct {
Factory *cmdutil.Factory
JSON bool
}
// NewCmdAuthLogout creates the auth logout subcommand.
@@ -34,6 +35,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
return authLogoutRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "write")
return cmd
@@ -44,12 +46,28 @@ func authLogoutRun(opts *LogoutOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_configured",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
return nil
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
return nil
}
@@ -63,6 +81,13 @@ func authLogoutRun(opts *LogoutOptions) error {
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": true,
})
return nil
}
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
return nil
}

147
cmd/auth/logout_test.go Normal file
View File

@@ -0,0 +1,147 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"strings"
"testing"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/zalando/go-keyring"
)
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
t.Helper()
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "test-app",
Apps: []core.AppConfig{
{
AppId: "test-app",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
Users: users,
},
},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != true {
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
}
if _, hasReason := payload["reason"]; hasReason {
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "Logged out") {
t.Errorf("stderr = %q, want success text", stderr.String())
}
}

View File

@@ -19,6 +19,7 @@ type ScopesOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
Format string
JSON bool
}
// NewCmdAuthScopes creates the auth scopes subcommand.
@@ -30,6 +31,9 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
Short: "Query scopes enabled for the app",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
}
if runF != nil {
return runF(opts)
}
@@ -38,6 +42,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd

View File

@@ -17,6 +17,7 @@ import (
type StatusOptions struct {
Factory *cmdutil.Factory
Verify bool
JSON bool
}
// NewCmdAuthStatus creates the auth status subcommand.
@@ -35,6 +36,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
}
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd

View File

@@ -143,6 +143,79 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
}
}
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 2,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB column header: %s", out)
}
if !strings.Contains(out, "alice") {
t.Errorf("missing alice suffix in SUB column: %s", out)
}
if !strings.Contains(out, "bob") {
t.Errorf("missing bob suffix in SUB column: %s", out)
}
}
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 1,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB header: %s", out)
}
if !strings.Contains(out, "-") {
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
}
}
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 1,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB header: %s", out)
}
if !strings.Contains(out, "-") {
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
}
}
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
var buf bytes.Buffer
if err := writeStatusJSON(&buf, []appStatus{

View File

@@ -134,12 +134,16 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
if len(def.Params) > 0 {
fmt.Fprintf(out, "\nParameters:\n")
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
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 = "-"
@@ -148,7 +152,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
if desc == "" {
desc = "-"
}
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc)
}
w.Flush()

View File

@@ -96,6 +96,79 @@ func TestRunSchema_JSONOutput(t *testing.T) {
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
{Name: "folders", Description: "filter only"},
},
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, false); err != nil {
t.Fatalf("runSchema: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "SUB-KEY") {
t.Errorf("missing SUB-KEY column header in:\n%s", out)
}
// Find the mailbox row and verify "yes" is present
var mailboxRow string
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
mailboxRow = ln
break
}
}
if !strings.Contains(mailboxRow, "yes") {
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
}
// Find the folders row and verify "no" is present
var foldersRow string
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
foldersRow = ln
break
}
}
if !strings.Contains(foldersRow, "no") {
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
}
}
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
const syntheticKey = "test.evt_json"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
if !strings.Contains(stdout.String(), `"subscription_key"`) {
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `true`) {
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
}
}
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
const syntheticKey = "t.custom.overlay"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"sort"
"strings"
"sync"
"time"
@@ -242,12 +243,17 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
if len(s.Consumers) > 0 {
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
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),
})

View File

@@ -80,6 +80,7 @@ const (
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read)
SubtypeExternalTool Subtype = "external_tool" // an external tool the CLI shells out to (git, npx) failed at runtime; the tool output is in the message
SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save)
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
)

View File

@@ -13,8 +13,8 @@ import (
const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
@@ -25,10 +25,13 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
return nil, err
}
return func() {
return func() error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
return err
}
return nil
}, nil
}
}

View File

@@ -13,8 +13,8 @@ import (
const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
@@ -25,10 +25,13 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
return nil, err
}
return func() {
return func() error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
return err
}
return nil
}, nil
}
}

View File

@@ -22,8 +22,8 @@ const cleanupTimeout = 5 * time.Second
//
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
@@ -44,10 +44,13 @@ func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, ev
return nil, err
}
return func() {
return func() error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
return err
}
return nil
}, nil
}
}

View File

@@ -262,19 +262,23 @@ func (b *Bus) handleConn(conn net.Conn) {
// handleHello registers a consume connection with the hub; reader carries bytes already pulled off conn.
func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.Hello) {
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID)
subID := hello.SubscriptionID
if subID == "" {
subID = hello.EventKey
}
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID)
bc.SetLogger(b.logger)
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
firstForKey := b.hub.RegisterAndIsFirst(bc)
bc.SetCheckLastForKey(func(eventKey string) bool {
return b.hub.AcquireCleanupLock(eventKey)
bc.SetCheckLastForKey(func(scope string) bool {
return b.hub.AcquireCleanupLock(scope)
})
bc.SetOnClose(func(c *Conn) {
b.hub.UnregisterAndIsLast(c)
// Release is idempotent and must fire on every disconnect path so waiters don't block forever.
b.hub.ReleaseCleanupLock(c.EventKey())
b.hub.ReleaseCleanupLock(c.SubscriptionID())
b.mu.Lock()
delete(b.conns, c)
remaining := len(b.conns)

View File

@@ -33,7 +33,7 @@ func TestRunShutdownWithMultipleConns(t *testing.T) {
server, client := net.Pipe()
pipes = append(pipes, server, client)
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i)
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i, "")
bc.SetLogger(logger)
hub.RegisterAndIsFirst(bc)

View File

@@ -29,9 +29,10 @@ type Conn struct {
writeMu sync.Mutex // serialises all net.Conn writes (Encode+SetWriteDeadline is a 2-call sequence)
eventKey string
eventTypes []string
subID string
pid int
onClose func(*Conn)
checkLastForKey func(eventKey string) bool
checkLastForKey func(scope string) bool
logger *log.Logger
closed chan struct{}
closeOnce sync.Once
@@ -41,7 +42,7 @@ type Conn struct {
}
// NewConn creates a Conn; pass a reader with pre-buffered bytes (handoff from Bus.handleConn) or nil for a fresh one.
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int) *Conn {
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int, subID string) *Conn {
if reader == nil {
reader = bufio.NewReader(conn)
}
@@ -52,10 +53,20 @@ func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []
eventKey: eventKey,
eventTypes: eventTypes,
pid: pid,
subID: subID,
closed: make(chan struct{}),
}
}
// SubscriptionID returns the subscription identity. Falls back to EventKey
// when the stored subID is empty (legacy clients / no-SubscriptionKey EventKeys).
func (c *Conn) SubscriptionID() string {
if c.subID == "" {
return c.eventKey
}
return c.subID
}
func (c *Conn) SetOnClose(fn func(*Conn)) { c.onClose = fn }
// SetCheckLastForKey: returning true means "you are the last subscriber, run cleanup".
@@ -132,13 +143,19 @@ func (c *Conn) ReaderLoop() {
}
func (c *Conn) handleControlMessage(msg interface{}) {
switch m := msg.(type) {
switch msg.(type) {
case *protocol.Bye:
c.shutdown()
case *protocol.PreShutdownCheck:
// Use the connection's own authoritative subscription identity rather
// than recomputing from the incoming message: a stale or mismatched
// PreShutdownCheck must not ask about the wrong scope (which would
// suppress or mistrigger per-subscription cleanup). Conn.SubscriptionID()
// already falls back to EventKey when its stored subID is empty.
scope := c.SubscriptionID()
lastForKey := true
if c.checkLastForKey != nil {
lastForKey = c.checkLastForKey(m.EventKey)
lastForKey = c.checkLastForKey(scope)
}
ack := protocol.NewPreShutdownAck(lastForKey)
if err := c.writeFrame(ack); err != nil && c.logger != nil {

View File

@@ -21,7 +21,7 @@ func TestConn_SenderWritesEvents(t *testing.T) {
defer server.Close()
defer client.Close()
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345)
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345, "")
go bc.SenderLoop()
bc.SendCh() <- &protocol.Event{
@@ -62,7 +62,7 @@ func TestConn_ConcurrentWritesSerialised(t *testing.T) {
defer client.Close()
det := &serializingDetector{Conn: server}
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345)
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345, "")
go func() { _, _ = io.Copy(io.Discard, client) }()
@@ -106,7 +106,7 @@ func TestConn_TrySend_NonEvicting(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "")
for i := 0; i < sendChCap; i++ {
if !bc.TrySend(i) {
@@ -126,7 +126,7 @@ func TestConn_ReaderDetectsEOF(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "")
done := make(chan struct{})
go func() {
@@ -142,3 +142,23 @@ func TestConn_ReaderDetectsEOF(t *testing.T) {
t.Fatal("ReaderLoop did not exit on EOF")
}
}
func TestConn_SubscriptionID(t *testing.T) {
c1, c2 := net.Pipe()
defer c1.Close()
defer c2.Close()
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "mail.x:abc")
if got := conn.SubscriptionID(); got != "mail.x:abc" {
t.Errorf("SubscriptionID() = %q, want %q", got, "mail.x:abc")
}
}
func TestConn_SubscriptionID_EmptyFallsBackToEventKey(t *testing.T) {
c1, c2 := net.Pipe()
defer c1.Close()
defer c2.Close()
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "")
if got := conn.SubscriptionID(); got != "mail.x" {
t.Errorf("SubscriptionID() with empty input = %q, want fallback %q", got, "mail.x")
}
}

View File

@@ -63,3 +63,134 @@ func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) {
t.Errorf("b.conns after failed HelloAck = %d entries, want 0", remaining)
}
}
// TestHandleHello_LegacyClient_FallsBackToEventKey: a Hello with empty
// subscription_id registers under EventKey (today's behavior preserved).
func TestHandleHello_LegacyClient_FallsBackToEventKey(t *testing.T) {
logger := log.New(io.Discard, "", 0)
hub := NewHub()
b := &Bus{
hub: hub,
logger: logger,
conns: make(map[*Conn]struct{}),
idleTimer: time.NewTimer(30 * time.Second),
shutdownCh: make(chan struct{}, 1),
}
server, client := net.Pipe()
defer server.Close()
defer client.Close()
// Legacy client: no subscription_id field (empty string).
hello := &protocol.Hello{
PID: 9999,
EventKey: "im.message",
EventTypes: []string{"im.message.receive_v1"},
SubscriptionID: "", // legacy: empty, should fallback to EventKey
}
br := bufio.NewReader(server)
done := make(chan struct{})
go func() {
b.handleHello(server, br, hello)
close(done)
}()
// Read the HelloAck from client side to let handleHello complete.
clientReader := bufio.NewReader(client)
ackLine, err := clientReader.ReadString('\n')
if err != nil {
t.Fatalf("failed to read HelloAck: %v", err)
}
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("handleHello did not return within 3s")
}
// Assertions: registered under EventKey (not a qualified subscription ID).
if got := hub.ConnCount(); got != 1 {
t.Errorf("hub.ConnCount = %d, want 1", got)
}
if got := hub.EventKeyCount("im.message"); got != 1 {
t.Errorf("hub.EventKeyCount(im.message) = %d, want 1", got)
}
if got := hub.SubCount("im.message"); got != 1 {
t.Errorf("hub.SubCount(im.message) = %d, want 1 (legacy fallback to EventKey)", got)
}
if got := hub.SubCount("im.message:something"); got != 0 {
t.Errorf("hub.SubCount(im.message:something) = %d, want 0 (should not exist)", got)
}
if ackLine == "" {
t.Fatal("HelloAck was empty")
}
}
// TestHandleHello_ModernClient_UsesSubscriptionID: a Hello with
// non-empty subscription_id registers under that ID, not EventKey.
func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) {
logger := log.New(io.Discard, "", 0)
hub := NewHub()
b := &Bus{
hub: hub,
logger: logger,
conns: make(map[*Conn]struct{}),
idleTimer: time.NewTimer(30 * time.Second),
shutdownCh: make(chan struct{}, 1),
}
server, client := net.Pipe()
defer server.Close()
defer client.Close()
// Modern client: subscription_id explicitly set.
subscriptionID := "mail.message:alice@example.com"
hello := &protocol.Hello{
PID: 8888,
EventKey: "mail.message",
EventTypes: []string{"mail.message.receive_v1"},
SubscriptionID: subscriptionID, // modern: per-resource subscription
}
br := bufio.NewReader(server)
done := make(chan struct{})
go func() {
b.handleHello(server, br, hello)
close(done)
}()
// Read the HelloAck from client side to let handleHello complete.
clientReader := bufio.NewReader(client)
ackLine, err := clientReader.ReadString('\n')
if err != nil {
t.Fatalf("failed to read HelloAck: %v", err)
}
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("handleHello did not return within 3s")
}
// Assertions: registered under the subscription_id, not bare EventKey.
if got := hub.ConnCount(); got != 1 {
t.Errorf("hub.ConnCount = %d, want 1", got)
}
if got := hub.EventKeyCount("mail.message"); got != 1 {
t.Errorf("hub.EventKeyCount(mail.message) = %d, want 1", got)
}
if got := hub.SubCount(subscriptionID); got != 1 {
t.Errorf("hub.SubCount(%q) = %d, want 1 (modern: uses SubscriptionID)", subscriptionID, got)
}
if got := hub.SubCount("mail.message"); got != 0 {
t.Errorf("hub.SubCount(mail.message) = %d, want 0 (modern: NOT registered under bare EventKey)", got)
}
if ackLine == "" {
t.Fatal("HelloAck was empty")
}
}

View File

@@ -16,6 +16,9 @@ import (
// Subscriber is the interface a connection must satisfy for Hub registration.
type Subscriber interface {
EventKey() string
// SubscriptionID identifies the per-resource subscription for dedup purposes.
// When no resource qualifier is needed it equals EventKey.
SubscriptionID() string
EventTypes() []string
SendCh() chan interface{}
PID() int
@@ -34,8 +37,11 @@ type Subscriber interface {
type Hub struct {
mu sync.RWMutex
subscribers map[Subscriber]struct{}
keyCounts map[string]int
// cleanupInProgress[key] holds a channel closed on release; presence means a cleanup lock is held.
// subCounts is keyed by SubscriptionID (not EventKey) so that different
// per-resource subscriptions sharing the same EventKey are deduped independently.
subCounts map[string]int
// cleanupInProgress[subscriptionID] holds a channel closed on release;
// presence means a cleanup lock is held for that subscription.
cleanupInProgress map[string]chan struct{}
logger atomic.Pointer[log.Logger]
}
@@ -43,7 +49,7 @@ type Hub struct {
func NewHub() *Hub {
return &Hub{
subscribers: make(map[Subscriber]struct{}),
keyCounts: make(map[string]int),
subCounts: make(map[string]int),
cleanupInProgress: make(map[string]chan struct{}),
}
}
@@ -51,7 +57,7 @@ func NewHub() *Hub {
// SetLogger attaches a logger (nil tolerated).
func (h *Hub) SetLogger(l *log.Logger) { h.logger.Store(l) }
// UnregisterAndIsLast removes s and reports whether it was last for its EventKey; stale unregisters are no-ops.
// UnregisterAndIsLast removes s and reports whether it was last for its SubscriptionID; stale unregisters are no-ops.
func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
h.mu.Lock()
defer h.mu.Unlock()
@@ -59,34 +65,35 @@ func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
return false
}
delete(h.subscribers, s)
h.keyCounts[s.EventKey()]--
isLast := h.keyCounts[s.EventKey()] == 0
sid := s.SubscriptionID()
h.subCounts[sid]--
isLast := h.subCounts[sid] == 0
if isLast {
delete(h.keyCounts, s.EventKey())
delete(h.subCounts, sid)
}
return isLast
}
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for eventKey and no lock is held.
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for subscriptionID and no lock is held.
// Count==0 is rejected (would block future Register calls). On true return, caller MUST Release.
func (h *Hub) AcquireCleanupLock(eventKey string) bool {
func (h *Hub) AcquireCleanupLock(subscriptionID string) bool {
h.mu.Lock()
defer h.mu.Unlock()
if h.keyCounts[eventKey] != 1 {
if h.subCounts[subscriptionID] != 1 {
return false
}
if _, alreadyLocked := h.cleanupInProgress[eventKey]; alreadyLocked {
if _, alreadyLocked := h.cleanupInProgress[subscriptionID]; alreadyLocked {
return false
}
h.cleanupInProgress[eventKey] = make(chan struct{})
h.cleanupInProgress[subscriptionID] = make(chan struct{})
return true
}
// ReleaseCleanupLock is idempotent; OnClose calls unconditionally.
func (h *Hub) ReleaseCleanupLock(eventKey string) {
func (h *Hub) ReleaseCleanupLock(subscriptionID string) {
h.mu.Lock()
ch := h.cleanupInProgress[eventKey]
delete(h.cleanupInProgress, eventKey)
ch := h.cleanupInProgress[subscriptionID]
delete(h.cleanupInProgress, subscriptionID)
h.mu.Unlock()
if ch != nil {
close(ch)
@@ -94,23 +101,24 @@ func (h *Hub) ReleaseCleanupLock(eventKey string) {
}
// RegisterAndIsFirst adds s to the hub and reports whether it's the first
// subscriber for its EventKey. If a cleanup is in progress for
// s.EventKey() (another conn holds the cleanup lock), this waits until
// subscriber for its SubscriptionID. If a cleanup is in progress for
// s.SubscriptionID() (another conn holds the cleanup lock), this waits until
// cleanup releases before registering — closing the PreShutdownCheck ×
// Hello TOCTOU race. The wait releases h.mu before blocking on the
// channel, so concurrent operations on other keys aren't stalled.
// channel, so concurrent operations on other subscriptions aren't stalled.
func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
sid := s.SubscriptionID()
for {
h.mu.Lock()
ch, locked := h.cleanupInProgress[s.EventKey()]
ch, locked := h.cleanupInProgress[sid]
if locked {
h.mu.Unlock()
<-ch // wait for release, then re-check (defensive against races)
continue
}
isFirst := h.keyCounts[s.EventKey()] == 0
isFirst := h.subCounts[sid] == 0
h.subscribers[s] = struct{}{}
h.keyCounts[s.EventKey()]++
h.subCounts[sid]++
h.mu.Unlock()
return isFirst
}
@@ -176,11 +184,25 @@ func (h *Hub) ConnCount() int {
return len(h.subscribers)
}
// EventKeyCount returns the number of subscribers registered for eventKey.
// EventKeyCount returns total subscribers for the given EventKey, aggregating
// across all SubscriptionIDs. For per-subscription counts use SubCount.
func (h *Hub) EventKeyCount(eventKey string) int {
h.mu.RLock()
defer h.mu.RUnlock()
return h.keyCounts[eventKey]
count := 0
for s := range h.subscribers {
if s.EventKey() == eventKey {
count++
}
}
return count
}
// SubCount returns the count of subscribers for the given SubscriptionID.
func (h *Hub) SubCount(subscriptionID string) int {
h.mu.RLock()
defer h.mu.RUnlock()
return h.subCounts[subscriptionID]
}
// BroadcastSourceStatus fans out a source-level status change to every
@@ -205,10 +227,11 @@ func (h *Hub) Consumers() []protocol.ConsumerInfo {
result := make([]protocol.ConsumerInfo, 0, len(h.subscribers))
for s := range h.subscribers {
result = append(result, protocol.ConsumerInfo{
PID: s.PID(),
EventKey: s.EventKey(),
Received: s.Received(),
Dropped: s.DroppedCount(),
PID: s.PID(),
EventKey: s.EventKey(),
SubscriptionID: s.SubscriptionID(),
Received: s.Received(),
Dropped: s.DroppedCount(),
})
}
return result

View File

@@ -17,7 +17,7 @@ func TestHubDroppedCountIncrements(t *testing.T) {
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c)
@@ -35,7 +35,7 @@ func TestPublishAssignsIncrementalSeq(t *testing.T) {
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
c.sendCh = make(chan interface{}, 10)
h.RegisterAndIsFirst(c)
@@ -60,7 +60,7 @@ func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) {
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c)
@@ -87,7 +87,7 @@ func TestPublishSourceTimeTakesPrecedence(t *testing.T) {
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c)
@@ -111,7 +111,7 @@ func TestPublishSourceTimeFallback(t *testing.T) {
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c)

View File

@@ -111,6 +111,7 @@ type alwaysFailSubscriber struct {
}
func (s *alwaysFailSubscriber) EventKey() string { return s.eventKey }
func (s *alwaysFailSubscriber) SubscriptionID() string { return s.eventKey }
func (s *alwaysFailSubscriber) EventTypes() []string { return s.eventTypes }
func (s *alwaysFailSubscriber) SendCh() chan interface{} { return s.sendCh }
func (s *alwaysFailSubscriber) PID() int { return 0 }
@@ -153,6 +154,7 @@ func newRaceSubscriber(key string, types []string, capacity int) *raceSubscriber
}
func (s *raceSubscriber) EventKey() string { return s.eventKey }
func (s *raceSubscriber) SubscriptionID() string { return s.eventKey }
func (s *raceSubscriber) EventTypes() []string { return s.eventTypes }
func (s *raceSubscriber) SendCh() chan interface{} { return s.sendCh }
func (s *raceSubscriber) PID() int { return s.pid }

View File

@@ -5,6 +5,7 @@ package bus
import (
"encoding/json"
"net"
"sync"
"sync/atomic"
"testing"
@@ -235,7 +236,10 @@ func newTestConn(eventKey string, eventTypes []string) *testConn {
}
}
func (c *testConn) EventKey() string { return c.eventKey }
func (c *testConn) EventKey() string { return c.eventKey }
// SubscriptionID falls back to EventKey for test mocks that don't set a separate subscription ID.
func (c *testConn) SubscriptionID() string { return c.eventKey }
func (c *testConn) EventTypes() []string { return c.eventTypes }
func (c *testConn) SendCh() chan interface{} { return c.sendCh }
func (c *testConn) PID() int { return c.pid }
@@ -275,3 +279,79 @@ func (c *testConn) TrySend(msg interface{}) bool {
return false
}
}
func TestHub_SubscriptionID_Isolation(t *testing.T) {
h := NewHub()
c1, _ := net.Pipe()
c2, _ := net.Pipe()
defer c1.Close()
defer c2.Close()
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
if !h.RegisterAndIsFirst(s1) {
t.Error("s1 should be first for its subscription")
}
if !h.RegisterAndIsFirst(s2) {
t.Error("s2 should ALSO be first (different SubscriptionID)")
}
if !h.UnregisterAndIsLast(s1) {
t.Error("s1 should be last for mail.x:alice")
}
if !h.UnregisterAndIsLast(s2) {
t.Error("s2 should be last for mail.x:bob")
}
}
func TestHub_SameSubscriptionID_NotFirst(t *testing.T) {
h := NewHub()
c1, _ := net.Pipe()
c2, _ := net.Pipe()
defer c1.Close()
defer c2.Close()
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:alice")
if !h.RegisterAndIsFirst(s1) {
t.Error("s1 first")
}
if h.RegisterAndIsFirst(s2) {
t.Error("s2 same SubscriptionID should NOT be first")
}
}
func TestHub_EventKeyCount_AggregatesAcrossSubscriptions(t *testing.T) {
h := NewHub()
c1, _ := net.Pipe()
c2, _ := net.Pipe()
defer c1.Close()
defer c2.Close()
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
h.RegisterAndIsFirst(s1)
h.RegisterAndIsFirst(s2)
if got := h.EventKeyCount("mail.x"); got != 2 {
t.Errorf("EventKeyCount(mail.x) = %d, want 2 (aggregated across subscriptions)", got)
}
if got := h.SubCount("mail.x:alice"); got != 1 {
t.Errorf("SubCount(mail.x:alice) = %d, want 1", got)
}
if got := h.SubCount("mail.x:bob"); got != 1 {
t.Errorf("SubCount(mail.x:bob) = %d, want 1", got)
}
}
func TestHub_Consumers_PopulatesSubscriptionID(t *testing.T) {
h := NewHub()
c1, _ := net.Pipe()
defer c1.Close()
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
h.RegisterAndIsFirst(s1)
consumers := h.Consumers()
if len(consumers) != 1 {
t.Fatalf("got %d consumers, want 1", len(consumers))
}
if consumers[0].SubscriptionID != "mail.x:alice" {
t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice")
}
}

View File

@@ -61,6 +61,22 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
}
}
// Normalize params (resolve aliases like "me" -> real email) before fingerprint
// compute, PreConsume, Match, Process. Must happen BEFORE doHello so the
// SubscriptionID we send to bus reflects canonical values.
if keyDef.NormalizeParams != nil {
if err := keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"normalize params for %s: %s", opts.EventKey, err).WithCause(err)
}
}
// Compute subscription identity from normalized params + SubscriptionKey flags.
subscriptionID := ComputeSubscriptionID(keyDef, opts.Params)
if opts.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
@@ -81,13 +97,13 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
}
defer conn.Close()
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType}, subscriptionID)
if err != nil {
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus handshake failed: %s", err).WithCause(err)
}
var cleanup func()
var cleanup func() error
if ack.FirstForKey && keyDef.PreConsume != nil {
if !opts.Quiet {
fmt.Fprintf(errOut, "[event] running pre-consume setup...\n")
@@ -113,14 +129,22 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
if cleanup != nil {
switch {
case r != nil:
fmt.Fprintf(errOut, "WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n", opts.EventKey)
cleanup()
fmt.Fprintf(errOut,
"WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n",
opts.EventKey)
if cleanupErr := cleanup(); cleanupErr != nil {
fmt.Fprintf(errOut,
"WARN: cleanup also failed during panic recovery: %v\n", cleanupErr)
}
case lastForKey:
if !opts.Quiet {
fmt.Fprintf(errOut, "[event] running cleanup...\n")
}
cleanup()
if !opts.Quiet {
if cleanupErr := cleanup(); cleanupErr != nil {
fmt.Fprintf(errOut,
"WARN: cleanup failed: %v (server-side subscribe is idempotent — residual record will be overwritten on next subscribe)\n",
cleanupErr)
} else if !opts.Quiet {
fmt.Fprintf(errOut, "[event] cleanup done.\n")
}
}
@@ -144,7 +168,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
writeReadyMarker(errOut, opts)
return consumeLoop(ctx, conn, br, keyDef, opts, &lastForKey, &emitted)
return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted)
}
func truncateDuration(d time.Duration) time.Duration {

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"net"
"strings"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/transport"
)
// fakeRT is a minimal event.APIClient mock.
type fakeRT struct {
err error
}
func (f *fakeRT) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
return nil, f.err
}
func TestNormalizeParams_ErrorIsWrappedWithEventKey(t *testing.T) {
// Drives the real Run() path: NormalizeParams fails before EnsureBus, so no
// bus is contacted, yet the production error-wrapping is exercised — if Run()
// ever stops wrapping, this test fails.
const key = "test.evt_normalize_fail"
event.RegisterKey(event.KeyDefinition{
Key: key,
EventType: key,
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}},
NormalizeParams: func(_ context.Context, _ event.APIClient, _ map[string]string) error {
return errors.New("simulated normalize failure")
},
})
defer event.UnregisterKeyForTest(key)
err := Run(context.Background(), transport.New(), "app", "", "", Options{
EventKey: key,
Runtime: &fakeRT{},
Quiet: true,
})
if err == nil {
t.Fatal("expected Run to fail when NormalizeParams errors")
}
if !strings.Contains(err.Error(), "normalize params for "+key+":") {
t.Errorf("error not wrapped with EventKey prefix: %v", err)
}
if !strings.Contains(err.Error(), "simulated normalize failure") {
t.Errorf("underlying error not propagated: %v", err)
}
}
func TestDoHello_PassesSubscriptionIDToWire(t *testing.T) {
a, b := net.Pipe()
defer a.Close()
defer b.Close()
// Server-side: read Hello, decode, assert SubscriptionID, send ack
done := make(chan string, 1)
go func() {
br := bufio.NewReader(b)
line, err := protocol.ReadFrame(br)
if err != nil {
done <- "READ_ERR:" + err.Error()
return
}
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
if err != nil {
done <- "DECODE_ERR:" + err.Error()
return
}
if hello, ok := msg.(*protocol.Hello); ok {
done <- hello.SubscriptionID
// send ack so client can return
ack := protocol.NewHelloAck("v1", true)
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
} else {
done <- "WRONG_TYPE"
}
}()
ack, _, err := doHello(a, "mail.x", []string{"mail.x"}, "mail.x:alice")
if err != nil {
t.Fatalf("doHello error: %v", err)
}
if ack == nil {
t.Fatal("got nil ack")
}
got := <-done
if got != "mail.x:alice" {
t.Errorf("Hello.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
}
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"sort"
"github.com/larksuite/cli/internal/event"
)
// ComputeSubscriptionID returns a stable identifier scoped to (EventKey, values
// of the ParamDefs marked SubscriptionKey); the framework uses it to dedup
// PreConsume/cleanup gates and key Hub counts per-subscription. No SubscriptionKey
// params -> returns def.Key verbatim (legacy one-dimensional behavior).
//
// Stability contract: same EventKey + same normalized param values -> same ID
// across CLI versions; changing the encoding requires a wire-format bump.
func ComputeSubscriptionID(def *event.KeyDefinition, params map[string]string) string {
type kv struct {
Name string `json:"name"`
Value string `json:"value"`
}
var subParams []kv
for _, p := range def.Params {
if !p.SubscriptionKey {
continue
}
subParams = append(subParams, kv{Name: p.Name, Value: params[p.Name]})
}
if len(subParams) == 0 {
return def.Key
}
sort.Slice(subParams, func(i, j int) bool { return subParams[i].Name < subParams[j].Name })
raw, _ := json.Marshal(subParams) // err impossible: kv has no unmarshalable fields
sum := sha256.Sum256(raw)
return def.Key + ":" + base64.RawURLEncoding.EncodeToString(sum[:12])
}

View File

@@ -0,0 +1,126 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/event"
)
func TestComputeSubscriptionID(t *testing.T) {
makeDef := func(subKeyNames ...string) *event.KeyDefinition {
def := &event.KeyDefinition{Key: "test.evt"}
marked := make(map[string]bool, len(subKeyNames))
for _, n := range subKeyNames {
marked[n] = true
}
for _, n := range []string{"alpha", "beta", "gamma"} {
def.Params = append(def.Params, event.ParamDef{Name: n, SubscriptionKey: marked[n]})
}
return def
}
t.Run("no SubscriptionKey params returns EventKey verbatim", func(t *testing.T) {
def := makeDef()
got := ComputeSubscriptionID(def, map[string]string{"alpha": "x", "beta": "y"})
if got != "test.evt" {
t.Errorf("got %q, want %q", got, "test.evt")
}
})
t.Run("single SubscriptionKey param: non-sub params do not leak into ID", func(t *testing.T) {
def := makeDef("alpha")
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "ignored"})
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "different"})
if id1 != id2 {
t.Errorf("non-SubscriptionKey param change leaked into ID: %q vs %q", id1, id2)
}
})
t.Run("different SubscriptionKey value produces different ID", func(t *testing.T) {
def := makeDef("alpha")
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "v1"})
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "v2"})
if id1 == id2 {
t.Errorf("different values produced same ID: %q", id1)
}
})
}
func TestComputeSubscriptionID_Stability(t *testing.T) {
// Param order in the ParamDef list must not affect the result (sorted by name internally).
def1 := &event.KeyDefinition{
Key: "test.evt",
Params: []event.ParamDef{
{Name: "b", SubscriptionKey: true},
{Name: "a", SubscriptionKey: true},
},
}
def2 := &event.KeyDefinition{
Key: "test.evt",
Params: []event.ParamDef{
{Name: "a", SubscriptionKey: true},
{Name: "b", SubscriptionKey: true},
},
}
id1 := ComputeSubscriptionID(def1, map[string]string{"a": "1", "b": "2"})
id2 := ComputeSubscriptionID(def2, map[string]string{"a": "1", "b": "2"})
if id1 != id2 {
t.Errorf("order-sensitive: id1=%q id2=%q", id1, id2)
}
}
func TestComputeSubscriptionID_Format(t *testing.T) {
def := &event.KeyDefinition{
Key: "mail.user_mailbox.event.message_received_v1",
Params: []event.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
}
id := ComputeSubscriptionID(def, map[string]string{"mailbox": "liuxinyang@example.com"})
prefix := "mail.user_mailbox.event.message_received_v1:"
if !strings.HasPrefix(id, prefix) {
t.Fatalf("missing prefix: %q", id)
}
suffix := strings.TrimPrefix(id, prefix)
if len(suffix) != 16 {
t.Errorf("fingerprint length = %d, want 16", len(suffix))
}
for _, c := range suffix {
isValid := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'
if !isValid {
t.Errorf("non-base64URL char in fingerprint: %q", suffix)
break
}
}
}
func TestComputeSubscriptionID_UnicodeAndSpecialChars(t *testing.T) {
def := &event.KeyDefinition{
Key: "test.evt",
Params: []event.ParamDef{{Name: "value", SubscriptionKey: true}},
}
for _, val := range []string{"中文", "emoji🚀", "with spaces", "with:colons", "with\"quotes"} {
id := ComputeSubscriptionID(def, map[string]string{"value": val})
if !strings.HasPrefix(id, "test.evt:") || len(id) != len("test.evt:")+16 {
t.Errorf("ID malformed for value=%q: %q (len=%d)", val, id, len(id))
}
}
}
func TestComputeSubscriptionID_EmptyValue(t *testing.T) {
def := &event.KeyDefinition{
Key: "test.evt",
Params: []event.ParamDef{{Name: "x", SubscriptionKey: true}},
}
id1 := ComputeSubscriptionID(def, map[string]string{"x": ""})
id2 := ComputeSubscriptionID(def, map[string]string{}) // missing entirely
if id1 != id2 {
t.Errorf("empty value should be indistinguishable from missing: %q vs %q", id1, id2)
}
id3 := ComputeSubscriptionID(def, map[string]string{"x": "nonempty"})
if id1 == id3 {
t.Errorf("empty and nonempty produced same ID: %q", id1)
}
}

View File

@@ -18,8 +18,8 @@ const helloAckTimeout = 5 * time.Second // symmetric with bus-side hello read de
// doHello returns a bufio.Reader holding any bytes already pulled off conn so events
// buffered with the ack in one TCP segment aren't dropped.
func doHello(conn net.Conn, eventKey string, eventTypes []string) (*protocol.HelloAck, *bufio.Reader, error) {
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1")
func doHello(conn net.Conn, eventKey string, eventTypes []string, subscriptionID string) (*protocol.HelloAck, *bufio.Reader, error) {
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1", subscriptionID)
if err := protocol.EncodeWithDeadline(conn, hello, protocol.WriteTimeout); err != nil {
return nil, nil, err
}

View File

@@ -27,7 +27,7 @@ func TestDoHello_ReadDeadline(t *testing.T) {
start := time.Now()
done := make(chan error, 1)
go func() {
_, _, err := doHello(client, "im.msg", []string{"im.msg"})
_, _, err := doHello(client, "im.msg", []string{"im.msg"}, "")
done <- err
}()

View File

@@ -22,7 +22,7 @@ import (
)
// consumeLoop reads events and dispatches to workers; cancels on terminal sink errors.
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, lastForKey *bool, emitted *atomic.Int64) error {
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, subscriptionID string, lastForKey *bool, emitted *atomic.Int64) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
@@ -185,7 +185,7 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e
close(stopReader)
<-readerDone
conn.SetReadDeadline(time.Time{})
*lastForKey = checkLastForKey(conn, opts.EventKey)
*lastForKey = checkLastForKey(conn, opts.EventKey, subscriptionID)
conn.Close()
case <-allDone:
// bus-side close; can't query, assume last
@@ -199,13 +199,19 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e
// processAndOutput returns (wrote, err); err non-nil only for sink.Write failures.
func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) {
raw := &event.RawEvent{
EventType: evt.EventType,
Payload: evt.Payload,
}
// Synchronous Match filter runs before any work (Process / sink write).
if keyDef.Match != nil && !keyDef.Match(raw, opts.Params) {
return false, nil
}
var result json.RawMessage
if keyDef.Process != nil {
raw := &event.RawEvent{
EventType: evt.EventType,
Payload: evt.Payload,
}
var err error
result, err = keyDef.Process(ctx, opts.Runtime, raw, opts.Params)
if err != nil {

View File

@@ -89,7 +89,7 @@ func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
if err != nil {
t.Fatalf("consumeLoop: %v", err)
}
@@ -132,7 +132,7 @@ func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
t.Fatalf("consumeLoop: %v", err)
}
if got := emitted.Load(); got != 2 {
@@ -169,7 +169,7 @@ func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
t.Fatalf("consumeLoop: %v", err)
}
if got := emitted.Load(); got != 1 {
@@ -196,12 +196,96 @@ func TestConsumeLoop_CompileJQFailsEarly(t *testing.T) {
var lastForKey bool
var emitted atomic.Int64
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
if err == nil {
t.Fatal("consumeLoop should fail immediately on bad jq expression")
}
}
// captureSink is a minimal Sink for unit-testing processAndOutput directly.
type captureSink struct {
written []json.RawMessage
}
func (s *captureSink) Write(data json.RawMessage) error {
s.written = append(s.written, data)
return nil
}
func TestProcessAndOutput_Match_DropsEvent(t *testing.T) {
calledProcess := false
keyDef := &event.KeyDefinition{
Key: "test.evt",
Match: func(raw *event.RawEvent, params map[string]string) bool {
return false
},
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
calledProcess = true
return json.RawMessage(`{}`), nil
},
}
sink := &captureSink{}
wrote, err := processAndOutput(context.Background(), keyDef,
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
Options{}, sink, nil)
if err != nil {
t.Fatal(err)
}
if wrote {
t.Error("Match returned false but event was written")
}
if calledProcess {
t.Error("Process was called even though Match returned false")
}
if len(sink.written) != 0 {
t.Errorf("sink received %d events, want 0", len(sink.written))
}
}
func TestProcessAndOutput_Match_NilAcceptsAll(t *testing.T) {
keyDef := &event.KeyDefinition{Key: "test.evt"} // no Match, no Process
sink := &captureSink{}
wrote, err := processAndOutput(context.Background(), keyDef,
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
Options{}, sink, nil)
if err != nil || !wrote {
t.Errorf("expected wrote=true err=nil; got wrote=%v err=%v", wrote, err)
}
if len(sink.written) != 1 {
t.Errorf("sink received %d events, want 1", len(sink.written))
}
}
func TestProcessAndOutput_Match_RunsBeforeProcess(t *testing.T) {
// Record the actual call sequence — a bare call-count check would still
// pass if Process ran before Match.
var order []string
keyDef := &event.KeyDefinition{
Key: "test.evt",
Match: func(raw *event.RawEvent, params map[string]string) bool {
order = append(order, "match")
return true
},
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
order = append(order, "process")
return raw.Payload, nil
},
}
sink := &captureSink{}
wrote, err := processAndOutput(context.Background(), keyDef,
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{}`)},
Options{}, sink, nil)
if err != nil {
t.Fatal(err)
}
if !wrote {
t.Error("expected wrote=true")
}
if len(order) != 2 || order[0] != "match" || order[1] != "process" {
t.Errorf("call order = %v, want [match process]", order)
}
}
func TestIsTerminalSinkError(t *testing.T) {
for _, tc := range []struct {
name string

View File

@@ -16,8 +16,8 @@ const preShutdownAckTimeout = 2 * time.Second
// checkLastForKey atomically reserves a cleanup lock; on any error defaults to true
// (cleanup-on-error is safer than leaking server state). Discards non-ack frames in flight.
func checkLastForKey(conn net.Conn, eventKey string) bool {
msg := protocol.NewPreShutdownCheck(eventKey)
func checkLastForKey(conn net.Conn, eventKey string, subscriptionID string) bool {
msg := protocol.NewPreShutdownCheck(eventKey, subscriptionID)
if err := protocol.EncodeWithDeadline(conn, msg, protocol.WriteTimeout); err != nil {
return true
}

View File

@@ -4,6 +4,8 @@
package consume
import (
"bufio"
"bytes"
"encoding/json"
"io"
"net"
@@ -38,7 +40,7 @@ func TestCheckLastForKey_IgnoresNonAckFrames(t *testing.T) {
}
}()
got := checkLastForKey(client, "im.msg")
got := checkLastForKey(client, "im.msg", "")
if got != false {
t.Errorf("checkLastForKey = %v, want false", got)
}
@@ -62,7 +64,7 @@ func TestCheckLastForKey_ReturnsAckValue(t *testing.T) {
_ = protocol.Encode(server, ack)
}()
got := checkLastForKey(client, "im.msg")
got := checkLastForKey(client, "im.msg", "")
if got != true {
t.Errorf("checkLastForKey = %v, want true", got)
}
@@ -83,7 +85,7 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
}()
start := time.Now()
got := checkLastForKey(client, "im.msg")
got := checkLastForKey(client, "im.msg", "")
elapsed := time.Since(start)
if got != true {
@@ -93,3 +95,39 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
t.Errorf("elapsed = %v, expected ~%v (timeout-bounded)", elapsed, preShutdownAckTimeout)
}
}
func TestCheckLastForKey_SendsSubscriptionID(t *testing.T) {
a, b := net.Pipe()
defer a.Close()
defer b.Close()
done := make(chan string, 1)
go func() {
br := bufio.NewReader(b)
line, err := protocol.ReadFrame(br)
if err != nil {
done <- "READ_ERR"
return
}
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
if err != nil {
done <- "DECODE_ERR"
return
}
check, ok := msg.(*protocol.PreShutdownCheck)
if !ok {
done <- "WRONG_TYPE"
return
}
done <- check.SubscriptionID
// Reply with ack so client returns
ack := protocol.NewPreShutdownAck(true)
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
}()
_ = checkLastForKey(a, "mail.x", "mail.x:alice")
got := <-done
if got != "mail.x:alice" {
t.Errorf("PreShutdownCheck.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
}
}

View File

@@ -77,3 +77,88 @@ func TestDecodeUnknownType(t *testing.T) {
t.Error("expected error for unknown type")
}
}
func TestEncodeDecodeHello_WithSubscriptionID(t *testing.T) {
msg := &Hello{
Type: MsgTypeHello,
PID: 12345,
EventKey: "mail.user_mailbox.event.message_received_v1",
EventTypes: []string{"mail.user_mailbox.event.message_received_v1"},
Version: "v1",
SubscriptionID: "mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs",
}
buf := &bytes.Buffer{}
if err := Encode(buf, msg); err != nil {
t.Fatal(err)
}
line := buf.Bytes()
if !bytes.Contains(line, []byte(`"subscription_id":"mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs"`)) {
t.Errorf("subscription_id not serialized: %s", string(line))
}
decoded, err := Decode(bytes.TrimRight(line, "\n"))
if err != nil {
t.Fatal(err)
}
hello, ok := decoded.(*Hello)
if !ok {
t.Fatalf("expected *Hello, got %T", decoded)
}
if hello.SubscriptionID != msg.SubscriptionID {
t.Errorf("roundtrip subscription_id: got %q want %q", hello.SubscriptionID, msg.SubscriptionID)
}
}
func TestEncodeDecodeHello_EmptySubscriptionIDOmitted(t *testing.T) {
msg := &Hello{
Type: MsgTypeHello,
PID: 1,
EventKey: "k",
EventTypes: []string{"k"},
Version: "v1",
}
buf := &bytes.Buffer{}
if err := Encode(buf, msg); err != nil {
t.Fatal(err)
}
if bytes.Contains(buf.Bytes(), []byte("subscription_id")) {
t.Errorf("empty subscription_id should be omitted: %s", buf.String())
}
decoded, _ := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
hello := decoded.(*Hello)
if hello.SubscriptionID != "" {
t.Errorf("got %q, want empty", hello.SubscriptionID)
}
}
func TestEncodeDecodePreShutdownCheck_WithSubscriptionID(t *testing.T) {
msg := &PreShutdownCheck{
Type: MsgTypePreShutdownCheck,
EventKey: "mail.x",
SubscriptionID: "mail.x:abc",
}
buf := &bytes.Buffer{}
if err := Encode(buf, msg); err != nil {
t.Fatal(err)
}
decoded, err := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
if err != nil {
t.Fatal(err)
}
got := decoded.(*PreShutdownCheck)
if got.SubscriptionID != msg.SubscriptionID {
t.Errorf("roundtrip: got %q want %q", got.SubscriptionID, msg.SubscriptionID)
}
}
func TestStatusResponse_ConsumerInfo_SubscriptionID(t *testing.T) {
msg := NewStatusResponse(7, 120, 1, []ConsumerInfo{
{PID: 99, EventKey: "mail.x", SubscriptionID: "mail.x:abc", Received: 5, Dropped: 0},
})
buf := &bytes.Buffer{}
if err := Encode(buf, msg); err != nil {
t.Fatal(err)
}
if !bytes.Contains(buf.Bytes(), []byte(`"subscription_id":"mail.x:abc"`)) {
t.Errorf("ConsumerInfo.SubscriptionID missing from JSON: %s", buf.String())
}
}

View File

@@ -34,11 +34,12 @@ type SourceStatus struct {
}
type Hello struct {
Type string `json:"type"`
PID int `json:"pid"`
EventKey string `json:"event_key"`
EventTypes []string `json:"event_types"`
Version string `json:"version"`
Type string `json:"type"`
PID int `json:"pid"`
EventKey string `json:"event_key"`
EventTypes []string `json:"event_types"`
Version string `json:"version"`
SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey on bus side
}
type HelloAck struct {
@@ -61,10 +62,11 @@ type Bye struct {
Type string `json:"type"`
}
// PreShutdownCheck atomically reserves the cleanup lock for EventKey.
// PreShutdownCheck atomically reserves the cleanup lock for (EventKey, SubscriptionID).
type PreShutdownCheck struct {
Type string `json:"type"`
EventKey string `json:"event_key"`
Type string `json:"type"`
EventKey string `json:"event_key"`
SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey
}
type PreShutdownAck struct {
@@ -77,10 +79,11 @@ type StatusQuery struct {
}
type ConsumerInfo struct {
PID int `json:"pid"`
EventKey string `json:"event_key"`
Received int64 `json:"received"`
Dropped int64 `json:"dropped"`
PID int `json:"pid"`
EventKey string `json:"event_key"`
SubscriptionID string `json:"subscription_id,omitempty"`
Received int64 `json:"received"`
Dropped int64 `json:"dropped"`
}
type StatusResponse struct {
@@ -95,13 +98,14 @@ type Shutdown struct {
Type string `json:"type"`
}
func NewHello(pid int, eventKey string, eventTypes []string, version string) *Hello {
func NewHello(pid int, eventKey string, eventTypes []string, version string, subscriptionID string) *Hello {
return &Hello{
Type: MsgTypeHello,
PID: pid,
EventKey: eventKey,
EventTypes: eventTypes,
Version: version,
Type: MsgTypeHello,
PID: pid,
EventKey: eventKey,
EventTypes: eventTypes,
Version: version,
SubscriptionID: subscriptionID,
}
}
@@ -124,8 +128,8 @@ func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.Ra
}
}
func NewPreShutdownCheck(eventKey string) *PreShutdownCheck {
return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey}
func NewPreShutdownCheck(eventKey, subscriptionID string) *PreShutdownCheck {
return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey, SubscriptionID: subscriptionID}
}
func NewPreShutdownAck(lastForKey bool) *PreShutdownAck {

View File

@@ -17,7 +17,7 @@ import (
// Every NewXxx helper must set the Type discriminator (Decode rejects messages without it).
func TestConstructors_PinTypeField(t *testing.T) {
if got := NewHello(1, "k", []string{"t"}, "v1"); got.Type != MsgTypeHello {
if got := NewHello(1, "k", []string{"t"}, "v1", ""); got.Type != MsgTypeHello {
t.Errorf("NewHello.Type = %q, want %q", got.Type, MsgTypeHello)
}
if got := NewHelloAck("v1", true); got.Type != MsgTypeHelloAck || !got.FirstForKey {
@@ -26,7 +26,7 @@ func TestConstructors_PinTypeField(t *testing.T) {
if got := NewEvent("im.msg", "e1", "", 7, json.RawMessage(`{}`)); got.Type != MsgTypeEvent || got.Seq != 7 {
t.Errorf("NewEvent mismatch: %+v", got)
}
if got := NewPreShutdownCheck("k"); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" {
if got := NewPreShutdownCheck("k", ""); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" {
t.Errorf("NewPreShutdownCheck mismatch: %+v", got)
}
if got := NewPreShutdownAck(true); got.Type != MsgTypePreShutdownAck || !got.LastForKey {
@@ -63,7 +63,7 @@ func TestEncode_DecodeRoundtripAllTypes(t *testing.T) {
}
}
roundtrip(t, NewHelloAck("v1", true), &HelloAck{})
roundtrip(t, NewPreShutdownCheck("im.msg"), &PreShutdownCheck{})
roundtrip(t, NewPreShutdownCheck("im.msg", ""), &PreShutdownCheck{})
roundtrip(t, NewPreShutdownAck(false), &PreShutdownAck{})
roundtrip(t, NewStatusQuery(), &StatusQuery{})
roundtrip(t, NewStatusResponse(7, 120, 1, []ConsumerInfo{{PID: 99, EventKey: "k"}}), &StatusResponse{})

View File

@@ -55,6 +55,23 @@ type ParamDef struct {
Default string `json:"default,omitempty"`
Description string `json:"description"`
Values []ParamValue `json:"values,omitempty"`
// SubscriptionKey marks this param as part of the subscription identity.
// Two consumers of the same EventKey but different values for any
// SubscriptionKey-marked param are treated as DISTINCT subscriptions:
// PreConsume runs once per (EventKey, SubscriptionID), cleanup runs once per
// (EventKey, SubscriptionID).
//
// CONTRACT: only mark a param SubscriptionKey if the EventKey's server-side
// subscribe/unsubscribe API is itself scoped to that resource. Lark keys the
// subscription record by (app, user, event_type) and overwrites it rather
// than reference-counting, so for a non-per-resource API the cleanup of one
// resource's last consumer unsubscribes the shared record and silently cuts
// off every other resource sharing that event_type.
//
// Default false = the param is a filter / formatting / metadata param
// and does not affect subscription identity.
SubscriptionKey bool `json:"subscription_key,omitempty"`
}
type ProcessFunc = func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error)
@@ -83,10 +100,44 @@ type KeyDefinition struct {
Schema SchemaDef `json:"schema"`
// NormalizeParams canonicalizes param values BEFORE fingerprint compute,
// PreConsume, Match, and Process. Mutates the params map in place.
// May call OAPI; runs once per consumer at startup.
//
// Use cases: resolve aliases ("me" -> real email, a name -> an ID),
// trim whitespace. On error, consume fails (no retry); caller gets the
// wrapped error.
//
// Default nil = no normalization, params pass through unchanged.
NormalizeParams func(ctx context.Context, rt APIClient, params map[string]string) error `json:"-"`
// Process required when Schema.Custom is Processed output; must be nil when Native is used.
//
// Convention: returning (nil, nil) signals "drop this event" — the
// consumer loop will skip writing it to sink and not advance the
// emitted counter. Useful for async filtering (e.g. fetch metadata,
// drop if folder doesn't match). For sync filters that don't need
// OAPI, use Match instead.
Process func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) `json:"-"`
PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func(), err error) `json:"-"`
// Match is a synchronous payload filter run on every received event
// BEFORE Process. Return false to drop the event without further work.
//
// Signature deliberately omits ctx/rt to physically enforce "no OAPI
// calls in Match". For filters that need a metadata fetch first, use
// Process and return nil to drop.
//
// Default nil = accept all events.
Match func(raw *RawEvent, params map[string]string) bool `json:"-"`
// PreConsume runs once per (EventKey, SubscriptionID) when this consumer
// is first for that scope. Returns a cleanup function that the framework
// invokes when this consumer is the last for its scope.
//
// The cleanup's error return is honored: on nil the framework prints
// "[event] cleanup done."; on non-nil it prints a WARN with an
// idempotency note.
PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func() error, err error) `json:"-"`
Scopes []string `json:"scopes,omitempty"`

View File

@@ -18,6 +18,7 @@ var migratedCommonHelperPaths = []string{
"cmd/event/",
"events/",
"internal/event/consume/",
"shortcuts/apps/",
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",
@@ -33,6 +34,7 @@ var migratedCommonHelperPaths = []string{
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/wiki/",
}
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"

View File

@@ -19,6 +19,7 @@ var migratedEnvelopePaths = []string{
"cmd/event/",
"events/",
"internal/event/consume/",
"shortcuts/apps/",
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",
@@ -34,6 +35,7 @@ var migratedEnvelopePaths = []string{
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/wiki/",
"shortcuts/im/",
}

View File

@@ -960,6 +960,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
"shortcuts/slides/slides_create.go",
"shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_query.go",
"shortcuts/wiki/wiki_node_get.go",
}
for _, path := range paths {
for _, helper := range helpers {
@@ -1076,6 +1077,23 @@ func boom() {
}
}
func TestCheckNoLegacyCommonHelperCall_CoversWikiPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/wiki/wiki_node_get.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on wiki path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package contact

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.51",
"version": "1.0.52",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
@@ -15,7 +15,8 @@
],
"cpu": [
"x64",
"arm64"
"arm64",
"riscv64"
],
"engines": {
"node": ">=16"

View File

@@ -33,6 +33,7 @@ build_target darwin arm64
build_target linux amd64
build_target darwin amd64
build_target linux arm64
build_target linux riscv64
build_target windows amd64
build_target windows arm64
@@ -55,6 +56,7 @@ const platformMap = {
const archMap = {
x64: "amd64",
arm64: "arm64",
riscv64: "riscv64",
};
const platform = platformMap[process.platform];

View File

@@ -30,6 +30,7 @@ const PLATFORM_MAP = {
const ARCH_MAP = {
x64: "amd64",
arm64: "arm64",
riscv64: "riscv64",
};
const platform = PLATFORM_MAP[process.platform];

View File

@@ -9,7 +9,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -32,7 +31,7 @@ var AppsAccessScopeGet = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
return nil
},

View File

@@ -10,7 +10,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -45,7 +44,7 @@ var AppsAccessScopeSet = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
return validateAccessScopeFlags(rctx)
},
@@ -90,36 +89,42 @@ func validateAccessScopeFlags(rctx *common.RuntimeContext) error {
switch scope {
case "specific":
if targets == "" {
return output.ErrValidation("--targets is required when --scope=specific")
return appsValidationParamError("--targets", "--targets is required when --scope=specific")
}
if err := validateTargetsJSON(targets); err != nil {
return err
}
if approver != "" && !applyEnabled {
return output.ErrValidation("--approver requires --apply-enabled")
return appsValidationParamError("--approver", "--approver requires --apply-enabled")
}
if requireLogin {
return output.ErrValidation("--require-login is not allowed when --scope=specific")
return appsValidationParamError("--require-login", "--require-login is not allowed when --scope=specific")
}
case "public":
if targets != "" {
return output.ErrValidation("--targets is not allowed when --scope=public")
return appsValidationParamError("--targets", "--targets is not allowed when --scope=public")
}
if applyEnabled {
return output.ErrValidation("--apply-enabled is not allowed when --scope=public")
return appsValidationParamError("--apply-enabled", "--apply-enabled is not allowed when --scope=public")
}
if approver != "" {
return output.ErrValidation("--approver is not allowed when --scope=public")
return appsValidationParamError("--approver", "--approver is not allowed when --scope=public")
}
if !rctx.Cmd.Flags().Changed("require-login") {
return output.ErrValidation("--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
return appsValidationParamError("--require-login", "--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
}
case "tenant":
if targets != "" || applyEnabled || approver != "" || requireLogin {
return output.ErrValidation("no extra flags allowed when --scope=tenant")
return appsValidationError("no extra flags allowed when --scope=tenant").
WithParams(
appsInvalidParam("--targets", "not allowed when --scope=tenant"),
appsInvalidParam("--apply-enabled", "not allowed when --scope=tenant"),
appsInvalidParam("--approver", "not allowed when --scope=tenant"),
appsInvalidParam("--require-login", "not allowed when --scope=tenant"),
)
}
default:
return output.ErrValidation("--scope must be specific / public / tenant")
return appsValidationParamError("--scope", "--scope must be specific / public / tenant")
}
return nil
}
@@ -127,18 +132,18 @@ func validateAccessScopeFlags(rctx *common.RuntimeContext) error {
func validateTargetsJSON(targetsJSON string) error {
var items []map[string]interface{}
if err := json.Unmarshal([]byte(targetsJSON), &items); err != nil {
return output.ErrValidation("--targets is not valid JSON: %v", err)
return appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err)
}
if len(items) == 0 {
return output.ErrValidation("--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
return appsValidationParamError("--targets", "--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
}
for i, t := range items {
typ, _ := t["type"].(string)
if !allowedAccessTargetTypes[typ] {
return output.ErrValidation("--targets[%d].type %q must be one of: user / department / chat", i, typ)
return appsValidationParamError("--targets", "--targets[%d].type %q must be one of: user / department / chat", i, typ)
}
if id, _ := t["id"].(string); strings.TrimSpace(id) == "" {
return output.ErrValidation("--targets[%d].id is empty", i)
return appsValidationParamError("--targets", "--targets[%d].id is empty", i)
}
}
return nil
@@ -157,7 +162,7 @@ func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{},
scope := rctx.Str("scope")
enum, ok := scopeStringToServerEnum[scope]
if !ok {
return nil, output.ErrValidation("--scope must be specific / public / tenant, got %q", scope)
return nil, appsValidationParamError("--scope", "--scope must be specific / public / tenant, got %q", scope)
}
body := map[string]interface{}{"scope": enum}
@@ -166,7 +171,7 @@ func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{},
// 用户传统一格式 [{type:user|department|chat, id:...}]body 里拆 3 个并列数组发后端。
var targets []map[string]interface{}
if err := json.Unmarshal([]byte(rctx.Str("targets")), &targets); err != nil {
return nil, output.ErrValidation("--targets is not valid JSON: %v", err)
return nil, appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err)
}
users, departments, chats := splitAccessScopeTargets(targets)
if len(users) > 0 {

View File

@@ -9,7 +9,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -43,14 +42,14 @@ var AppsChat = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
if strings.TrimSpace(rctx.Str("session-id")) == "" {
return output.ErrValidation("--session-id is required")
return appsValidationParamError("--session-id", "--session-id is required")
}
// Do not echo --message content in the error (spec §4 redaction).
if strings.TrimSpace(rctx.Str("message")) == "" {
return output.ErrValidation("--message is required")
return appsValidationParamError("--message", "--message is required")
}
return nil
},

View File

@@ -9,7 +9,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -36,7 +35,7 @@ var AppsCreate = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("name")) == "" {
return output.ErrValidation("--name is required")
return appsValidationParamError("--name", "--name is required")
}
return nil
},

View File

@@ -12,6 +12,7 @@ import (
"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/httpmock"
@@ -47,6 +48,31 @@ func runAppsShortcut(t *testing.T, sc common.Shortcut, args []string, factory *c
return parent.ExecuteContext(context.Background())
}
func requireAppsProblem(t *testing.T, err error, category errs.Category) *errs.Problem {
t.Helper()
if err == nil {
t.Fatalf("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != category {
t.Fatalf("error category = %q, want %q", p.Category, category)
}
return p
}
func requireAppsValidationProblem(t *testing.T, err error) *errs.Problem {
t.Helper()
return requireAppsProblem(t, err, errs.CategoryValidation)
}
func requireAppsAPIProblem(t *testing.T, err error) *errs.Problem {
t.Helper()
return requireAppsProblem(t, err, errs.CategoryAPI)
}
// +create 测试
func TestAppsCreate_Success(t *testing.T) {

View File

@@ -31,8 +31,9 @@ import (
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
//
// 失败语义server 多语句失败仍返 code:0把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
// 后升级成 typed api_errorexit 非 0、detail 带 statement_index / completed / rolled_back
// 避免 agent 误判 ok:true 假成功。CLI 永远 DBA 模式transactional=false失败前的语句已 auto-commit
// 后按 partial failure 上报exit 非 0stdout 输出 ok:false 数据,带 results /
// statement_index / error_code / error_message / rolled_back / note避免 agent 误判
// ok:true 假成功。CLI 永远 DBA 模式transactional=false失败前的语句已 auto-commit
// 落地,故 rolled_back=false真机 boe 实证)。
//
// JSON envelope成功路径CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。
@@ -68,19 +69,27 @@ var AppsDBExecute = common.Shortcut{
sql := strings.TrimSpace(rctx.Str("sql"))
file := strings.TrimSpace(rctx.Str("file"))
if sql != "" && file != "" {
return output.ErrValidation("--sql and --file are mutually exclusive")
return appsValidationError("--sql and --file are mutually exclusive").
WithParams(
appsInvalidParam("--sql", "mutually exclusive with --file"),
appsInvalidParam("--file", "mutually exclusive with --sql"),
)
}
if file != "" {
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
if err != nil {
return output.ErrValidation("--file: %v", err)
return appsValidationParamError("--file", "--file: %v", err).WithCause(err)
}
// 归一化:把文件内容写回 --sql下游DryRun/Execute统一从 sql 取。
rctx.Cmd.Flags().Set("sql", string(data))
sql = strings.TrimSpace(string(data))
}
if sql == "" {
return output.ErrValidation("one of --sql or --file is required (use --sql - to read stdin)")
return appsValidationError("one of --sql or --file is required (use --sql - to read stdin)").
WithParams(
appsInvalidParam("--sql", "one of --sql or --file is required"),
appsInvalidParam("--file", "one of --sql or --file is required"),
)
}
return nil
},
@@ -113,13 +122,15 @@ var AppsDBExecute = common.Shortcut{
data := map[string]interface{}{"results": stmts}
// 多语句 / 单语句失败server 仍返 code:0把失败语句标成 ERROR 哨兵塞进 result。
// 升级成 typed api_errorexit 非 0别让 agent 误判 ok:true 假成功。
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout人看再返回 errorenvelope→stderr
// 已落地的前序语句 + 失败语句构成 partial failure逐条结果作为 ok:false 数据
// 留在 stdout机器可读+ 非零退出信号,别让 agent 误判 ok:true 假成功
// pretty 模式 stdout 只打逐条 ✓/✗ 摘要(不再叠一份 JSON envelope仅返回退出信号。
if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
if rctx.Format == "pretty" {
renderSQLPretty(rctx.IO().Out, stmts)
return output.PartialFailure(output.ExitAPI)
}
return sqlStatementError(stmts, errIdx, errStmt)
return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
@@ -140,31 +151,28 @@ func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interfac
return 0, nil, false
}
// sqlStatementError 把 ERROR 哨兵升级成 typed api_error
// sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据
//
// CLI 永远 DBA 模式transactional=false真机 boe 实证:失败语句之前的语句已逐条 auto-commit
// 落地,不存在外层事务回滚。因此 rolled_back=false、completed 列出已落地的前序语句hint 提示用户
// 别整批重跑(否则会重复写入)。
func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error {
// 落地,不存在外层事务回滚。因此 rolled_back=false、results 含全部逐条结果ERROR 哨兵在
// 失败位置note 提示用户别整批重跑(否则会重复写入)。
func sqlStatementFailurePayload(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) map[string]interface{} {
code, msg := parseErrorSentinel(common.GetString(errStmt, "data"))
stmtNo := errIdx + 1 // 1-based 给人看
fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts))
apiErr := output.ErrAPI(code, fullMsg, map[string]interface{}{
"statement_index": errIdx,
"completed": stmts[:errIdx],
"rolled_back": false,
})
if apiErr.Detail != nil {
if errIdx > 0 {
apiErr.Detail.Hint = fmt.Sprintf(
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
errIdx, stmtNo)
} else {
apiErr.Detail.Hint = "no statements were applied; fix the SQL and re-run."
}
note := "no statements were applied; fix the SQL and re-run."
if errIdx > 0 {
note = fmt.Sprintf(
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
errIdx, stmtNo)
}
return map[string]interface{}{
"results": stmts,
"statement_index": errIdx,
"error_code": code,
"error_message": fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)),
"rolled_back": false,
"note": note,
}
return apiErr
}
// parseErrorSentinel 解析 ERROR 哨兵的 data`{code,message}` JSON返回数值 code 与 message。

View File

@@ -495,9 +495,9 @@ func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *t
}
}
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → typed api_error」:
// json 默认不再打 ok:true 假成功,而是返回 *output.ExitErrortype=api_error、非零 exit
// detail 带 statement_index / completed / rolled_back。rolled_back=false 因 CLI 永远 DBA 模式
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → partial failure」:
// 逐条结果 + statement_index / error_code / rolled_back / note 作为 ok:false 数据落 stdout
// 退出信号是 PartialFailureError非零 exit。rolled_back=false 因 CLI 永远 DBA 模式
// (真机 boe 实证:失败前的语句已落地)。
func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
@@ -518,45 +518,64 @@ func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("multi-statement failure must return a typed error; stdout:\n%s", stdout.String())
t.Fatalf("multi-statement failure must return a partial-failure error; stdout:\n%s", stdout.String())
}
// json 失败路径不得打成功 envelope。
if strings.Contains(stdout.String(), `"ok": true`) {
t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
}
if exitErr.Detail.Type != "api_error" {
t.Errorf("error.type = %q, want api_error", exitErr.Detail.Type)
if pfErr.Code != output.ExitAPI {
t.Errorf("exit = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
if exitErr.Detail.Code != 1300002 {
t.Errorf("error.code = %d, want 1300002", exitErr.Detail.Code)
payload := decodePartialFailureData(t, stdout.String())
if got := payload["statement_index"]; got != float64(1) {
t.Errorf("statement_index = %v, want 1", got)
}
if !strings.Contains(exitErr.Detail.Message, "(at statement 2 of 2)") {
t.Errorf("error.message missing statement locator: %q", exitErr.Detail.Message)
if got := payload["error_code"]; got != float64(1300002) {
t.Errorf("error_code = %v, want 1300002", got)
}
if output.ExitCodeOf(err) != output.ExitAPI {
t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
msg, _ := payload["error_message"].(string)
if !strings.Contains(msg, "(at statement 2 of 2)") {
t.Errorf("error_message missing statement locator: %q", msg)
}
detail, ok := exitErr.Detail.Detail.(map[string]interface{})
if !ok {
t.Fatalf("error.detail not a map: %T", exitErr.Detail.Detail)
if got := payload["rolled_back"]; got != false {
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", got)
}
if detail["statement_index"] != 1 {
t.Errorf("statement_index = %v, want 1", detail["statement_index"])
results, _ := payload["results"].([]interface{})
if len(results) != 2 {
t.Errorf("results length = %d, want 2 (persisted statement + ERROR sentinel)", len(results))
}
if detail["rolled_back"] != false {
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", detail["rolled_back"])
}
if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 1 {
t.Errorf("completed = %v, want 1 persisted statement", detail["completed"])
note, _ := payload["note"].(string)
if !strings.Contains(note, "already applied") {
t.Errorf("note should warn prior statements persisted, got %q", note)
}
}
// decodePartialFailureData 解析 stdout 上 ok:false 的 partial-failure envelope返回 data 块。
func decodePartialFailureData(t *testing.T, stdoutStr string) map[string]interface{} {
t.Helper()
var envelope struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(stdoutStr), &envelope); err != nil {
t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, stdoutStr)
}
if envelope.OK {
t.Fatalf("envelope.ok = true, want false on partial failure")
}
if envelope.Data == nil {
t.Fatalf("envelope.data missing; stdout:\n%s", stdoutStr)
}
return envelope.Data
}
// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败server 也返 code:0 + ERROR 哨兵)
// 同样升级成 typed errorstatement_index=0、completed 空、message 标注 (at statement 1 of 1)。
// 同样走 partial failurestatement_index=0、note 说明无语句落地、message 标注 (at statement 1 of 1)。
func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -573,21 +592,23 @@ func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("single ERROR sentinel must return a typed error; stdout:\n%s", stdout.String())
t.Fatalf("single ERROR sentinel must return a partial-failure error; stdout:\n%s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
}
if !strings.Contains(exitErr.Detail.Message, "(at statement 1 of 1)") {
t.Errorf("error.message missing locator: %q", exitErr.Detail.Message)
payload := decodePartialFailureData(t, stdout.String())
msg, _ := payload["error_message"].(string)
if !strings.Contains(msg, "(at statement 1 of 1)") {
t.Errorf("error_message missing locator: %q", msg)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["statement_index"] != 0 {
t.Errorf("statement_index = %v, want 0", detail["statement_index"])
if got := payload["statement_index"]; got != float64(0) {
t.Errorf("statement_index = %v, want 0", got)
}
if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 0 {
t.Errorf("completed = %v, want empty", detail["completed"])
note, _ := payload["note"].(string)
if !strings.Contains(note, "no statements were applied") {
t.Errorf("note should say nothing was applied, got %q", note)
}
}
@@ -795,3 +816,35 @@ func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
})
}
}
// TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly pins the pretty
// contract on a statement failure: stdout carries only the per-statement
// human summary (no JSON envelope stacked after it), and the command still
// exits non-zero via the partial-failure signal.
func TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[{"sql_type":"ERROR","data":"{\"code\":\"k_dl_000002\",\"message\":\"syntax error\"}"}]`,
},
},
})
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
factory, stdout)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
}
out := stdout.String()
if !strings.Contains(out, "✗") {
t.Fatalf("pretty summary missing failure marker; stdout:\n%s", out)
}
if strings.Contains(out, `"ok"`) {
t.Fatalf("pretty stdout must not stack a JSON envelope after the summary; stdout:\n%s", out)
}
}

View File

@@ -8,7 +8,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -45,7 +44,7 @@ var AppsDBTableGet = common.Shortcut{
return err
}
if strings.TrimSpace(rctx.Str("table")) == "" {
return output.ErrValidation("--table is required")
return appsValidationParamError("--table", "--table is required")
}
return nil
},

View File

@@ -47,11 +47,11 @@ var AppsEnvPull = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: "--app-id is required"}, Param: "app-id"}
return appsValidationParamError("--app-id", "--app-id is required")
}
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
if err != nil {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err)
}
if err := checkEnvPullTarget(envFile); err != nil {
return err
@@ -71,7 +71,7 @@ var AppsEnvPull = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
if err != nil {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(err)
}
if err := checkEnvPullTarget(envFile); err != nil {
return err
@@ -120,7 +120,7 @@ func resolveEnvPullTarget(projectPath string) (string, string, error) {
if strings.TrimSpace(projectPath) == "" {
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
if err != nil {
return "", "", fmt.Errorf("cannot determine working directory: %w", err)
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "cannot determine working directory: %v", err).WithCause(err)
}
projectPath = cwd
}
@@ -137,13 +137,13 @@ func checkEnvPullTarget(envFile string) error {
if os.IsNotExist(err) {
return nil
}
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("cannot inspect %s: %v", envFile, err)}, Param: "project-path", Cause: err}
return appsValidationParamError("--project-path", "cannot inspect %s: %v", envFile, err).WithCause(err)
}
if info.Mode()&os.ModeSymlink != 0 {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file, not a symlink", envFile)}, Param: "project-path"}
return appsValidationParamError("--project-path", "target %s must be a regular file, not a symlink", envFile)
}
if !info.Mode().IsRegular() {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file", envFile)}, Param: "project-path"}
return appsValidationParamError("--project-path", "target %s must be a regular file", envFile)
}
return nil
}
@@ -156,7 +156,7 @@ func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPull
}
}
if raw == nil {
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries")
}
var skippedKeys []string
@@ -203,7 +203,7 @@ func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPull
}
return out, info, skippedKeys, nil
default:
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries")
}
}

View File

@@ -1079,3 +1079,28 @@ func TestEnsureEnvPullParentDir_MkdirError(t *testing.T) {
t.Error("MkdirAll over a file component must surface an error")
}
}
// TestExtractEnvPullVars_MissingEnvVarsIsInternalInvalidResponse pins that a
// response without a usable env_vars field classifies as
// internal/invalid_response — a broken upstream payload, not a flag problem
// the agent should retry with different arguments.
func TestExtractEnvPullVars_MissingEnvVarsIsInternalInvalidResponse(t *testing.T) {
for name, data := range map[string]map[string]interface{}{
"missing": {},
"wrong type": {"env_vars": "not-an-object"},
} {
t.Run(name, func(t *testing.T) {
_, _, _, err := extractEnvPullVars(data)
if err == nil {
t.Fatalf("expected error for %s env_vars", name)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("classification = %s/%s, want internal/invalid_response", p.Category, p.Subtype)
}
})
}
}

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"errors"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
)
func appsValidationError(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
func appsValidationParamError(param, format string, args ...any) *errs.ValidationError {
return appsValidationError(format, args...).WithParam(param)
}
func appsInvalidParam(name, reason string) errs.InvalidParam {
return errs.InvalidParam{Name: name, Reason: reason}
}
func appsFailedPreconditionParamError(param, format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...).WithParam(param)
}
func appsFailedPreconditionError(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...)
}
// appsStorageError classifies a local credential/state storage failure
// (read, write, delete, list) as internal/storage.
func appsStorageError(err error, format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeStorage, format, args...).WithCause(err)
}
// appsExternalToolError classifies a runtime failure of an external tool the
// CLI shells out to (git, npx) as internal/external_tool. The tool output is
// carried in the message; recovery guidance goes in the hint.
func appsExternalToolError(err error, format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeExternalTool, format, args...).WithCause(err)
}
// appsSubprocessEnvelopeError classifies a malformed or failed envelope from a
// lark-cli subprocess (+git-credential-init / +env-pull) as internal/invalid_response.
func appsSubprocessEnvelopeError(format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
}
func appsInputPathError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return appsValidationParamError("--path", "unsafe --path: %s", err).WithCause(err)
}
return appsValidationParamError("--path", "cannot read --path: %s", err).WithCause(err)
}
func appsInputPathEntryError(path string, err error) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return appsValidationParamError("--path", "unsafe --path entry %s: %s", path, err).WithCause(err)
}
return appsValidationParamError("--path", "cannot read --path entry %s: %s", path, err).WithCause(err)
}
func appsFileIOError(err error, format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
}
// enrichHTMLPublishAPIError adapts a typed failure from the HTML publish
// endpoint: refines endpoint-scoped business codes, prefixes the message with
// command context, and attaches endpoint-specific recovery hints. A
// still-untyped error is lifted at the SDK boundary instead.
func enrichHTMLPublishAPIError(err error) error {
if err == nil {
return nil
}
p, ok := errs.ProblemOf(err)
if !ok {
return client.WrapDoAPIError(err)
}
// The HTML publish business codes (90001/90002) are scoped to this
// endpoint, not service-global, so their subtype classification lives
// here instead of the global errclass code table. Only an
// otherwise-unclassified API error is refined; a stronger upstream
// classification is never overridden.
if p.Category == errs.CategoryAPI && p.Subtype == errs.SubtypeUnknown && p.Code == errCodeAppNotFound {
p.Subtype = errs.SubtypeNotFound
}
if p.Message != "" {
p.Message = "html-publish failed: " + p.Message
}
if hint := buildHTMLPublishFailureHint(p.Code); hint != "" {
p.Hint = hint
}
return err
}

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
func TestAppsInputPathError_ClassifiesPathValidation(t *testing.T) {
cause := errors.New("path escapes working directory")
err := appsInputPathError(&fileio.PathValidationError{Err: cause})
problem := requireAppsValidationProblem(t, err)
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(problem.Message, "unsafe --path") {
t.Fatalf("message = %q, want unsafe --path context", problem.Message)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--path" {
t.Fatalf("param = %q, want --path", validationErr.Param)
}
if !errors.Is(err, fileio.ErrPathValidation) || !errors.Is(err, cause) {
t.Fatalf("path validation cause chain not preserved: %v", err)
}
}
func TestAppsInputPathEntryError_ClassifiesReadFailure(t *testing.T) {
cause := errors.New("permission denied")
err := appsInputPathEntryError("dist/index.html", cause)
problem := requireAppsValidationProblem(t, err)
if !strings.Contains(problem.Message, "cannot read --path entry dist/index.html") {
t.Fatalf("message = %q, want entry read context", problem.Message)
}
if !errors.Is(err, cause) {
t.Fatalf("cause chain not preserved: %v", err)
}
}
func TestAppsFileIOError_ClassifiesInternalFileIO(t *testing.T) {
cause := errors.New("archive writer failed")
err := appsFileIOError(cause, "pack failed: %v", cause)
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if problem.Subtype != errs.SubtypeFileIO {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeFileIO)
}
if !errors.Is(err, cause) {
t.Fatalf("cause chain not preserved: %v", err)
}
}
func TestEnrichHTMLPublishAPIError_LiftsUntypedBoundaryError(t *testing.T) {
err := enrichHTMLPublishAPIError(errors.New("connection reset by peer"))
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
if problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNetworkTransport)
}
}
func TestEnrichHTMLPublishAPIError_PreservesClassificationAndAddsHint(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeUnknown, "build failed").
WithCode(errCodeBuildFailed).
WithLogID("logid-build-failed")
got := enrichHTMLPublishAPIError(err)
if got != err {
t.Fatalf("typed error should be enriched in place")
}
problem := requireAppsAPIProblem(t, got)
if problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeUnknown)
}
if problem.Code != errCodeBuildFailed {
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
}
if problem.LogID != "logid-build-failed" {
t.Fatalf("log_id = %q, want preserved", problem.LogID)
}
if !strings.Contains(problem.Message, "html-publish failed") {
t.Fatalf("message = %q, want html-publish context", problem.Message)
}
if problem.Hint == "" {
t.Fatalf("expected known-code recovery hint")
}
}
func TestEnrichHTMLPublishAPIError_ClassifiesAppNotFoundLocally(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeUnknown, "app not found").WithCode(errCodeAppNotFound)
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
if problem.Subtype != errs.SubtypeNotFound {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
}
}
func TestEnrichHTMLPublishAPIError_KeepsStrongerClassification(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeRateLimit, "throttled").WithCode(errCodeAppNotFound)
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
if problem.Subtype != errs.SubtypeRateLimit {
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeRateLimit)
}
}

View File

@@ -10,7 +10,7 @@ import (
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -35,11 +35,11 @@ var AppsHTMLPublish = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
path := strings.TrimSpace(rctx.Str("path"))
if path == "" {
return output.ErrValidation("--path is required")
return appsValidationParamError("--path", "--path is required")
}
// Block well-known credential files in the publish payload unless the
// caller explicitly opts in. Lives in Validate (not DryRun) so that
@@ -150,9 +150,9 @@ func sensitiveCandidatesError(hits []string) error {
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
}
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("--path contains %d credential file(s) that should not be published: %s", len(hits), sample),
"remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
return appsValidationParamError("--path",
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
}
// maxHTMLPublishTarballBytes 是 client 端 tar.gz 包体上限,对齐 OAPI 设计 20MB 约束。
@@ -178,15 +178,14 @@ func ensureIndexHTML(candidates []htmlPublishCandidate) error {
return nil
}
}
return output.ErrWithHint(output.ExitAPI, "validation",
"--path 中缺少 index.html",
"妙搭以 index.html 作为应用入口;目录形态把首页放在根目录命名 index.html单文件形态把文件命名为 index.html")
return appsFailedPreconditionParamError("--path", "--path is missing index.html").
WithHint("Miaoda uses index.html as the app entrypoint; for a directory put index.html at the root, or pass a single file named index.html")
}
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
if err != nil {
return nil, output.Errorf(output.ExitAPI, "io", "scan --path %s: %v", spec.Path, err)
return nil, err
}
if err := ensureIndexHTML(candidates); err != nil {
return nil, err
@@ -196,24 +195,24 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPubli
rawTotal += c.Size
}
if rawTotal > maxHTMLPublishRawBytes {
return nil, output.ErrWithHint(output.ExitAPI, "validation",
fmt.Sprintf("--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes),
"在 tar+gzip 进入内存前拦截,避免 OOM精简 --path 内容或选择更小的子目录")
return nil, appsValidationParamError("--path",
"--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes).
WithHint("reduce --path contents or choose a smaller subdirectory before packaging")
}
tarball, err := buildHTMLPublishTarball(fio, candidates)
if err != nil {
return nil, output.Errorf(output.ExitAPI, "io", "pack: %v", err)
return nil, err
}
if tarball.Size > maxHTMLPublishTarballBytes {
return nil, output.ErrWithHint(output.ExitAPI, "validation",
fmt.Sprintf("packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes),
"请精简 --path 目录(去掉无关大文件 / 压缩资源)后重试;本期接口上限 20MB")
return nil, appsValidationParamError("--path",
"packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes).
WithHint("reduce --path contents, remove unrelated large files, then retry")
}
resp, err := client.HTMLPublish(ctx, spec.AppID, tarball)
resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball)
if err != nil {
return nil, err
return nil, client.WrapDoAPIError(err)
}
out := map[string]interface{}{}

View File

@@ -10,8 +10,6 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
)
type fakeAppsHTMLPublishClient struct {
@@ -105,17 +103,11 @@ func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
if err == nil {
t.Fatalf("expected error for missing index.html")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
problem := requireAppsValidationProblem(t, err)
if !strings.Contains(problem.Message, "index.html") {
t.Fatalf("message missing 'index.html': %v", problem.Message)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "index.html") {
t.Fatalf("message missing 'index.html': %v", exitErr.Detail.Message)
}
if exitErr.Detail.Hint == "" {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
@@ -153,10 +145,7 @@ func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
if err == nil {
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
t.Fatalf("expected ExitError type=validation, got %v", err)
}
requireAppsValidationProblem(t, err)
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when index.html missing")
}
@@ -199,17 +188,11 @@ func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
if err == nil {
t.Fatalf("expected oversize error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
problem := requireAppsValidationProblem(t, err)
if !strings.Contains(problem.Message, "exceeds") {
t.Fatalf("message missing 'exceeds': %v", problem.Message)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "exceeds") {
t.Fatalf("message missing 'exceeds': %v", exitErr.Detail.Message)
}
if exitErr.Detail.Hint == "" {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
@@ -337,18 +320,12 @@ func TestAppsHTMLPublish_SensitiveBlocksValidate(t *testing.T) {
if err == nil {
t.Fatalf("dry-run with sensitive file should fail")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
problem := requireAppsValidationProblem(t, err)
if !strings.Contains(problem.Message, ".env") {
t.Fatalf("error message should list the offending file, got %q", problem.Message)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, ".env") {
t.Fatalf("error message should list the offending file, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--allow-sensitive") {
t.Fatalf("error hint should mention --allow-sensitive escape hatch, got %q", exitErr.Detail.Hint)
if !strings.Contains(problem.Hint, "--allow-sensitive") {
t.Fatalf("error hint should mention --allow-sensitive escape hatch, got %q", problem.Hint)
}
}
@@ -438,15 +415,9 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialParentDir(t *testing
if err == nil {
t.Fatalf("expected rejection when --path is %s/ (would leak %s), got success", tc.parent, tc.fileName)
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, tc.wantSubstr) {
t.Fatalf("error message should name the leaked file, got %q", exitErr.Detail.Message)
problem := requireAppsValidationProblem(t, err)
if !strings.Contains(problem.Message, tc.wantSubstr) {
t.Fatalf("error message should name the leaked file, got %q", problem.Message)
}
})
}
@@ -480,15 +451,9 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialFileItself(t *testin
if err == nil {
t.Fatalf("expected rejection when --path points directly at .aws/credentials, got success")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "credentials") {
t.Fatalf("error message should name the leaked file, got %q", exitErr.Detail.Message)
problem := requireAppsValidationProblem(t, err)
if !strings.Contains(problem.Message, "credentials") {
t.Fatalf("error message should name the leaked file, got %q", problem.Message)
}
}
@@ -498,11 +463,7 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialFileItself(t *testin
func TestSensitiveCandidatesError_Truncation(t *testing.T) {
hits := []string{"a.env", "b.env", "c.env", "d.env", "e.env", "f.env", "g.env"}
err := sensitiveCandidatesError(hits)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
}
msg := exitErr.Detail.Message
msg := requireAppsValidationProblem(t, err).Message
if !strings.Contains(msg, "7 credential file(s)") {
t.Fatalf("message should report the full count, got %q", msg)
}
@@ -534,15 +495,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
if err == nil {
t.Fatalf("expected raw-size cap to fire")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "raw") || !strings.Contains(exitErr.Detail.Message, "bytes") {
t.Fatalf("expected message to explain raw-byte cap, got %q", exitErr.Detail.Message)
problem := requireAppsValidationProblem(t, err)
if !strings.Contains(problem.Message, "raw") || !strings.Contains(problem.Message, "bytes") {
t.Fatalf("expected message to explain raw-byte cap, got %q", problem.Message)
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when raw cap hit")

View File

@@ -14,8 +14,8 @@ import (
"strings"
"unicode"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -72,14 +72,14 @@ var AppsInit = common.Shortcut{
// exit-1 (root.go handleRootError case 4), bypassing the structured
// envelope. The spec and the E2E assert exit-2 + a structured
// {"ok":false,"error":{...}} envelope for missing --app-id, so the empty
// check lives in Validate (output.ErrValidation -> ExitValidation=2).
// check lives in Validate (typed validation error -> exit 2).
{Name: "app-id", Desc: "Miaoda app ID"},
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
return nil
},
@@ -152,14 +152,14 @@ func resolveTargetPath(rctx *common.RuntimeContext, appID string) (string, error
// path is a log-injection vector); charcheck additionally rejects dangerous
// Unicode (bidi overrides, zero-width) that IsControl does not.
if strings.IndexFunc(raw, unicode.IsControl) >= 0 {
return "", output.ErrValidation("--dir must not contain control characters")
return "", appsValidationParamError("--dir", "--dir must not contain control characters")
}
if err := charcheck.RejectControlChars(raw, "--dir"); err != nil {
return "", output.ErrValidation("%v", err)
return "", appsValidationParamError("--dir", "%v", err).WithCause(err)
}
abs, err := filepath.Abs(raw) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); raw is control-char-validated above, and FileIO.ResolvePath cannot resolve a clone target (it rejects absolute paths).
if err != nil {
return "", output.ErrValidation("--dir cannot be resolved: %v", err)
return "", appsValidationParamError("--dir", "--dir cannot be resolved: %v", err)
}
return abs, nil
}
@@ -173,20 +173,20 @@ func ensureEmptyDir(dir string) error {
return nil
}
if err != nil {
return output.ErrValidation("--dir cannot be read: %v", err)
return appsValidationParamError("--dir", "--dir cannot be read: %v", err)
}
if info.Mode()&os.ModeSymlink != 0 {
return output.ErrValidation("--dir must not be a symlink: %q", dir)
return appsValidationParamError("--dir", "--dir must not be a symlink: %q", dir)
}
if !info.IsDir() {
return output.ErrValidation("--dir exists and is not a directory: %q", dir)
return appsValidationParamError("--dir", "--dir exists and is not a directory: %q", dir)
}
entries, err := os.ReadDir(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and FileIO has no ReadDir.
if err != nil {
return output.ErrValidation("--dir cannot be read: %v", err)
return appsValidationParamError("--dir", "--dir cannot be read: %v", err)
}
if len(entries) > 0 {
return output.ErrValidation("target directory %q already exists and is not empty", dir)
return appsValidationParamError("--dir", "target directory %q already exists and is not empty", dir)
}
return nil
}
@@ -209,11 +209,11 @@ func ensureMetaAppID(dir, appID string) error {
return nil
}
if err != nil {
return output.Errorf(output.ExitAPI, "meta_write", "read %s failed: %v", metaRelPath, err)
return appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
return output.Errorf(output.ExitAPI, "meta_write", "parse %s failed: %v", metaRelPath, err)
return appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
}
if cur, _ := m["app_id"].(string); strings.TrimSpace(cur) != "" {
return nil
@@ -224,10 +224,10 @@ func ensureMetaAppID(dir, appID string) error {
m["app_id"] = appID
out, err := json.MarshalIndent(m, "", " ")
if err != nil {
return output.Errorf(output.ExitAPI, "meta_write", "marshal %s failed: %v", metaRelPath, err)
return appsFileIOError(err, "marshal %s failed: %v", metaRelPath, err)
}
if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Save rejects absolute paths.
return output.Errorf(output.ExitAPI, "meta_write", "write %s failed: %v", metaRelPath, err)
return appsFileIOError(err, "write %s failed: %v", metaRelPath, err)
}
return nil
}
@@ -244,7 +244,7 @@ func hasSteeringSkills(dir string) bool {
func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
stdout, stderr, err := initRunner.Run(ctx, dir, "git", "ls-files")
if err != nil {
return false, output.Errorf(output.ExitAPI, "git_ls_files", "git ls-files failed: %s", gitErr(stderr, err))
return false, appsExternalToolError(err, "git ls-files failed: %s", gitErr(stderr, err))
}
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
f := strings.TrimSpace(line)
@@ -274,19 +274,19 @@ func runScaffold(ctx context.Context, dir, appID, template string) (string, erro
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
// appear, extend isEmptyRepo's allow-list accordingly.
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
return "", output.Errorf(output.ExitAPI, "npx_app_init", "npx app init failed: %s", gitErr(stderr, err))
return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err))
}
return scaffoldKindInit, nil
}
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "sync"); err != nil {
return "", output.Errorf(output.ExitAPI, "npx_app_sync", "npx app sync failed: %s", gitErr(stderr, err))
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
}
if err := ensureMetaAppID(dir, appID); err != nil {
return "", err
}
if !hasSteeringSkills(dir) {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
return "", output.Errorf(output.ExitAPI, "npx_skills_sync", "npx skills sync failed: %s", gitErr(stderr, err))
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
}
}
return scaffoldKindUpgrade, nil
@@ -303,13 +303,13 @@ func parseRepoURLFromEnvelope(stdout string) (string, error) {
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
return "", output.Errorf(output.ExitInternal, "credential_init", "could not parse +git-credential-init output as JSON: %v", err)
return "", appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
}
if !env.OK {
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init reported failure")
return "", appsSubprocessEnvelopeError("+git-credential-init reported failure")
}
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init returned no repository_url")
return "", appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
}
return env.Data.RepositoryURL, nil
}
@@ -324,13 +324,13 @@ func parseEnvFileFromEnvelope(stdout string) (string, error) {
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
return "", output.Errorf(output.ExitInternal, "env_pull", "could not parse +env-pull output as JSON: %v", err)
return "", appsSubprocessEnvelopeError("could not parse +env-pull output as JSON: %v", err)
}
if !env.OK {
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull reported failure")
return "", appsSubprocessEnvelopeError("+env-pull reported failure")
}
if strings.TrimSpace(env.Data.EnvFile) == "" {
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull returned no env_file")
return "", appsSubprocessEnvelopeError("+env-pull returned no env_file")
}
return env.Data.EnvFile, nil
}
@@ -364,7 +364,9 @@ func validateRepoURLScheme(repoURL string) error {
if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") {
return nil
}
return output.Errorf(output.ExitValidation, "validation",
// The URL comes from the +git-credential-init subprocess response, not user
// input, so a non-http(s) scheme is a broken upstream contract.
return appsSubprocessEnvelopeError(
"repository_url from +git-credential-init must be http(s); refusing %q", redactURLCredentials(repoURL))
}
@@ -415,12 +417,12 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
}
if _, err := exec.LookPath("git"); err != nil {
return output.ErrWithHint(output.ExitInternal, "dependency",
"git executable not found on PATH", "install git and ensure it is on your PATH")
return appsFailedPreconditionError("git executable not found on PATH").
WithHint("install git and ensure it is on your PATH")
}
if _, err := exec.LookPath("npx"); err != nil {
return output.ErrWithHint(output.ExitInternal, "dependency",
"npx executable not found on PATH", "install Node.js (which provides npx) and ensure it is on your PATH")
return appsFailedPreconditionError("npx executable not found on PATH").
WithHint("install Node.js (which provides npx) and ensure it is on your PATH")
}
if err := ensureEmptyDir(dir); err != nil {
@@ -438,11 +440,11 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
initLogf(rctx, "Cloning into %s...", dir)
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
return output.Errorf(output.ExitAPI, "git_clone", "git clone failed: %s", gitErr(stderr, err))
return appsExternalToolError(err, "git clone failed: %s", gitErr(stderr, err))
}
initLogf(rctx, "Checking out %s...", defaultInitBranch)
if _, stderr, err := initRunner.Run(ctx, dir, "git", "checkout", defaultInitBranch); err != nil {
return output.Errorf(output.ExitAPI, "git_checkout", "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
}
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
@@ -536,7 +538,7 @@ func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
self, err := os.Executable()
if err != nil {
return "", output.Errorf(output.ExitInternal, "internal", "cannot locate lark-cli executable: %v", err)
return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
}
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
@@ -544,9 +546,9 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
}
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
if err != nil {
return "", output.ErrWithHint(output.ExitAPI, "credential_init",
fmt.Sprintf("apps +git-credential-init failed: %s", gitErr(stderr, err)),
"ensure apps +git-credential-init is available and you are logged in")
return "", appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
WithHint("ensure apps +git-credential-init is available and you are logged in").
WithCause(err)
}
return parseRepoURLFromEnvelope(stdout)
}
@@ -560,7 +562,7 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (committed, pushed bool, err error) {
status, stderr, runErr := initRunner.Run(ctx, dir, "git", "status", "--porcelain")
if runErr != nil {
return false, false, output.Errorf(output.ExitAPI, "git_status", "git status failed: %s", gitErr(stderr, runErr))
return false, false, appsExternalToolError(runErr, "git status failed: %s", gitErr(stderr, runErr))
}
if strings.TrimSpace(status) == "" {
return false, false, nil
@@ -595,7 +597,7 @@ func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (commit
if _, se, e := initRunner.Run(ctx, dir, "git", "push", "origin", defaultInitBranch); e != nil {
return true, false, withAppsHint(
output.Errorf(output.ExitAPI, "git_push", "git push failed: %s", gitErr(se, e)),
appsExternalToolError(e, "git push failed: %s", gitErr(se, e)),
"the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded")
}
return true, true, nil
@@ -609,10 +611,10 @@ func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (commit
func stageAndCommit(ctx context.Context, dir, message string, pathspecs ...string) error {
addArgs := append([]string{"add", "-A", "--"}, pathspecs...)
if _, se, e := initRunner.Run(ctx, dir, "git", addArgs...); e != nil {
return output.Errorf(output.ExitAPI, "git_add", "git add failed: %s", gitErr(se, e))
return appsExternalToolError(e, "git add failed: %s", gitErr(se, e))
}
if _, se, e := initRunner.Run(ctx, dir, "git", "commit", "--no-verify", "-m", message); e != nil {
return output.Errorf(output.ExitAPI, "git_commit", "git commit failed: %s", gitErr(se, e))
return appsExternalToolError(e, "git commit failed: %s", gitErr(se, e))
}
return nil
}

View File

@@ -17,6 +17,7 @@ import (
"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/shortcuts/common"
@@ -1466,3 +1467,28 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
t.Errorf("Description should mention app code: %q", AppsInit.Description)
}
}
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
// classification of an external-tool failure: a failing git subprocess
// surfaces as internal/external_tool with the cause preserved.
func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
cause := errors.New("exit status 128")
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"git ls-files": {stderr: "fatal: not a git repository", err: cause},
}}
withFakeRunner(t, f)
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
if err == nil {
t.Fatalf("expected error from failing git subprocess")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeExternalTool {
t.Fatalf("classification = %s/%s, want internal/external_tool", p.Category, p.Subtype)
}
if !errors.Is(err, cause) {
t.Fatalf("cause chain not preserved: %v", err)
}
}

View File

@@ -9,7 +9,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -33,7 +32,7 @@ var AppsReleaseCreate = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
return nil
},

View File

@@ -9,7 +9,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -32,10 +31,10 @@ var AppsReleaseGet = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
if strings.TrimSpace(rctx.Str("release-id")) == "" {
return output.ErrValidation("--release-id is required")
return appsValidationParamError("--release-id", "--release-id is required")
}
return nil
},

View File

@@ -35,7 +35,7 @@ var AppsReleaseList = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
return nil
},

View File

@@ -9,7 +9,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -31,7 +30,7 @@ var AppsSessionCreate = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
return nil
},

View File

@@ -9,7 +9,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -33,10 +32,10 @@ var AppsSessionGet = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
if strings.TrimSpace(rctx.Str("session-id")) == "" {
return output.ErrValidation("--session-id is required")
return appsValidationParamError("--session-id", "--session-id is required")
}
return nil
},

View File

@@ -32,7 +32,7 @@ var AppsSessionList = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
return nil
},

View File

@@ -9,7 +9,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -35,13 +34,13 @@ var AppsSessionStop = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
if strings.TrimSpace(rctx.Str("session-id")) == "" {
return output.ErrValidation("--session-id is required")
return appsValidationParamError("--session-id", "--session-id is required")
}
if strings.TrimSpace(rctx.Str("turn-id")) == "" {
return output.ErrValidation("--turn-id is required")
return appsValidationParamError("--turn-id", "--turn-id is required")
}
return nil
},

View File

@@ -9,7 +9,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -34,11 +33,15 @@ var AppsUpdate = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
body := buildAppsUpdateBody(rctx)
if len(body) == 0 {
return output.ErrValidation("provide at least one of --name or --description")
return appsValidationError("provide at least one of --name or --description").
WithParams(
appsInvalidParam("--name", "provide at least one of --name or --description"),
appsInvalidParam("--description", "provide at least one of --name or --description"),
)
}
return nil
},

View File

@@ -4,11 +4,9 @@
package apps
import (
"errors"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
// appsService 是 CLI 命令的 service 前缀lark-cli apps ...)。
@@ -23,11 +21,11 @@ const apiBasePath = "/open-apis/spark/v1"
// lark-apps SKILL.md ("app_id 获取"); the hint stays lean and does not repeat it.
const appIDListHint = "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`"
// withAppsHint attaches an actionable next-step hint to a failure returned by
// CallAPI, preserving its original classification (typed subtype/code/log_id or
// legacy detail). A hint already present on the error is kept (the upstream
// wording wins); only an empty hint is filled in. Mirrors
// drive.appendDriveExportRecoveryHint. err==nil passes through.
// withAppsHint attaches an actionable next-step hint to a typed failure,
// preserving its original classification (subtype/code/log_id). A hint already
// present on the error is kept (the upstream wording wins); only an empty hint
// is filled in. Mirrors drive.appendDriveExportRecoveryHint. err==nil and
// untyped errors pass through unchanged.
func withAppsHint(err error, hint string) error {
if err == nil {
return nil
@@ -39,14 +37,5 @@ func withAppsHint(err error, hint string) error {
}
return err
}
// Legacy *output.ExitError fallback: fill the hint in place, preserving the
// original class / exit code rather than downgrading the error.
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) == "" {
exitErr.Detail.Hint = hint
}
return err
}
return err
}

View File

@@ -7,7 +7,7 @@ import (
"errors"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
)
func TestWithAppsHint(t *testing.T) {
@@ -17,46 +17,40 @@ func TestWithAppsHint(t *testing.T) {
}
})
t.Run("empty hint gets filled, code/type preserved", func(t *testing.T) {
in := &output.ExitError{Code: 1, Detail: &output.ErrDetail{Type: "api_error", Message: "boom"}}
t.Run("empty hint gets filled, classification preserved", func(t *testing.T) {
in := errs.NewAPIError(errs.SubtypeNotFound, "boom").WithCode(404)
out := withAppsHint(in, "run +release-list")
var exitErr *output.ExitError
if !errors.As(out, &exitErr) {
t.Fatalf("returned error is not *output.ExitError: %T", out)
p, ok := errs.ProblemOf(out)
if !ok {
t.Fatalf("returned error is not typed: %T", out)
}
if exitErr.Detail.Hint != "run +release-list" {
t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "run +release-list")
if p.Hint != "run +release-list" {
t.Errorf("Hint = %q, want %q", p.Hint, "run +release-list")
}
if exitErr.Code != 1 || exitErr.Detail.Type != "api_error" || exitErr.Detail.Message != "boom" {
t.Errorf("code/type/message mutated: code=%d type=%q msg=%q", exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message)
if p.Subtype != errs.SubtypeNotFound || p.Code != 404 || p.Message != "boom" {
t.Errorf("subtype/code/message mutated: subtype=%q code=%d msg=%q", p.Subtype, p.Code, p.Message)
}
})
t.Run("existing hint is preserved, not clobbered", func(t *testing.T) {
in := output.ErrWithHint(1, "api_error", "boom", "original hint")
in := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("original hint")
out := withAppsHint(in, "new hint")
var exitErr *output.ExitError
if !errors.As(out, &exitErr) {
t.Fatalf("returned error is not *output.ExitError: %T", out)
}
if exitErr.Detail.Hint != "original hint" {
t.Errorf("Hint = %q, want preserved %q", exitErr.Detail.Hint, "original hint")
p, _ := errs.ProblemOf(out)
if p.Hint != "original hint" {
t.Errorf("Hint = %q, want preserved %q", p.Hint, "original hint")
}
})
t.Run("blank-whitespace hint is treated as empty and filled", func(t *testing.T) {
in := output.ErrWithHint(1, "api_error", "boom", " ")
in := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint(" ")
out := withAppsHint(in, "filled hint")
var exitErr *output.ExitError
if !errors.As(out, &exitErr) {
t.Fatalf("returned error is not *output.ExitError: %T", out)
}
if exitErr.Detail.Hint != "filled hint" {
t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "filled hint")
p, _ := errs.ProblemOf(out)
if p.Hint != "filled hint" {
t.Errorf("Hint = %q, want %q", p.Hint, "filled hint")
}
})
t.Run("unrecognized error type returned unchanged, no panic", func(t *testing.T) {
t.Run("untyped error returned unchanged, no panic", func(t *testing.T) {
in := errors.New("plain")
out := withAppsHint(in, "ignored")
if out == nil || out.Error() != "plain" {

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
@@ -37,7 +36,7 @@ func appDbEnvCreatePath(appID string) string {
func requireAppID(raw string) (string, error) {
id := strings.TrimSpace(raw)
if id == "" {
return "", output.ErrValidation("--app-id is required")
return "", appsValidationParamError("--app-id", "--app-id is required")
}
return id, nil
}

View File

@@ -6,11 +6,11 @@ package apps
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -21,10 +21,11 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/apps/gitcred"
"github.com/larksuite/cli/shortcuts/common"
@@ -52,16 +53,27 @@ var AppsGitCredentialInit = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id")
if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil {
return appsValidationParamError("--app-id", "%v", err).WithCause(err)
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(gitCredentialIssuePath).
Desc("Issue a Miaoda Git repository PAT").
Set("mode", "api-plus-local-setup").
Set("action", "initialize_local_git_credential").
Set("app_id", appID).
Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)).
Set("local_effects", []string{
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
}).
Params(gitCredentialIssueParams(appID))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -120,9 +132,27 @@ var AppsGitCredentialRemove = common.Shortcut{
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
return appsValidationParamError("--app-id", "--app-id is required")
}
return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id")
if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil {
return appsValidationParamError("--app-id", "%v", err).WithCause(err)
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
Desc("Preview local Git credential cleanup (no API call; would clean up local-only state).").
Set("mode", "local-cleanup-only").
Set("action", "remove_local_git_credential").
Set("app_id", appID).
Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)).
Set("effects", []string{
"read app-scoped git credential metadata",
"remove the saved PAT from the local system credential store",
"remove the app-scoped Git helper from global git config when present",
"delete the local metadata record after cleanup succeeds",
})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
@@ -171,6 +201,17 @@ var AppsGitCredentialList = common.Shortcut{
Scopes: []string{},
AuthTypes: []string{"user"},
HasFormat: true,
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Preview local Git credential listing (no API call, read-only local state).").
Set("mode", "local-read-only").
Set("action", "list_local_git_credentials").
Set("storage_root", filepath.Join(core.GetConfigDir(), storageRoot)).
Set("reads", []string{
"scan app-scoped git credential metadata under the CLI config directory",
"derive per-app repository URLs and local credential status from local metadata",
})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
records, err := listGitCredentialRecords(rctx.Factory.Keychain, time.Now)
if err != nil {
@@ -233,7 +274,7 @@ func (i runtimeIssuer) Issue(ctx context.Context, appID string, profile gitcred.
HttpMethod: http.MethodGet,
ApiPath: issuePath(appID),
})
data, err := parseIssueCredentialData(resp, err)
data, err := parseIssueCredentialData(resp, err, i.rctx.APIClassifyContext())
if err != nil {
return nil, err
}
@@ -250,7 +291,8 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
return nil, err
}
if cfg.UserOpenId == "" {
return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`")
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").
WithHint("run `lark-cli auth login --scope \"spark:app:read\"`")
}
ac, err := i.f.NewAPIClientWithConfig(cfg)
if err != nil {
@@ -261,7 +303,11 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
ApiPath: issuePath(appID),
}
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser)
data, err := parseIssueCredentialData(resp, err)
data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{
Brand: string(cfg.Brand),
AppID: cfg.AppID,
Identity: string(core.AsUser),
})
if err != nil {
return nil, err
}
@@ -379,13 +425,11 @@ func gitCredentialLocalError(action string, err error) error {
if err == nil {
return nil
}
// Typed errors pass through unchanged; everything the apps domain and the
// shared runtime produce is typed, so there is no legacy envelope to forward.
if _, ok := errs.UnwrapTypedError(err); ok {
return err
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return &errs.ConfigError{Problem: errs.Problem{
Category: errs.CategoryConfig,
Subtype: errs.SubtypeInvalidConfig,
@@ -413,64 +457,43 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
issued.AppID = appID
}
if issued.GitHTTPURL == "" {
return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL")
}
if issued.PAT == "" {
return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token")
}
return issued, nil
}
func parseIssueCredentialData(resp *larkcore.ApiResp, err error) (map[string]any, error) {
// parseIssueCredentialData turns the git-credential issue response into the
// credential data map. A standard Lark envelope (top-level "code") and any
// HTTP error status route through the shared response classifier, so generic
// codes (missing scope, app not authorized) and 5xx statuses keep their
// canonical category/subtype/retryable classification. The endpoint's
// non-standard success shapes — direct git info or a BaseResp wrapper — are
// handled locally.
func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.ClassifyContext) (map[string]any, error) {
if err != nil {
return nil, err
return nil, client.WrapDoAPIError(err)
}
detail := logIDDetail(resp)
if resp == nil || len(resp.RawBody) == 0 {
return nil, &errs.InternalError{Problem: errs.Problem{
Category: errs.CategoryInternal,
Subtype: errs.SubtypeUnknown,
Message: "Issue Miaoda Git credential: empty response body",
}}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"Issue Miaoda Git credential: empty response body")
}
var result map[string]any
if jsonErr := json.Unmarshal(resp.RawBody, &result); jsonErr != nil {
return nil, &errs.InternalError{Problem: errs.Problem{
Category: errs.CategoryInternal,
Subtype: errs.SubtypeUnknown,
Message: fmt.Sprintf("Issue Miaoda Git credential: unmarshal response: %s", jsonErr),
}, Cause: jsonErr}
}
if resp.StatusCode >= http.StatusBadRequest {
msg := firstString(result, "msg", "message")
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
jsonErr := json.Unmarshal(resp.RawBody, &result)
_, hasCode := result["code"]
if jsonErr != nil || hasCode || resp.StatusCode >= http.StatusBadRequest {
data, cerr := common.ClassifyAPIResponseWith(resp, cc)
if cerr != nil {
return nil, withAppsHint(cerr, gitCredentialIssueHint)
}
return nil, &errs.APIError{Problem: errs.Problem{
Category: errs.CategoryAPI,
Subtype: errs.SubtypeUnknown,
Code: resp.StatusCode,
Message: msg,
LogID: logIDString(resp),
Hint: gitCredentialIssueHint,
Retryable: resp.StatusCode >= http.StatusInternalServerError,
}}
}
if _, hasCode := result["code"]; hasCode {
code := firstInt64(result, "code")
if code != 0 {
return nil, &errs.APIError{Problem: errs.Problem{
Category: errs.CategoryAPI,
Subtype: errs.SubtypeUnknown,
Code: int(code),
Message: firstString(result, "msg", "message"),
LogID: logIDString(resp),
Hint: gitCredentialIssueHint,
}}
}
if data, ok := result["data"].(map[string]any); ok {
if data != nil {
result = data
}
// data == nil: a code==0 envelope whose fields sit beside "code" instead
// of under "data" — keep the locally-unmarshalled top-level object.
} else if err := checkGitInfoBaseResp(result, logIDString(resp)); err != nil {
return nil, err
}
@@ -499,13 +522,11 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
if message == "" {
message = "Git credential API returned non-zero BaseResp status"
}
return &errs.APIError{Problem: errs.Problem{
Category: errs.CategoryAPI,
Subtype: errs.SubtypeUnknown,
Code: int(code),
Message: "Issue Miaoda Git credential: " + message,
LogID: logID,
}}
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue Miaoda Git credential: %s", message).WithCode(int(code))
if logID != "" {
baseErr = baseErr.WithLogID(logID)
}
return baseErr
}
return nil
}
@@ -543,6 +564,9 @@ func firstInt64(data map[string]interface{}, keys ...string) int64 {
return int64(v)
case float64:
return int64(v)
case json.Number:
n, _ := v.Int64()
return n
case string:
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
return n

View File

@@ -5,7 +5,6 @@ package apps
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
@@ -35,7 +34,7 @@ func (gitCredentialAppStorage) ListAppIDs() ([]string, error) {
if errors.Is(err, os.ErrNotExist) {
return []string{}, nil
}
return nil, fmt.Errorf("apps storage: read root: %w", err)
return nil, appsStorageError(err, "apps storage: read root: %v", err)
}
appIDs := make([]string, 0, len(entries))
for _, e := range entries {

View File

@@ -25,8 +25,8 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/apps/gitcred"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -45,6 +45,11 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
Params map[string]interface{} `json:"params"`
Body interface{} `json:"body"`
} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
AppID string `json:"app_id"`
MetadataFile string `json:"metadata_file"`
LocalEffects []string `json:"local_effects"`
}
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
@@ -65,6 +70,107 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
if call.Body != nil {
t.Fatalf("body = %#v, want nil", call.Body)
}
if payload.Mode != "api-plus-local-setup" {
t.Fatalf("mode = %q", payload.Mode)
}
if payload.Action != "initialize_local_git_credential" {
t.Fatalf("action = %q", payload.Action)
}
if payload.AppID != "app_xxx" {
t.Fatalf("app_id = %q", payload.AppID)
}
if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
t.Fatalf("metadata_file = %q", payload.MetadataFile)
}
assertStringSliceEqual(t, payload.LocalEffects, []string{
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
})
}
func TestAppsGitCredentialListDryRunDescribesLocalReads(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsGitCredentialList,
[]string{"+git-credential-list", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var payload struct {
Description string `json:"description"`
API []interface{} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
StorageRoot string `json:"storage_root"`
Reads []string `json:"reads"`
}
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if payload.Description != "Preview local Git credential listing (no API call, read-only local state)." {
t.Fatalf("description = %q", payload.Description)
}
if len(payload.API) != 0 {
t.Fatalf("api len = %d, want 0", len(payload.API))
}
if payload.Mode != "local-read-only" {
t.Fatalf("mode = %q", payload.Mode)
}
if payload.Action != "list_local_git_credentials" {
t.Fatalf("action = %q", payload.Action)
}
if !strings.HasSuffix(payload.StorageRoot, filepath.Join("spark")) {
t.Fatalf("storage_root = %q", payload.StorageRoot)
}
assertStringSliceEqual(t, payload.Reads, []string{
"scan app-scoped git credential metadata under the CLI config directory",
"derive per-app repository URLs and local credential status from local metadata",
})
}
func TestAppsGitCredentialRemoveDryRunDescribesLocalCleanup(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsGitCredentialRemove,
[]string{"+git-credential-remove", "--app-id", "app_xxx", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var payload struct {
Description string `json:"description"`
API []interface{} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
AppID string `json:"app_id"`
MetadataFile string `json:"metadata_file"`
Effects []string `json:"effects"`
}
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if payload.Description != "Preview local Git credential cleanup (no API call; would clean up local-only state)." {
t.Fatalf("description = %q", payload.Description)
}
if len(payload.API) != 0 {
t.Fatalf("api len = %d, want 0", len(payload.API))
}
if payload.Mode != "local-cleanup-only" {
t.Fatalf("mode = %q", payload.Mode)
}
if payload.Action != "remove_local_git_credential" {
t.Fatalf("action = %q", payload.Action)
}
if payload.AppID != "app_xxx" {
t.Fatalf("app_id = %q", payload.AppID)
}
if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
t.Fatalf("metadata_file = %q", payload.MetadataFile)
}
assertStringSliceEqual(t, payload.Effects, []string{
"read app-scoped git credential metadata",
"remove the saved PAT from the local system credential store",
"remove the app-scoped Git helper from global git config when present",
"delete the local metadata record after cleanup succeeds",
})
}
func TestAppsGitCredentialInitRequiresAppID(t *testing.T) {
@@ -107,7 +213,7 @@ func TestParseIssueCredentialDataAcceptsDirectBaseRespShape(t *testing.T) {
"expiredTime":1780050600,
"BaseResp":{"StatusCode":0,"StatusMessage":"ok"}
}`),
}, nil)
}, nil, errclass.ClassifyContext{})
if err != nil {
t.Fatalf("parseIssueCredentialData returned error: %v", err)
}
@@ -579,6 +685,18 @@ func TestAppsGitCredentialRemoveReturnsStoreError(t *testing.T) {
}
}
func assertStringSliceEqual(t *testing.T, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("slice len = %d, want %d; got %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("slice[%d] = %q, want %q; got %#v", i, got[i], want[i], got)
}
}
}
func TestGitCredentialLocalErrorWrapsOnlyPlainErrors(t *testing.T) {
plain := errors.New("git config failed")
wrapped := gitCredentialLocalError("List local Miaoda Git credentials", plain)
@@ -599,9 +717,9 @@ func TestGitCredentialLocalErrorWrapsOnlyPlainErrors(t *testing.T) {
t.Fatalf("typed error was rewrapped: %#v", got)
}
exitErr := output.ErrValidation("bad app")
if got := gitCredentialLocalError("action", exitErr); got != exitErr {
t.Fatalf("legacy output error was rewrapped: %#v", got)
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad app")
if got := gitCredentialLocalError("action", validationErr); got != error(validationErr) {
t.Fatalf("typed validation error was rewrapped: %#v", got)
}
if got := gitCredentialLocalError("action", nil); got != nil {
@@ -807,43 +925,43 @@ func TestGitCredentialHelpersAndParsers(t *testing.T) {
}
func TestParseIssueCredentialDataErrors(t *testing.T) {
if _, err := parseIssueCredentialData(nil, errors.New("transport failed")); err == nil {
if _, err := parseIssueCredentialData(nil, errors.New("transport failed"), errclass.ClassifyContext{}); err == nil {
t.Fatal("parseIssueCredentialData transport error returned nil")
}
if _, err := parseIssueCredentialData(nil, nil); err == nil {
if _, err := parseIssueCredentialData(nil, nil, errclass.ClassifyContext{}); err == nil {
t.Fatal("parseIssueCredentialData nil response returned nil")
}
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte("{bad json")}, nil); err == nil {
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte("{bad json")}, nil, errclass.ClassifyContext{}); err == nil {
t.Fatal("parseIssueCredentialData bad json returned nil")
}
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusBadRequest, RawBody: []byte(`{"msg":"bad request"}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "bad request") {
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusBadRequest, RawBody: []byte(`{"msg":"bad request"}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "bad request") {
t.Fatalf("HTTP error = %v", err)
}
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusInternalServerError, RawBody: []byte(`{}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "HTTP 500") {
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusInternalServerError, RawBody: []byte(`{}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "HTTP 500") {
t.Fatalf("HTTP fallback error = %v", err)
}
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"failed"}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "failed") {
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"failed"}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "failed") {
t.Fatalf("code error = %v", err)
}
data, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":0}`), Header: header}, nil)
data, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":0}`), Header: header}, nil, errclass.ClassifyContext{})
if err != nil {
t.Fatalf("code zero without data returned error: %v", err)
}
if data["log_id"] != "log_x" {
t.Fatalf("log_id = %v", data["log_id"])
}
data, err = parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`null`), Header: header}, nil)
data, err = parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`null`), Header: header}, nil, errclass.ClassifyContext{})
if err != nil {
t.Fatalf("null response with log id returned error: %v", err)
}
if data["log_id"] != "log_x" {
t.Fatalf("null response log_id = %v", data["log_id"])
}
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"BaseResp":{"StatusCode":7,"StatusMessage":"denied"}}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "denied") {
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"BaseResp":{"StatusCode":7,"StatusMessage":"denied"}}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "denied") {
t.Fatalf("BaseResp error = %v", err)
}
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"baseResp":{"statusCode":7}}`)}, nil); err == nil || !strings.Contains(err.Error(), "non-zero BaseResp") {
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"baseResp":{"statusCode":7}}`)}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "non-zero BaseResp") {
t.Fatalf("BaseResp fallback error = %v", err)
}
}
@@ -852,7 +970,7 @@ func TestParseIssueCredentialDataErrors(t *testing.T) {
// credential issuance failure is flagged retryable and carries the developer-access hint.
func TestParseIssueCredentialData503IsRetryableWithHint(t *testing.T) {
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header}, nil)
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header}, nil, errclass.ClassifyContext{})
if err == nil {
t.Fatal("expected 503 error, got nil")
}
@@ -872,7 +990,7 @@ func TestParseIssueCredentialData503IsRetryableWithHint(t *testing.T) {
// non-zero business code (no HTTP status) carries the hint but is not retryable.
func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"no developer access"}`), Header: header}, nil)
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"no developer access"}`), Header: header}, nil, errclass.ClassifyContext{})
if err == nil {
t.Fatal("expected business-code error, got nil")
}
@@ -896,11 +1014,10 @@ func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
// server msg and assert (a) Message equals that msg exactly, and (b) neither
// Message nor Hint contains any token/secret-shaped string.
//
// Note: server msg passthrough is the framework's responsibility; apps adds
// only a static hint. There is no msg redaction in this path (verbatim
// passthrough is the existing behavior), so this test does not assert a
// redaction that does not exist — it asserts that apps injects nothing
// sensitive of its own.
// Note: server msg passthrough is the shared classifier's responsibility;
// apps adds only a static hint. There is no msg redaction in this path, so
// this test does not assert a redaction that does not exist — it asserts
// that apps injects nothing sensitive of its own.
func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
const serverMsg = "permission denied"
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
@@ -927,7 +1044,7 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := parseIssueCredentialData(tc.resp, nil)
_, err := parseIssueCredentialData(tc.resp, nil, errclass.ClassifyContext{})
if err == nil {
t.Fatal("expected an error, got nil")
}
@@ -935,9 +1052,12 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
// (a) The server msg is passed through verbatim.
if p.Message != serverMsg {
t.Fatalf("Message = %q, want server msg %q (verbatim passthrough)", p.Message, serverMsg)
// (a) The server msg survives into the message. The business-code
// path passes it through verbatim; the HTTP-status path reports
// "HTTP <status>: <body>" via the shared classifier, so assert
// containment rather than equality.
if !strings.Contains(p.Message, serverMsg) {
t.Fatalf("Message = %q, want it to contain server msg %q", p.Message, serverMsg)
}
// apps adds only the static hint — assert that exact static text,
// proving apps injects no per-request secret into the hint either.
@@ -1020,3 +1140,45 @@ exit 0
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
// TestParseIssueCredentialData_SharedClassifierCoverage pins the canonical
// classifications the shared classifier provides on the credential-issue
// path: a generic missing-scope code becomes a typed permission error with
// the missing scopes extracted, and an HTTP 503 becomes a retryable
// network/server_error — neither collapses to api/unknown.
func TestParseIssueCredentialData_SharedClassifierCoverage(t *testing.T) {
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
t.Run("missing scope classifies as authorization with scopes", func(t *testing.T) {
body := `{"code":99991676,"msg":"token scope insufficient","error":{"permission_violations":[{"subject":"spark:app:read"}]}}`
_, err := parseIssueCredentialData(&larkcore.ApiResp{
StatusCode: http.StatusOK, RawBody: []byte(body), Header: header,
}, nil, errclass.ClassifyContext{})
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("want *errs.PermissionError, got %T: %v", err, err)
}
if permErr.Subtype != errs.SubtypeTokenScopeInsufficient {
t.Fatalf("subtype = %q, want %q", permErr.Subtype, errs.SubtypeTokenScopeInsufficient)
}
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != "spark:app:read" {
t.Fatalf("MissingScopes = %v, want [spark:app:read]", permErr.MissingScopes)
}
})
t.Run("http 503 classifies as retryable network server_error", func(t *testing.T) {
_, err := parseIssueCredentialData(&larkcore.ApiResp{
StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header,
}, nil, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("want typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkServer {
t.Fatalf("classification = %s/%s, want network/server_error", p.Category, p.Subtype)
}
if !p.Retryable {
t.Fatalf("retryable = false, want true for 5xx")
}
})
}

View File

@@ -5,10 +5,10 @@ package gitcred
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
)
@@ -38,7 +38,7 @@ func (g GlobalGitConfig) SetHelper(ctx context.Context, gitHTTPURL, appID string
return err
}
if hadHelper && previousHelper != helper && !g.isManagedHelper(previousHelper) {
return fmt.Errorf("git credential helper already configured for %s; refusing to overwrite non-lark helper", normalizedURL)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "git credential helper already configured for %s; refusing to overwrite non-lark helper", normalizedURL)
}
if err := exec.CommandContext(ctx, "git", "config", "--global", helperKey, helper).Run(); err != nil {
return err
@@ -106,7 +106,7 @@ func gitConfigGet(ctx context.Context, key string) (string, bool, error) {
if isGitConfigGetMissing(err) {
return "", false, nil
}
return "", false, fmt.Errorf("get %s: %w", key, err)
return "", false, errs.NewInternalError(errs.SubtypeExternalTool, "git config get %s failed: %v", key, err).WithCause(err)
}
func gitConfigUnset(ctx context.Context, key string) error {
@@ -114,7 +114,7 @@ func gitConfigUnset(ctx context.Context, key string) error {
if isGitConfigUnsetMissing(err) {
return nil
}
return fmt.Errorf("unset %s: %w", key, err)
return errs.NewInternalError(errs.SubtypeExternalTool, "git config unset %s failed: %v", key, err).WithCause(err)
}
return nil
}

View File

@@ -11,7 +11,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
)
@@ -40,21 +40,21 @@ func NewManager(store *Store, secrets *SecretStore, gitConfig GitConfig, issuer
func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string) (*InitResult, error) {
appID = strings.TrimSpace(appID)
if appID == "" {
return nil, output.ErrValidation("--app-id is required")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
}
if err := validate.ResourceName(appID, "--app-id"); err != nil {
return nil, err
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err)
}
if profile.UserOpenID == "" {
return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`")
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").WithHint("run `lark-cli auth login --scope \"spark:app:read\"`")
}
unlockApp, err := lockApp(appID)
if err != nil {
return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err)
return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(err)
}
defer unlockApp()
if m.Issuer == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "git credential issuer is not configured")
return nil, errs.NewInternalError(errs.SubtypeUnknown, "git credential issuer is not configured")
}
issued, err := m.Issuer.Issue(ctx, appID, profile)
if err != nil {
@@ -125,14 +125,14 @@ func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string
func (m *Manager) Remove(ctx context.Context, profile ProfileContext, appID string) (*RemoveResult, error) {
appID = strings.TrimSpace(appID)
if appID == "" {
return nil, output.ErrValidation("--app-id is required")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
}
if err := validate.ResourceName(appID, "--app-id"); err != nil {
return nil, err
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err)
}
unlockApp, err := lockApp(appID)
if err != nil {
return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err)
return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(err)
}
defer unlockApp()
records, err := m.Store.FindByAppID(appID, ProfileContext{})
@@ -335,7 +335,7 @@ func (m *Manager) Erase(r io.Reader) error {
}
unlockApp, err := lockApp(record.AppID)
if err != nil {
return fmt.Errorf("acquire Git credential lock for %s: %w", record.AppID, err)
return errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(err)
}
defer unlockApp()
record, err = m.Store.FindByURL(url)
@@ -360,7 +360,8 @@ func (m *Manager) readConfirmed(url string, current ProfileContext) (CredentialR
return CredentialRecord{}, "", false, err
}
if record.ProfileAppID != current.ProfileAppID || record.UserOpenID != current.UserOpenID {
return CredentialRecord{}, "", false, fmt.Errorf("current login does not match initialized credential; run `lark-cli apps +git-credential-init --app-id %s` with the current login or switch back to the original account", record.AppID)
return CredentialRecord{}, "", false, errs.NewValidationError(errs.SubtypeFailedPrecondition, "current login does not match initialized credential").
WithHint(fmt.Sprintf("run `lark-cli apps +git-credential-init --app-id %s` with the current login or switch back to the original account", record.AppID))
}
pat, err := m.Secrets.Get(record.PATRef)
if err != nil {
@@ -423,7 +424,7 @@ func ParseCredentialInput(r io.Reader) (CredentialInput, error) {
func parseNormalizedForInput(raw string) (CredentialInput, error) {
parts := strings.SplitN(raw, "://", 2)
if len(parts) != 2 {
return CredentialInput{}, output.ErrValidation("invalid credential URL")
return CredentialInput{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid credential URL")
}
hostPath := parts[1]
idx := strings.Index(hostPath, "/")
@@ -457,19 +458,19 @@ func defaultUsername(username string) string {
func validateIssuedCredential(appID, normalizedURL string, issued *IssuedCredential, now int64) error {
if issued == nil {
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: empty credential")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: empty credential")
}
if issued.AppID != "" && issued.AppID != appID {
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
}
if normalizedURL == "" {
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL")
}
if strings.TrimSpace(issued.PAT) == "" {
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token")
}
if issued.ExpiresAt <= now {
return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response expiredTime must be in the future")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response expiredTime must be in the future")
}
return nil
}

View File

@@ -5,12 +5,12 @@ package gitcred
import (
"errors"
"fmt"
"path/filepath"
"regexp"
"sync"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/lockfile"
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // git credential locks live under CLI config dir and are not user file I/O.
@@ -30,7 +30,7 @@ func lockURL(url string) func() {
func lockApp(appID string) (func(), error) {
dir := filepath.Join(core.GetConfigDir(), "locks")
if err := vfs.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("create Git credential lock dir: %w", err)
return nil, errs.NewInternalError(errs.SubtypeStorage, "create Git credential lock dir: %v", err).WithCause(err)
}
name := "apps_git_credential_" + safeLockNameChars.ReplaceAllString(appID, "_") + ".lock"
lock := lockfile.New(filepath.Join(dir, filepath.Base(name)))

View File

@@ -12,17 +12,17 @@ import (
"path"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
)
func NormalizeGitHTTPURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", output.ErrValidation("git_http_url is empty")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git_http_url is empty")
}
u, err := url.Parse(raw)
if err != nil {
return "", output.ErrValidation("invalid git_http_url %q: %s", raw, err)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid git_http_url %q: %s", raw, err).WithCause(err)
}
return normalizeParsedURL(u)
}
@@ -31,7 +31,7 @@ func NormalizeCredentialInput(input CredentialInput) (string, error) {
protocol := strings.TrimSpace(input.Protocol)
host := strings.TrimSpace(input.Host)
if protocol == "" || host == "" {
return "", output.ErrValidation("git credential input must include protocol and host")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git credential input must include protocol and host")
}
u := &url.URL{
Scheme: protocol,
@@ -44,11 +44,11 @@ func NormalizeCredentialInput(input CredentialInput) (string, error) {
func normalizeParsedURL(u *url.URL) (string, error) {
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
if scheme != "http" && scheme != "https" {
return "", output.ErrValidation("git credential only supports http/https URLs")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git credential only supports http/https URLs")
}
host := normalizeHost(scheme, u.Host)
if host == "" {
return "", output.ErrValidation("git_http_url host is empty")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git_http_url host is empty")
}
cleanPath := cleanURLPath(u.EscapedPath())
normalized := (&url.URL{Scheme: scheme, Host: host, Path: cleanPath}).String()

View File

@@ -6,13 +6,13 @@ package apps
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -39,28 +39,18 @@ func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tar
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return nil, err
return nil, client.WrapDoAPIError(err)
}
return parseHTMLPublishResponse(apiResp.RawBody)
}
func parseHTMLPublishResponse(raw []byte) (*htmlPublishResponse, error) {
var envelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
URL string `json:"url"`
} `json:"data"`
data, err := api.runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return nil, enrichHTMLPublishAPIError(err)
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("decode html-publish response: %w", err)
url, _ := data["url"].(string)
if url == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"html-publish response is missing the published app url")
}
if envelope.Code != 0 {
return nil, output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("html-publish failed (code=%d): %s", envelope.Code, envelope.Msg),
buildHTMLPublishFailureHint(envelope.Code))
}
return &htmlPublishResponse{URL: envelope.Data.URL}, nil
return &htmlPublishResponse{URL: url}, nil
}
// OAPI business error codes returned by the Miaoda
@@ -74,9 +64,9 @@ const (
func buildHTMLPublishFailureHint(code int) string {
switch code {
case errCodeBuildFailed:
return "构建失败:用 `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` 检查打包文件清单"
return "server-side build failed: run `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` to inspect the packaged file list"
case errCodeAppNotFound:
return "应用不存在或无权访问;请用户确认 app_id从妙搭应用链接 https://miaoda.feishu.cn/app/app_xxx /app/ 后面提取,或直接给 app_xxx 字符串)"
return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the Miaoda app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
default:
return ""
}

View File

@@ -6,21 +6,21 @@ package apps
import (
"bytes"
"context"
"errors"
"mime"
"mime/multipart"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
@@ -94,15 +94,57 @@ func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
if err == nil {
t.Fatalf("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
problem := requireAppsAPIProblem(t, err)
if problem.Code != errCodeBuildFailed {
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
}
if exitErr.Detail.Hint == "" {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint on code 90001")
}
if !strings.Contains(exitErr.Detail.Message, "build failed") {
t.Fatalf("missing failure message: %v", exitErr.Detail.Message)
if !strings.Contains(problem.Message, "build failed") {
t.Fatalf("missing failure message: %v", problem.Message)
}
}
func TestAppsHTMLPublishAPI_AppNotFoundClassified(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_missing/upload_and_release_html_code",
Body: map[string]interface{}{
"code": errCodeAppNotFound,
"msg": "app not found",
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_missing", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsAPIProblem(t, err)
if problem.Subtype != errs.SubtypeNotFound {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
}
if problem.Hint == "" {
t.Fatalf("expected app-not-found recovery hint")
}
}
func TestAppsHTMLPublishAPI_MissingURLIsInvalidResponse(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
}
}
@@ -138,8 +180,18 @@ func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing
}
}
func TestParseHTMLPublishResponse_InvalidJSON(t *testing.T) {
if _, err := parseHTMLPublishResponse([]byte("{not json")); err == nil {
t.Error("malformed html-publish response must surface a decode error")
func TestAppsHTMLPublishAPI_MalformedResponseIsInvalidResponse(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
RawBody: []byte("{not json"),
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
}
}

View File

@@ -9,10 +9,9 @@ import (
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
@@ -26,7 +25,7 @@ type htmlPublishTarball struct {
func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidate) (*htmlPublishTarball, error) {
if len(candidates) == 0 {
return nil, errors.New("no files to pack")
return nil, appsValidationParamError("--path", "no files to pack")
}
var buf bytes.Buffer
@@ -45,10 +44,10 @@ func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidat
if err := tw.Close(); err != nil {
_ = gz.Close()
return nil, fmt.Errorf("tar close: %w", err)
return nil, appsFileIOError(err, "tar close: %v", err)
}
if err := gz.Close(); err != nil {
return nil, fmt.Errorf("gzip close: %w", err)
return nil, appsFileIOError(err, "gzip close: %v", err)
}
return &htmlPublishTarball{
@@ -60,12 +59,12 @@ func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidat
func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCandidate) error {
if isUnsafeRelPath(c.RelPath) {
return fmt.Errorf("invalid tar entry name %q", c.RelPath)
return errs.NewInternalError(errs.SubtypeUnknown, "invalid tar entry name %q", c.RelPath)
}
src, err := fio.Open(c.AbsPath)
if err != nil {
return fmt.Errorf("open %s: %w", c.AbsPath, err)
return appsInputPathEntryError(c.AbsPath, err)
}
defer src.Close()
@@ -76,10 +75,10 @@ func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCa
Typeflag: tar.TypeReg,
}
if err := tw.WriteHeader(hdr); err != nil {
return fmt.Errorf("write header %s: %w", c.RelPath, err)
return appsFileIOError(err, "write header %s: %v", c.RelPath, err)
}
if _, err := io.Copy(tw, src); err != nil {
return fmt.Errorf("copy %s: %w", c.RelPath, err)
return appsFileIOError(err, "copy %s: %v", c.RelPath, err)
}
return nil
}

View File

@@ -5,11 +5,11 @@ package apps
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // existing apps storage persists CLI config-dir state; it is not user file I/O.
@@ -25,10 +25,10 @@ const storageRoot = "spark"
// can traverse out of the storage directory.
func checkSeg(name, what string) error {
if err := validate.ResourceName(name, what); err != nil {
return fmt.Errorf("apps storage: %w", err)
return appsStorageError(err, "apps storage: %v", err)
}
if name == "." {
return fmt.Errorf("apps storage: %s must not be \".\"", what)
return errs.NewInternalError(errs.SubtypeStorage, "apps storage: %s must not be \".\"", what)
}
return nil
}
@@ -60,7 +60,7 @@ func Read(appID, key string) ([]byte, error) {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("apps storage: read: %w", err)
return nil, appsStorageError(err, "apps storage: read: %v", err)
}
return data, nil
}
@@ -79,10 +79,10 @@ func Write(appID, key string, data []byte) error {
return err
}
if err := vfs.MkdirAll(appDir(appID), 0700); err != nil {
return fmt.Errorf("apps storage: create dir: %w", err)
return appsStorageError(err, "apps storage: create dir: %v", err)
}
if err := validate.AtomicWrite(appKeyPath(appID, key), data, 0600); err != nil {
return fmt.Errorf("apps storage: write: %w", err)
return appsStorageError(err, "apps storage: write: %v", err)
}
return nil
}
@@ -96,7 +96,7 @@ func Delete(appID, key string) error {
return err
}
if err := vfs.Remove(appKeyPath(appID, key)); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("apps storage: delete: %w", err)
return appsStorageError(err, "apps storage: delete: %v", err)
}
return nil
}
@@ -113,7 +113,7 @@ func List(appID string) ([]string, error) {
if errors.Is(err, os.ErrNotExist) {
return []string{}, nil
}
return nil, fmt.Errorf("apps storage: read dir: %w", err)
return nil, appsStorageError(err, "apps storage: read dir: %v", err)
}
keys := make([]string, 0, len(entries))
for _, e := range entries {

View File

@@ -4,11 +4,11 @@
package apps
import (
"fmt"
"io/fs"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
@@ -40,7 +40,7 @@ func isUnsafeRelPath(rel string) bool {
func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublishCandidate, error) {
stat, err := fio.Stat(rootPath)
if err != nil {
return nil, fmt.Errorf("stat %s: %w", rootPath, err)
return nil, appsInputPathError(err)
}
if !stat.IsDir() {
return []htmlPublishCandidate{{
@@ -54,14 +54,23 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis
//nolint:forbidigo // fileio has no WalkDir; rootPath is already validated above via fio.Stat -> SafeInputPath.
err = filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
return appsInputPathEntryError(path, walkErr)
}
// Skip a stray git repo: a directory named .git skips the whole subtree,
// and a .git file (the gitdir pointer used by submodules/worktrees) is
// skipped too.
if d.Name() == ".git" {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
return appsInputPathEntryError(path, err)
}
// 只接受 regular file —— symlink / device / pipe / socket 都跳过。
// symlink 不跟随是设计决策(避免 loop + out-of-root 引用),且 fio.Open 也会拒非 regular。
@@ -70,7 +79,7 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis
}
rel, err := filepath.Rel(rootPath, path)
if err != nil {
return err
return appsFileIOError(err, "resolve relative path for %s: %v", path, err)
}
relSlash := filepath.ToSlash(rel)
// Defense in depth: WalkDir + Rel inside rootPath should never yield a
@@ -78,7 +87,7 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis
// filesystem layout shouldn't be able to inject one into RelPath.
// Mirrors the same guard at tar entry write time.
if isUnsafeRelPath(relSlash) {
return fmt.Errorf("walker produced unsafe relative path %q for %s", relSlash, path)
return errs.NewInternalError(errs.SubtypeUnknown, "walker produced unsafe relative path %q for %s", relSlash, path)
}
out = append(out, htmlPublishCandidate{
RelPath: relSlash,

View File

@@ -113,6 +113,102 @@ func TestIsUnsafeRelPath(t *testing.T) {
}
}
func TestWalkHTMLPublishCandidates_ExcludesGitDir(t *testing.T) {
dir := t.TempDir()
files := map[string]string{
"index.html": "<html></html>",
".git/config": "[core]\n",
".git/HEAD": "ref: refs/heads/main\n",
".git/objects/ab/cdef123": "binary",
".github/workflows/ci.yml": "on: push\n",
".gitignore": "node_modules\n",
}
for rel, content := range files {
full := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
if err != nil {
t.Fatalf("err=%v", err)
}
rels := make(map[string]bool)
for _, c := range got {
rels[c.RelPath] = true
}
// .git 子树整体排除
for _, banned := range []string{".git/config", ".git/HEAD", ".git/objects/ab/cdef123"} {
if rels[banned] {
t.Errorf("%q should be excluded from candidates", banned)
}
}
// 相邻名不受影响
for _, kept := range []string{"index.html", ".github/workflows/ci.yml", ".gitignore"} {
if !rels[kept] {
t.Errorf("%q should be kept in candidates, got %+v", kept, got)
}
}
}
func TestWalkHTMLPublishCandidates_ExcludesNestedGitDir(t *testing.T) {
dir := t.TempDir()
files := map[string]string{
"index.html": "<html></html>",
"sub/.git/config": "[core]\n",
"sub/page.html": "<html></html>",
}
for rel, content := range files {
full := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
if err != nil {
t.Fatalf("err=%v", err)
}
rels := make(map[string]bool)
for _, c := range got {
rels[c.RelPath] = true
}
if rels["sub/.git/config"] {
t.Errorf("nested sub/.git/config should be excluded, got %+v", got)
}
if !rels["sub/page.html"] || !rels["index.html"] {
t.Errorf("non-git files should be kept, got %+v", got)
}
}
func TestWalkHTMLPublishCandidates_ExcludesGitFile(t *testing.T) {
// submodule / worktree 场景:.git 是个 gitdir 指针文件,不是目录。
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: /elsewhere\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
if err != nil {
t.Fatalf("err=%v", err)
}
for _, c := range got {
if c.RelPath == ".git" {
t.Errorf(".git pointer file should be excluded, got %+v", got)
}
}
}
func TestWalkHTMLPublishCandidates_SymlinkSkipped(t *testing.T) {
// Walker 只接受 regular file —— symlink 跳过(避免 loop + out-of-root 引用,
// 且 fio.Open 对 symlink 行为不一致。real.html 仍然被收link.html 不在结果里。

View File

@@ -298,6 +298,14 @@ func (ctx *RuntimeContext) CallAPITyped(method, url string, params map[string]in
// carry fields a caller needs on failure (e.g. the file_token an overwrite
// returned, for token-stability handling).
func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[string]interface{}, error) {
return ClassifyAPIResponseWith(resp, ctx.APIClassifyContext())
}
// ClassifyAPIResponseWith is the RuntimeContext-free form of
// ClassifyAPIResponse for callers that drive the request outside a running
// shortcut (e.g. a cobra command holding only a factory) and supply their own
// classification context.
func ClassifyAPIResponseWith(resp *larkcore.ApiResp, cc errclass.ClassifyContext) (map[string]interface{}, error) {
logID, _ := logIDFromHeader(resp)["log_id"].(string)
result, parseErr := client.ParseJSONResponse(resp)
@@ -321,7 +329,7 @@ func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[stri
}
}
out, _ := resultMap["data"].(map[string]interface{})
if apiErr := errclass.BuildAPIError(resultMap, ctx.APIClassifyContext()); apiErr != nil {
if apiErr := errclass.BuildAPIError(resultMap, cc); apiErr != nil {
return out, apiErr
}
if resp.StatusCode >= 400 {

View File

@@ -317,6 +317,17 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImChatSearch invalid chat-modes value", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "ok",
"chat-modes": "group,bogus",
}, nil)
err := ImChatSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "invalid --chat-modes value") {
t.Fatalf("ImChatSearch.Validate() error = %v", err)
}
})
t.Run("ImChatUpdate requires fields", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
@@ -693,6 +704,39 @@ func TestShortcutDryRunShapes(t *testing.T) {
}
})
t.Run("ImChatSearch dry run maps chat-modes to wire values", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
"chat-modes": "group,topic",
}, nil)
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"chat_modes":["default","thread"]`) {
t.Fatalf("ImChatSearch.DryRun() chat_modes mapping = %s", got)
}
})
t.Run("ImChatSearch dry run maps single chat-mode topic", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
"chat-modes": "topic",
}, nil)
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"chat_modes":["thread"]`) {
t.Fatalf("ImChatSearch.DryRun() chat_modes mapping = %s", got)
}
})
t.Run("ImChatSearch dry run dedupes chat-modes", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
"chat-modes": "group, group",
}, nil)
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"chat_modes":["default"]`) {
t.Fatalf("ImChatSearch.DryRun() chat_modes dedupe = %s", got)
}
})
t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"query": "incident",
@@ -797,7 +841,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
"page-size": "10",
}, nil)
got := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":10`) {
if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":"10"`) {
t.Fatalf("ImThreadsMessagesList.DryRun() = %s", got)
}
})
@@ -857,7 +901,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
t.Run("ImChatList dry run includes endpoint and params", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
"sort": "create_time",
}, nil)
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {

View File

@@ -48,7 +48,8 @@ var ImChatList = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "sort-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
{Name: "sort", Default: "create_time", Desc: "sort field: create_time (ascending) | active_time (descending)", Enum: []string{"create_time", "active_time"}},
{Name: "sort-type", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
{Name: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "pagination token for next page"},
@@ -266,9 +267,16 @@ func resolveTypes(runtime *common.RuntimeContext) (string, bool, error) {
// CSV string already normalized + bot-stripped by resolveTypes; pass "" to
// omit the types query param entirely (backward compatible default).
func buildChatListParams(runtime *common.RuntimeContext, effectiveTypes string) map[string]interface{} {
sortType := map[string]string{
"create_time": "ByCreateTimeAsc",
"active_time": "ByActiveTimeDesc",
}[runtime.Str("sort")]
if old, ok := aliasFlagValue(runtime, "sort-type", "sort"); ok {
sortType = old // old value is already the upstream enum -> pass through
}
params := map[string]interface{}{
"user_id_type": runtime.Str("user-id-type"),
"sort_type": runtime.Str("sort-type"),
"sort_type": sortType,
}
if n := runtime.Int("page-size"); n > 0 {
params["page_size"] = n

View File

@@ -611,3 +611,85 @@ func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
t.Fatalf("remaining chat = %v; want oc_g", parsed.Data.Chats[0]["chat_id"])
}
}
func TestChatList_SortMapping(t *testing.T) {
cases := []struct{ sort, want string }{
{"create_time", "ByCreateTimeAsc"},
{"active_time", "ByActiveTimeDesc"},
}
for _, c := range cases {
t.Run(c.sort, func(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{"sort": c.sort}, nil)
got := buildChatListParams(rt, "")
if got["sort_type"] != c.want {
t.Fatalf("sort=%s -> sort_type=%v, want %s", c.sort, got["sort_type"], c.want)
}
})
}
}
// TestChatList_SortAliasParity proves the hidden --sort-type alias maps to the
// exact same upstream request as the equivalent new --sort value (byte-equal).
func TestChatList_SortAliasParity(t *testing.T) {
pairs := []struct{ newVal, oldVal string }{
{"create_time", "ByCreateTimeAsc"},
{"active_time", "ByActiveTimeDesc"},
}
for _, p := range pairs {
t.Run(p.newVal, func(t *testing.T) {
newRT := newChatListTestRuntimeContext(t, map[string]string{"sort": p.newVal}, nil)
oldRT := newChatListTestRuntimeContext(t, map[string]string{"sort-type": p.oldVal}, nil)
a := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), newRT))
b := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), oldRT))
if a != b {
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
}
})
}
}
// TestChatList_SortNewWins: both flags set -> new wins, old ignored, no error.
func TestChatList_SortNewWins(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"sort": "active_time",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := buildChatListParams(rt, "")
if got["sort_type"] != "ByActiveTimeDesc" {
t.Fatalf("new should win: sort_type=%v, want ByActiveTimeDesc", got["sort_type"])
}
}
// TestChatList_SortFlagSurface asserts the declared flag structure.
func TestChatList_SortFlagSurface(t *testing.T) {
var sortFlag, aliasFlag *common.Flag
for i := range ImChatList.Flags {
switch ImChatList.Flags[i].Name {
case "sort":
sortFlag = &ImChatList.Flags[i]
case "sort-type":
aliasFlag = &ImChatList.Flags[i]
}
}
if sortFlag == nil || aliasFlag == nil {
t.Fatalf("expected both --sort and --sort-type flags declared")
}
if sortFlag.Default != "create_time" {
t.Errorf("--sort Default = %q, want create_time", sortFlag.Default)
}
if got := strings.Join(sortFlag.Enum, ","); got != "create_time,active_time" {
t.Errorf("--sort Enum = %q, want create_time,active_time", got)
}
if !strings.Contains(sortFlag.Desc, "create_time") || !strings.Contains(sortFlag.Desc, "active_time") {
t.Errorf("--sort Desc must document both fields/directions: %q", sortFlag.Desc)
}
if !aliasFlag.Hidden {
t.Errorf("--sort-type must be Hidden")
}
if got := strings.Join(aliasFlag.Enum, ","); got != "ByCreateTimeAsc,ByActiveTimeDesc" {
t.Errorf("--sort-type Enum = %q, want ByCreateTimeAsc,ByActiveTimeDesc", got)
}
if aliasFlag.Default != "" {
t.Errorf("--sort-type (hidden alias) must not carry a Default, got %q", aliasFlag.Default)
}
}

View File

@@ -32,7 +32,8 @@ var ImChatMessageList = common.Shortcut{
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"},
{Name: "start", Desc: "start time (ISO 8601)"},
{Name: "end", Desc: "end time (ISO 8601)"},
{Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}},
{Name: "order", Default: "desc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
{Name: "page-size", Default: "50", Desc: "page size (1-50)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
@@ -209,7 +210,11 @@ func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.Q
}
func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string) (larkcore.QueryParams, error) {
params := buildChatMessageListParams(runtime.Str("sort"), runtime.Str("page-size"), chatId)
dir := runtime.Str("order")
if old, ok := aliasFlagValue(runtime, "sort", "order"); ok {
dir = old // old value is asc/desc -> must go through the same map, never pass through
}
params := buildChatMessageListParams(dir, runtime.Str("page-size"), chatId)
if startFlag := runtime.Str("start"); startFlag != "" {
startTime, err := common.ParseTime(startFlag)

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// newMsgListTestRT registers chat-id (so the builder has a container) plus the
// sort flags under test; only flags present in stringFlags are "set" (Changed).
func newMsgListTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
t.Helper()
if stringFlags == nil {
stringFlags = map[string]string{}
}
if _, ok := stringFlags["chat-id"]; !ok {
stringFlags["chat-id"] = "oc_test"
}
return newChatListTestRuntimeContext(t, stringFlags, nil)
}
func TestChatMessagesList_OrderMapping(t *testing.T) {
cases := []struct{ order, want string }{
{"asc", "ByCreateTimeAsc"},
{"desc", "ByCreateTimeDesc"},
}
for _, c := range cases {
t.Run(c.order, func(t *testing.T) {
rt := newMsgListTestRT(t, map[string]string{"order": c.order})
params, err := buildChatMessageListRequest(rt, "oc_test")
if err != nil {
t.Fatalf("buildChatMessageListRequest() error = %v", err)
}
if got := params["sort_type"][0]; got != c.want {
t.Fatalf("order=%s -> sort_type=%s, want %s", c.order, got, c.want)
}
})
}
}
// TestChatMessagesList_OrderAliasParity: hidden --sort alias (asc/desc) must map
// through the SAME table as --order (NOT pass through), producing identical upstream.
func TestChatMessagesList_OrderAliasParity(t *testing.T) {
for _, dir := range []string{"asc", "desc"} {
t.Run(dir, func(t *testing.T) {
newRT := newMsgListTestRT(t, map[string]string{"order": dir})
oldRT := newMsgListTestRT(t, map[string]string{"sort": dir})
a := mustMarshalDryRun(t, ImChatMessageList.DryRun(context.Background(), newRT))
b := mustMarshalDryRun(t, ImChatMessageList.DryRun(context.Background(), oldRT))
if a != b {
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
}
})
}
}
func TestChatMessagesList_OrderNewWins(t *testing.T) {
rt := newMsgListTestRT(t, map[string]string{"order": "asc", "sort": "desc"})
params, err := buildChatMessageListRequest(rt, "oc_test")
if err != nil {
t.Fatalf("error = %v", err)
}
if got := params["sort_type"][0]; got != "ByCreateTimeAsc" {
t.Fatalf("new should win: sort_type=%s, want ByCreateTimeAsc", got)
}
}
func TestChatMessagesList_OrderFlagSurface(t *testing.T) {
var orderFlag, aliasFlag *common.Flag
for i := range ImChatMessageList.Flags {
switch ImChatMessageList.Flags[i].Name {
case "order":
orderFlag = &ImChatMessageList.Flags[i]
case "sort":
aliasFlag = &ImChatMessageList.Flags[i]
}
}
if orderFlag == nil || aliasFlag == nil {
t.Fatalf("expected both --order and --sort flags declared")
}
if orderFlag.Default != "desc" {
t.Errorf("--order Default = %q, want desc", orderFlag.Default)
}
if got := strings.Join(orderFlag.Enum, ","); got != "asc,desc" {
t.Errorf("--order Enum = %q, want asc,desc", got)
}
if !aliasFlag.Hidden {
t.Errorf("--sort must be Hidden")
}
if got := strings.Join(aliasFlag.Enum, ","); got != "asc,desc" {
t.Errorf("--sort (alias) Enum = %q, want asc,desc", got)
}
}

View File

@@ -31,10 +31,12 @@ var ImChatSearch = common.Shortcut{
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (max 64 chars)"},
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
{Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"},
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
{Name: "is-manager", Type: "bool", Desc: "only show chats you created or manage"},
{Name: "disable-search-by-user", Type: "bool", Desc: "disable search-by-member-name (default: search by member name first, then group name)"},
{Name: "sort-by", Desc: "sort field (descending)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
{Name: "sort", Desc: "sort field (always descending): create_time | update_time | member_count", Enum: []string{"create_time", "update_time", "member_count"}},
{Name: "sort-by", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
@@ -72,6 +74,13 @@ var ImChatSearch = common.Shortcut{
}
}
}
if cm := runtime.Str("chat-modes"); cm != "" {
for _, mode := range common.SplitCSV(cm) {
if mode != "group" && mode != "topic" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-modes value %q: expected one of group, topic", mode).WithParam("--chat-modes")
}
}
}
if mi := runtime.Str("member-ids"); mi != "" {
ids := common.SplitCSV(mi)
if len(ids) > 50 {
@@ -201,8 +210,8 @@ var ImChatSearch = common.Shortcut{
// buildSearchChatBody builds the JSON request body for POST /im/v2/chats/search
// from the runtime flag values. The query string is normalized via
// normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object
// is omitted when no filter flags are set; "sorter" is omitted when --sort-by
// is empty.
// is omitted when no filter flags are set; "sorter" is omitted when --sort
// (and its hidden alias --sort-by) is unset.
func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
@@ -217,6 +226,24 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{}
if st := runtime.Str("search-types"); st != "" {
filter["search_types"] = common.SplitCSV(st)
}
// chat_modes is a server-side filter. The CLI exposes group/topic; the wire
// expects default/thread. Map and dedupe (the API caps the list at 2, and
// there are only 2 distinct modes) while preserving the user's order.
if cm := runtime.Str("chat-modes"); cm != "" {
seen := map[string]bool{}
var modes []string
for _, mode := range common.SplitCSV(cm) {
wire := map[string]string{"group": "default", "topic": "thread"}[mode]
if wire == "" || seen[wire] {
continue
}
seen[wire] = true
modes = append(modes, wire)
}
if len(modes) > 0 {
filter["chat_modes"] = modes
}
}
if mi := runtime.Str("member-ids"); mi != "" {
filter["member_ids"] = common.SplitCSV(mi)
}
@@ -230,9 +257,18 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{}
body["filter"] = filter
}
// Build sorters (always descending)
if sortBy := runtime.Str("sort-by"); sortBy != "" {
body["sorter"] = sortBy
// Build sorter (always descending). --sort maps field -> field_desc; the hidden
// --sort-by alias is already the upstream value (pass-through). Omitted when unset.
sorter := map[string]string{
"create_time": "create_time_desc",
"update_time": "update_time_desc",
"member_count": "member_count_desc",
}[runtime.Str("sort")]
if old, ok := aliasFlagValue(runtime, "sort-by", "sort"); ok {
sorter = old
}
if sorter != "" {
body["sorter"] = sorter
}
return body

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
func newSearchTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
t.Helper()
if stringFlags == nil {
stringFlags = map[string]string{}
}
if _, ok := stringFlags["query"]; !ok {
stringFlags["query"] = "team"
}
return newChatListTestRuntimeContext(t, stringFlags, nil)
}
func TestChatSearch_SortMapping(t *testing.T) {
cases := []struct{ sort, want string }{
{"create_time", "create_time_desc"},
{"update_time", "update_time_desc"},
{"member_count", "member_count_desc"},
}
for _, c := range cases {
t.Run(c.sort, func(t *testing.T) {
rt := newSearchTestRT(t, map[string]string{"sort": c.sort})
body := buildSearchChatBody(rt)
if body["sorter"] != c.want {
t.Fatalf("sort=%s -> sorter=%v, want %s", c.sort, body["sorter"], c.want)
}
})
}
}
// TestChatSearch_SortOmittedWhenUnset: no --sort and no --sort-by -> sorter omitted.
func TestChatSearch_SortOmittedWhenUnset(t *testing.T) {
rt := newSearchTestRT(t, nil)
body := buildSearchChatBody(rt)
if _, present := body["sorter"]; present {
t.Fatalf("sorter should be omitted when neither --sort nor --sort-by set")
}
}
// TestChatSearch_SortAliasParity: hidden --sort-by value is already the upstream
// sorter (pass-through), so it must equal the mapped new --sort body.
func TestChatSearch_SortAliasParity(t *testing.T) {
pairs := []struct{ newVal, oldVal string }{
{"create_time", "create_time_desc"},
{"update_time", "update_time_desc"},
{"member_count", "member_count_desc"},
}
for _, p := range pairs {
t.Run(p.newVal, func(t *testing.T) {
newBody := buildSearchChatBody(newSearchTestRT(t, map[string]string{"sort": p.newVal}))
oldBody := buildSearchChatBody(newSearchTestRT(t, map[string]string{"sort-by": p.oldVal}))
if newBody["sorter"] != oldBody["sorter"] {
t.Fatalf("alias parity: new sorter=%v, old sorter=%v", newBody["sorter"], oldBody["sorter"])
}
})
}
}
func TestChatSearch_SortNewWins(t *testing.T) {
rt := newSearchTestRT(t, map[string]string{"sort": "member_count", "sort-by": "create_time_desc"})
body := buildSearchChatBody(rt)
if body["sorter"] != "member_count_desc" {
t.Fatalf("new should win: sorter=%v, want member_count_desc", body["sorter"])
}
}
func TestChatSearch_SortFlagSurface(t *testing.T) {
var sortFlag, aliasFlag *common.Flag
for i := range ImChatSearch.Flags {
switch ImChatSearch.Flags[i].Name {
case "sort":
sortFlag = &ImChatSearch.Flags[i]
case "sort-by":
aliasFlag = &ImChatSearch.Flags[i]
}
}
if sortFlag == nil || aliasFlag == nil {
t.Fatalf("expected both --sort and --sort-by flags declared")
}
if sortFlag.Default != "" {
t.Errorf("--sort must have no default (sorter omitted when unset), got %q", sortFlag.Default)
}
if got := strings.Join(sortFlag.Enum, ","); got != "create_time,update_time,member_count" {
t.Errorf("--sort Enum = %q", got)
}
if !aliasFlag.Hidden {
t.Errorf("--sort-by must be Hidden")
}
if got := strings.Join(aliasFlag.Enum, ","); got != "create_time_desc,update_time_desc,member_count_desc" {
t.Errorf("--sort-by Enum = %q", got)
}
}

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