diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2ba1ecadc..61013c316 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,7 +9,7 @@ ## Test Plan - [ ] Unit tests pass -- [ ] Manual local verification confirms the `lark xxx` command works as expected +- [ ] Manual local verification confirms the `lark-cli ` flow works as expected ## Related Issues diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37745d6ba..3349fb447 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,8 @@ jobs: run: python3 scripts/fetch_meta.py - name: Run golangci-lint run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main + - name: Run errs/ lint guards (lintcheck) + run: go run -C lint . .. coverage: needs: fast-gate diff --git a/.gitignore b/.gitignore index 437052468..cf7fd1bec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Build output -/lark-cli +/lark-cli* .cache/ dist/ bin/ diff --git a/.golangci.yml b/.golangci.yml index c15ebe084..f9a51eae8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -49,18 +49,39 @@ linters: - gocritic - depguard - forbidigo - - path-except: (shortcuts/|internal/) + # Paths that run forbidigo. Add an entry when a path joins one of + # the rules below. + - path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/) linters: - forbidigo - path: internal/vfs/ linters: - forbidigo - # The shortcuts-no-raw-http forbidigo rule below is shortcuts-only; - # internal/ legitimately wraps raw HTTP for the client / credential layer. + # shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP + # for the client / credential layer. - path-except: shortcuts/ text: shortcuts-no-raw-http 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/calendar/helpers\.go|shortcuts/drive/) + 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/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go) + text: errs-no-bare-wrap + linters: + - forbidigo + # errs-no-legacy-helper is drive-only: the shared helpers it bans are + # still used by other domains until their later migration phase. + - path-except: (shortcuts/drive/) + text: errs-no-legacy-helper + linters: + - forbidigo settings: depguard: @@ -79,6 +100,30 @@ linters: Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation. forbidigo: forbid: + # ── legacy output.Err* helpers banned on migrated paths ── + # output.ErrBare is intentionally not listed — it is the predicate- + # command silent-exit signal, outside the typed envelope contract. + - pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b + msg: >- + [errs-typed-only] use errs.NewXxxError(...) builder + (see errs/types.go). + # ── legacy shared error helpers banned on drive ── + # These helpers internally produce legacy output.Err* shapes, so they + # are invisible to the errs-typed-only ban above. Drive has migrated its + # calls to typed errs.* (drive-local driveInputStatError / driveSaveError); + # this prevents reintroduction. Other domains still use the shared + # helpers (migrated globally in a later phase), so this is drive-scoped. + - pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b + msg: >- + [errs-no-legacy-helper] these shared helpers emit legacy output.Err* + shapes. Use the typed errs.NewXxxError builders or the drive-local + driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go). + # ── bare error wraps banned on fully-typed paths ── + - pattern: (fmt\.Errorf|errors\.New)\b + msg: >- + [errs-no-bare-wrap] final errors must be typed (errs.NewXxxError); + wrap a cause with .WithCause(err). Genuine intermediate wraps: + //nolint:forbidigo with a reason. # ── http: shortcuts must not construct raw HTTP requests ── # Bans request / client construction; constants (http.MethodPost, # http.StatusOK) and pure helpers (http.StatusText, http.Header) are diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a07fd89b..0e430d1d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,135 @@ All notable changes to this project will be documented in this file. +## [v1.0.46] - 2026-06-02 + +### Features + +- **im**: Add card message format support (#1218) +- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216) +- **vc**: Inline transcript from artifacts API and add keywords (#1206) +- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181) +- **agent**: Increase agent trace max length to 1024 (#1211) +- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156) + +### Bug Fixes + +- **cli**: Remove FLAGS section from root `--help` (#1226) +- **cli**: Stop root `--help` listing per-command flags as global (#1223) + +### Refactor + +- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213) + +### Documentation + +- **base**: Optimize base skill references (#1171) +- **drive**: Add Lark Drive knowledge organization workflow (#1028) + +## [v1.0.45] - 2026-06-01 + +### Features + +- **errors**: Add typed envelope contract for auth-domain errors (#1135) +- **platform**: Support multiple policy rules per plugin (#1182) + +### Bug Fixes + +- **vc**: Add domain boundaries and enrich `+notes` (#1172) +- **whiteboard**: Fix whiteboard skill (#1180) + +### Refactor + +- **auth**: Update login hint and split-flow docs (#1201) + +## [v1.0.44] - 2026-05-29 + +### Features + +- **base**: Add dashboard block data shortcut and workflow docs (#1067) +- **im**: Support `--types` flag for listing p2p single chats in `chat-list` (#1077) +- **agent**: Add agent header support (#1158) + +### Bug Fixes + +- **im**: Correct 64-bit MP4 box size handling to prevent panic on crafted media (#1165) +- **install**: Detect curl version before using `--ssl-revoke-best-effort` (#1124) +- **vc**: Correct `--minute-token` to `--minute-tokens` in recording reference (#1170) +- **whiteboard**: Fix whiteboard skill (#1166) + +## [v1.0.43] - 2026-05-28 + +### Features + +- **event**: Support `note` generated event (#1159) +- **config**: Decouple `--lang` preference from TUI display language (#1132) +- **mail**: Add HTML lint library with Larksuite-native autofix for `lark-mail` (#1019) + +### Bug Fixes + +- **config**: Propagate `Lang` across credential boundary; respect `CurrentApp` in priorLang (#1157) +- **config**: Allow lark-channel bind source override (#1154) +- **im**: Clarify `messages-send` dry-run chat membership (#1150) +- **base**: Include `log_id` in attachment media errors (#1133) + +### Performance + +- **im**: Parallelize reactions, thread_replies, and merge_forward fetches (#1146) + +### Documentation + +- **im**: Update IM skill urgent APIs (#1153) + +## [v1.0.42] - 2026-05-27 + +### Features + +- **mail**: Add `+draft-send` shortcut for batch draft sending (#1017) +- **im**: Enrich messages with reactions and output `update_time` (#1095) +- **schema**: Output JSON spec envelope for all API commands (#1048) +- **event**: Support `vc` / `note` / `minute` events (#1113) +- **drive**: Add secure label shortcuts (#985) +- **affordance**: Use description and command in affordance example schema (#1126) + +### Bug Fixes + +- **docs**: Remove unsupported `fetch` text format (#1109) + +### Refactor + +- **auth**: Drop duplicate top-level user fields in `status` (#1128) + +### Documentation + +- **doc**: Document block anchor URLs in `lark-doc` skill (#1120) +- **whiteboard**: Improve SVG/Mermaid instructions (#1097) + +## [v1.0.41] - 2026-05-26 + +### Features + +- **minutes**: Add minutes edit shortcuts (#1036) +- **minutes**: Get minutes keywords (#1079) +- **slides**: Support importing pptx as slides (#1068) +- **config**: Add `keychain-downgrade` subcommand (macOS) (#1085) +- **errors**: Add structured CLI error contract (#984) +- **apps**: Replace `+html-publish` cwd hard-reject with credential-file scan (#1072) + +### Bug Fixes + +- **drive**: Support doubao drive inspect URL variants (#1106) +- **skills**: Sync skills incrementally during update (#1042) +- **apps**: Read app object from `data.app` for `+create` and `+update` (#1087) +- **common**: Escape special chars in multipart form filenames (#1037) +- **auth**: Remove fenced code block guidance from auth URL output hints (#1088) + +### Documentation + +- **skills**: Fix agent routing for doubao.com URLs (#1082) +- **task**: Require `--complete=false` for pending standup summaries (#1101) +- **base**: Document UI-only field settings (#1078) +- **contributing**: Clarify contributor guidance (#1096) + ## [v1.0.40] - 2026-05-25 ### Features @@ -860,6 +989,12 @@ Bundled AI agent skills for intelligent assistance: - Bilingual documentation (English & Chinese). - CI/CD pipelines: linting, testing, coverage reporting, and automated releases. +[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46 +[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45 +[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44 +[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43 +[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42 +[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41 [v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40 [v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39 [v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38 diff --git a/README.md b/README.md index 56e4dba11..f61910841 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,8 @@ Community contributions are welcome! If you find a bug or have feature suggestio For major changes, we recommend discussing with us first via an Issue. +Before opening a PR, see [AGENTS.md](./AGENTS.md) for the local build, test, and PR checklist used by contributors and AI agents. + ## License This project is licensed under the **MIT License**. diff --git a/README.zh.md b/README.zh.md index 82b55305a..d0df8d4e6 100644 --- a/README.zh.md +++ b/README.zh.md @@ -280,6 +280,8 @@ lark-cli schema im.messages.delete 对于较大的改动,建议先通过 Issue 与我们讨论。 +提交 PR 前,请先阅读 [AGENTS.md](./AGENTS.md),其中列出了贡献者和 AI Agent 使用的本地构建、测试和 PR 检查清单。 + ## 许可证 本项目基于 **MIT 许可证** 开源。 diff --git a/cmd/api/api.go b/cmd/api/api.go index 4c7712fe6..057493d42 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -238,10 +238,10 @@ func apiRun(opts *APIOptions) error { resp, err := ac.DoAPI(opts.Ctx, request) if err != nil { - // MarkRaw tells the dispatcher to skip enrichPermissionError so the - // raw API error detail (log_id, troubleshooter, permission_violations) - // stays on the wire — `lark-cli api` callers explicitly want the raw - // envelope. + // MarkRaw tells the dispatcher to skip the legacy enrichPermissionError + // pass on *output.ExitError values. Typed *errs.* errors that flow + // through here keep their canonical message / hint from BuildAPIError; + // MarkRaw is a no-op on those (it only flips a flag on *ExitError). return output.MarkRaw(err) } err = client.HandleResponse(resp, client.ResponseOptions{ @@ -253,14 +253,14 @@ func apiRun(opts *APIOptions) error { FileIO: f.ResolveFileIO(opts.Ctx), CommandPath: opts.Cmd.CommandPath(), Identity: opts.As, - // Stage 1: CheckResponse emits the legacy *output.ExitError envelope. - // Per-domain migration in stage 2+ will route through - // errclass.BuildAPIError to populate identity-aware fields - // (PermissionError.ConsoleURL needs Brand+AppID from the client). + // CheckResponse routes through errclass.BuildAPIError for known Lark + // codes (typed PermissionError / AuthenticationError / ...). For + // unknown codes it falls back to *errs.APIError. The Brand+AppID on + // the client populate identity-aware fields (ConsoleURL etc.). CheckError: ac.CheckResponse, }) - // MarkRaw: see comment above on the DoAPI path. Applies equally to - // HandleResponse failures so the raw API error survives to the wire. + // MarkRaw: see comment above on the DoAPI path. Skips legacy + // *ExitError enrichment; typed errors flow through unchanged. if err != nil { return output.MarkRaw(err) } diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 8488c84a4..a966f1430 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -4,11 +4,13 @@ package api import ( + "errors" "os" "sort" "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" @@ -670,3 +672,49 @@ func TestApiCmd_DryRunWithFile(t *testing.T) { t.Errorf("expected dry-run header, got: %s", out) } } + +// TestApiCmd_PermissionError_DerivesFirstClassFields pins that when a Lark +// API returns a missing-scope failure, the typed *errs.PermissionError +// surfaced by `lark-cli api` lifts the diagnostic signals BuildAPIError +// consumed during classification into first-class wire fields +// (MissingScopes, LogID, ConsoleURL). The wire shape is the typed envelope +// — there is no raw-payload passthrough; new Lark diagnostic fields require +// a CLI release. +func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "cli_test_perm", AppSecret: "secret", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/docx/v1/documents/test", + Body: map[string]interface{}{ + "code": 99991679, + "msg": "scope missing", + "log_id": "20260527-test-log", + "error": map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": "docx:document"}, + }, + }, + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for non-zero code") + } + + var pe *errs.PermissionError + if !errors.As(err, &pe) { + t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err) + } + + if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "docx:document" { + t.Errorf("MissingScopes = %v, want [docx:document]", pe.MissingScopes) + } + if pe.LogID != "20260527-test-log" { + t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log") + } +} diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 5c5e1c720..288f16de5 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -17,6 +17,7 @@ import ( larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errclass" ) // NewCmdAuth creates the auth command with subcommands. @@ -70,7 +71,7 @@ func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (ope var resp userInfoResponse if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { - return "", "", fmt.Errorf("failed to parse user info: %v", err) + return "", "", fmt.Errorf("failed to parse user info: %w", err) } if resp.Code != 0 { return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg) @@ -110,6 +111,11 @@ type appInfoResponse struct { } `json:"data"` } +// getAppInfoFn is the package-level seam used by callers (scopes.go) so tests +// can substitute a fake without standing up a full SDK + httpmock pipeline. +// Mirrors the pollDeviceToken pattern in login.go. +var getAppInfoFn = getAppInfo + // getAppInfo queries app info from the Lark API. func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) { ac, err := f.NewAPIClient() @@ -131,10 +137,10 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo var resp appInfoResponse if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { - return nil, fmt.Errorf("failed to parse response: %v", err) + return nil, fmt.Errorf("failed to parse response: %w", err) } if resp.Code != 0 { - return nil, fmt.Errorf("API error [%d]: %s", resp.Code, resp.Msg) + return nil, classifyAppInfoErr(apiResp.RawBody, resp.Code, resp.Msg, f, appId) } app := resp.Data.App @@ -153,3 +159,21 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil } + +// classifyAppInfoErr re-decodes the raw body so BuildAPIError sees the +// upstream `error` block — the typed appInfoResponse shape drops it. +func classifyAppInfoErr(rawBody []byte, code int, msg string, f *cmdutil.Factory, appId string) error { + var raw map[string]any + _ = json.Unmarshal(rawBody, &raw) + if raw == nil { + raw = map[string]any{} + } + raw["code"] = code + raw["msg"] = msg + cc := errclass.ClassifyContext{Identity: string(core.AsBot)} + if cfg, _ := f.Config(); cfg != nil { + cc.Brand = string(cfg.Brand) + cc.AppID = appId + } + return errclass.BuildAPIError(raw, cc) +} diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index ba0d48e6b..996c71c68 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -318,6 +319,54 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) } } +// TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError pins that when +// the Lark API returns a permission code (99991679 with permission_violations), +// getAppInfo classifies it as *errs.PermissionError carrying the server- +// supplied MissingScopes — not a bare error wrapped as InternalError. +func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + tokenResolver := &authScopesTokenResolver{} + f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil) + + reg.Register(&httpmock.Stub{ + Method: http.MethodGet, + URL: "/open-apis/application/v6/applications/test-app", + Body: map[string]interface{}{ + "code": 99991679, + "msg": "scope missing", + "error": map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": "application:application:self_manage"}, + }, + }, + }, + }) + + err := authScopesRun(&ScopesOptions{ + Factory: f, + Ctx: context.Background(), + Format: "json", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + + var pe *errs.PermissionError + if !errors.As(err, &pe) { + t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err) + } + if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "application:application:self_manage" { + t.Errorf("MissingScopes = %v, want server-supplied [application:application:self_manage]", pe.MissingScopes) + } + + var intErr *errs.InternalError + if errors.As(err, &intErr) { + t.Error("Lark business error must not be wrapped as InternalError; permission semantics lost") + } +} + type authScopesTokenResolver struct { requests []credential.TokenSpec } @@ -389,15 +438,8 @@ func TestAuthBlockedByExternalProvider(t *testing.T) { if matched != nil && matched != cmd && !matched.SilenceUsage { t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) - } - if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" { - t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider") + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } }) } diff --git a/cmd/auth/check.go b/cmd/auth/check.go index 8f7072aa8..50c47d103 100644 --- a/cmd/auth/check.go +++ b/cmd/auth/check.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/output" @@ -47,7 +48,7 @@ func authCheckRun(opts *CheckOptions) error { required := strings.Fields(opts.Scope) if len(required) == 0 { - return output.ErrValidation("--scope cannot be empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be empty").WithParam("--scope") } config, err := f.Config() diff --git a/cmd/auth/check_test.go b/cmd/auth/check_test.go new file mode 100644 index 000000000..ea69174cb --- /dev/null +++ b/cmd/auth/check_test.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "encoding/json" + "errors" + "testing" + "time" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/zalando/go-keyring" +) + +// `lark-cli auth check` is a predicate command: its README contract is +// `exit 0 = ok, 1 = missing`. The JSON answer goes to stdout; stderr stays +// empty so callers can write `if lark-cli auth check ...; then ... fi` +// without their logs getting polluted by an error envelope on the negative +// branch. These tests pin that contract end-to-end through the dispatcher. + +func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) { + f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + // UserOpenId left empty: triggers the not_logged_in branch. + }) + + err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"}) + + if got := output.ExitCodeOf(err); got != 1 { + t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got) + } + var bare *output.ExitError + if !errors.As(err, &bare) { + t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err) + } + if bare.Detail != nil { + t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail) + } + + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty for predicate negative answer, got:\n%s", stderr.String()) + } + + 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"] != false { + t.Errorf("stdout.ok = %v, want false", payload["ok"]) + } + if payload["error"] != "not_logged_in" { + t.Errorf("stdout.error = %v, want 'not_logged_in'", payload["error"]) + } +} + +func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) { + f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + UserOpenId: "ou_user", UserName: "tester", + }) + + err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"}) + + if got := output.ExitCodeOf(err); got != 1 { + t.Errorf("exit code = %d, want 1", got) + } + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty, got:\n%s", stderr.String()) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout must be valid JSON: %v", err) + } + if payload["ok"] != false { + t.Errorf("stdout.ok = %v, want false", payload["ok"]) + } + if payload["error"] != "no_token" { + t.Errorf("stdout.error = %v, want 'no_token'", payload["error"]) + } +} + +func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) { + // Predicate command happy path: stored token covers every required + // scope. Exit must be 0 (nil error, not ErrBare), stdout carries the + // `{"ok":true,...}` JSON answer, and stderr stays empty so shell + // callers can rely on `if lark-cli auth check ...; then` without log + // pollution. Pairs with the two exit-1 negatives above so both + // branches of the predicate contract are pinned. + keyring.MockInit() + t.Setenv("HOME", t.TempDir()) + t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) + + cfg := &core.CliConfig{ + AppID: "test-app", + AppSecret: "test-secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_user", + UserName: "tester", + } + now := time.Now() + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: cfg.AppID, + UserOpenId: cfg.UserOpenId, + AccessToken: "user-access-token", + RefreshToken: "refresh-token", + ExpiresAt: now.Add(time.Hour).UnixMilli(), + RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(), + GrantedAt: now.Add(-time.Hour).UnixMilli(), + Scope: "im:message docx:document", + }); err != nil { + t.Fatalf("SetStoredToken() error = %v", err) + } + + f, stdout, stderr, _ := cmdutil.TestFactory(t, cfg) + + err := authCheckRun(&CheckOptions{Factory: f, Scope: "im:message"}) + + if err != nil { + t.Fatalf("expected nil error for happy path (exit 0), got %v", err) + } + if got := output.ExitCodeOf(err); got != 0 { + t.Errorf("exit code = %d, want 0", got) + } + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty for predicate exit-0 answer, got:\n%s", stderr.String()) + } + + 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"]) + } + granted, ok := payload["granted"].([]any) + if !ok || len(granted) != 1 || granted[0] != "im:message" { + t.Errorf("stdout.granted = %v, want [im:message]", payload["granted"]) + } + if payload["missing"] != nil { + t.Errorf("stdout.missing = %v, want nil/absent on happy path", payload["missing"]) + } + if _, has := payload["suggestion"]; has { + t.Errorf("stdout.suggestion must be absent on happy path; got %v", payload["suggestion"]) + } +} + +func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) { + // Scope validation is a real input error, not a predicate negative + // answer — it must surface as a typed ValidationError with the normal + // stderr envelope, distinct from the silent ErrBare predicate path. + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + err := authCheckRun(&CheckOptions{Factory: f, Scope: " "}) + if err == nil { + t.Fatal("expected validation error for empty --scope") + } + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Errorf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation) + } +} diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 8a56b1891..41ab63af9 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -13,9 +13,12 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" + larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts" @@ -53,9 +56,9 @@ run --device-code in a later step after the user confirms authorization. Use 'la to generate QR codes (supports ASCII and PNG formats).`, RunE: func(cmd *cobra.Command, args []string) error { if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot { - return output.ErrWithHint(output.ExitValidation, "command_denied", - fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode), - "if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "strict mode is %q, user login is disabled in this profile", mode). + WithHint("if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") } opts.Ctx = cmd.Context() if runF != nil { @@ -121,7 +124,7 @@ func authLoginRun(opts *LoginOptions) error { } // Determine UI language from saved config - lang := "zh" + var lang i18n.Lang if multi, _ := core.LoadMultiAppConfig(); multi != nil { if app := multi.FindApp(config.ProfileName); app != nil { lang = app.Lang @@ -157,14 +160,14 @@ func authLoginRun(opts *LoginOptions) error { for _, d := range selectedDomains { if !knownDomains[d] { if suggestion := suggestDomain(d, knownDomains); suggestion != "" { - return output.ErrValidation("unknown domain %q, did you mean %q?", d, suggestion) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain") } available := make([]string, 0, len(knownDomains)) for k := range knownDomains { available = append(available, k) } sort.Strings(available) - return output.ErrValidation("unknown domain %q, available domains: %s", d, strings.Join(available, ", ")) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain") } } } @@ -172,17 +175,17 @@ func authLoginRun(opts *LoginOptions) error { hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0 if len(opts.Exclude) > 0 && !hasAnyOption { - return output.ErrValidation("--exclude requires --scope, --domain, or --recommend to be specified") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--exclude requires --scope, --domain, or --recommend to be specified").WithParam("--exclude") } if !hasAnyOption { if !opts.JSON && f.IOStreams.IsTerminal { - result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand) + result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand) if err != nil { return err } if result == nil { - return output.ErrValidation("no login options selected") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected") } selectedDomains = result.Domains scopeLevel = result.ScopeLevel @@ -198,7 +201,7 @@ func authLoginRun(opts *LoginOptions) error { log(msg.HintFooter) log("") log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.") - return output.ErrValidation("please specify the scopes to authorize") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope") } } @@ -227,7 +230,7 @@ func authLoginRun(opts *LoginOptions) error { } if len(candidateScopes) == 0 && opts.Scope == "" { - return output.ErrValidation("no matching scopes found, check domain/scope options") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options") } // Merge --scope additively with the resolved domain scopes. @@ -247,13 +250,13 @@ func authLoginRun(opts *LoginOptions) error { if len(opts.Exclude) > 0 { excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude) if len(unknown) > 0 { - return output.ErrValidation( + return errs.NewValidationError(errs.SubtypeInvalidArgument, "these --exclude scopes are not present in the requested set: %s", - strings.Join(unknown, ", ")) + strings.Join(unknown, ", ")).WithParam("--exclude") } finalScope = excluded if strings.TrimSpace(finalScope) == "" { - return output.ErrValidation("no scopes left after applying --exclude; nothing to authorize") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "no scopes left after applying --exclude; nothing to authorize").WithParam("--exclude") } } @@ -264,7 +267,7 @@ func authLoginRun(opts *LoginOptions) error { } authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut) if err != nil { - return output.ErrAuth("device authorization failed: %v", err) + return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err) } // --no-wait: return immediately with device code and URL @@ -276,12 +279,18 @@ func authLoginRun(opts *LoginOptions) error { "verification_url": authResp.VerificationUriComplete, "device_code": authResp.DeviceCode, "expires_in": authResp.ExpiresIn, - "hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode), + "hint": "**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it." + + "**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it." + + "**Display order:** Output the URL first, then place the QR code image below the URL." + + "**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation." + + "For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. **Before ending the turn, tell the user to come back and notify you after completing authorization.**" + + "**After the user confirms authorization:** YOU must execute `lark-cli auth login --device-code ` yourself." + + "**Do NOT cache verification_url or device_code for future use.** Always run `lark-cli auth login --no-wait --json` fresh when authorization is needed.", } encoder := json.NewEncoder(f.IOStreams.Out) encoder.SetEscapeHTML(false) if err := encoder.Encode(data); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err) } return nil } @@ -303,7 +312,7 @@ func authLoginRun(opts *LoginOptions) error { encoder := json.NewEncoder(f.IOStreams.Out) encoder.SetEscapeHTML(false) if err := encoder.Encode(data); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err) } } else { fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL) @@ -324,25 +333,25 @@ func authLoginRun(opts *LoginOptions) error { "event": "authorization_failed", "error": result.Message, }); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err) } return output.ErrBare(output.ExitAuth) } - return output.ErrAuth("authorization failed: %s", result.Message) + return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message) } if result.Token == nil { - return output.ErrAuth("authorization succeeded but no token returned") + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned") } // Step 6: Get user info log(msg.AuthSuccess) sdk, err := f.LarkClient() if err != nil { - return output.ErrAuth("failed to get SDK: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err) } openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken) if err != nil { - return output.ErrAuth("failed to get user info: %v", err) + return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err) } scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope) @@ -360,13 +369,13 @@ func authLoginRun(opts *LoginOptions) error { GrantedAt: now, } if err := larkauth.SetStoredToken(storedToken); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save token: %v", err).WithCause(err) } // Step 8: Update config — overwrite Users to single user, clean old tokens if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil { _ = larkauth.RemoveStoredToken(config.AppID, openId) - return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err) + return err } if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil { @@ -409,22 +418,22 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo if shouldRemoveLoginRequestedScope(result) { cleanupRequestedScope() } - return output.ErrAuth("authorization failed: %s", result.Message) + return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message) } defer cleanupRequestedScope() if result.Token == nil { - return output.ErrAuth("authorization succeeded but no token returned") + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned") } // Get user info log(msg.AuthSuccess) sdk, err := f.LarkClient() if err != nil { - return output.ErrAuth("failed to get SDK: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err) } openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken) if err != nil { - return output.ErrAuth("failed to get user info: %v", err) + return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err) } scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope) @@ -442,13 +451,13 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo GrantedAt: now, } if err := larkauth.SetStoredToken(storedToken); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to save token: %v", err).WithCause(err) } // Update config — overwrite Users to single user, clean old tokens if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil { _ = larkauth.RemoveStoredToken(config.AppID, openId) - return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to update login profile: %v", err).WithCause(err) } if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil { @@ -463,18 +472,18 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo func syncLoginUserToProfile(profileName, appID, openID, userName string) error { multi, err := core.LoadMultiAppConfig() if err != nil { - return fmt.Errorf("load config: %w", err) + return errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err) } app := findProfileByName(multi, profileName) if app == nil { - return fmt.Errorf("profile %q not found in config", profileName) + return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName) } oldUsers := append([]core.AppUser(nil), app.Users...) app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}} if err := core.SaveMultiAppConfig(multi); err != nil { - return fmt.Errorf("save config: %w", err) + return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err) } for _, oldUser := range oldUsers { diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go index 32b81c845..cd9754862 100644 --- a/cmd/auth/login_interactive.go +++ b/cmd/auth/login_interactive.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/huh" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" @@ -163,7 +164,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra } if len(selectedDomains) == 0 { - return nil, output.ErrValidation("no domains selected") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no domains selected").WithParam("--domain") } // Compute scope summary diff --git a/cmd/auth/login_messages.go b/cmd/auth/login_messages.go index 56ef8de39..2d8ff1eb0 100644 --- a/cmd/auth/login_messages.go +++ b/cmd/auth/login_messages.go @@ -3,6 +3,8 @@ package auth +import "github.com/larksuite/cli/internal/i18n" + type loginMsg struct { // Interactive UI (login_interactive.go) SelectDomains string @@ -115,8 +117,8 @@ var loginMsgEn = &loginMsg{ } // getLoginMsg returns the login message bundle for the given language. -func getLoginMsg(lang string) *loginMsg { - if lang == "en" { +func getLoginMsg(lang i18n.Lang) *loginMsg { + if lang.IsEnglish() { return loginMsgEn } return loginMsgZh diff --git a/cmd/auth/login_messages_test.go b/cmd/auth/login_messages_test.go index 3c5cc1c88..a5c7f936c 100644 --- a/cmd/auth/login_messages_test.go +++ b/cmd/auth/login_messages_test.go @@ -8,6 +8,8 @@ import ( "reflect" "strings" "testing" + + "github.com/larksuite/cli/internal/i18n" ) func TestGetLoginMsg_Zh(t *testing.T) { @@ -31,7 +33,7 @@ func TestGetLoginMsg_En(t *testing.T) { } func TestGetLoginMsg_DefaultsToZh(t *testing.T) { - for _, lang := range []string{"", "fr", "ja", "unknown"} { + for _, lang := range []i18n.Lang{"", "fr_fr", "ja_jp", "unknown"} { msg := getLoginMsg(lang) if msg != loginMsgZh { t.Errorf("getLoginMsg(%q) should default to zh", lang) @@ -61,7 +63,7 @@ func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string) } func TestLoginMsg_FormatStrings(t *testing.T) { - for _, lang := range []string{"zh", "en"} { + for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} { msg := getLoginMsg(lang) // LoginSuccess should contain two %s placeholders (userName, openId) @@ -102,10 +104,10 @@ func TestLoginMsg_FormatStrings(t *testing.T) { // --device-code split-flow, and (c) non-streaming harnesses must end the turn // after presenting the URL instead of blocking in the same turn. func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) { - for _, lang := range []string{"zh", "en"} { + for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} { hint := getLoginMsg(lang).AgentTimeoutHint for _, want := range []string{"--no-wait", "--device-code", "turn"} { - if lang == "zh" && want == "turn" { + if lang == i18n.LangZhCN && want == "turn" { want = "本轮" } if !strings.Contains(hint, want) { diff --git a/cmd/auth/login_result.go b/cmd/auth/login_result.go index db609d6c0..84eccc97d 100644 --- a/cmd/auth/login_result.go +++ b/cmd/auth/login_result.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" + "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/output" @@ -171,25 +172,12 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory fmt.Fprintln(f.IOStreams.Out, string(b)) return output.ErrBare(output.ExitAuth) } - detail := map[string]interface{}{ - "requested": issue.Summary.Requested, - "granted": issue.Summary.Granted, - "missing": issue.Summary.Missing, - } - // Legacy *output.ExitError producer: this literal predates the typed - // error contract introduced by errs/. New code MUST NOT construct - // *output.ExitError directly — missing-scope signals should move to - // *errs.PermissionError (with MissingScopes/ConsoleURL as typed - // extension fields) when the login flow migrates to typed errors. - return &output.ExitError{ - Code: output.ExitAuth, - Detail: &output.ErrDetail{ - Type: "missing_scope", - Message: issue.Message, - Hint: issue.Hint, - Detail: detail, - }, - } + return errs.NewPermissionError(errs.SubtypeMissingScope, "%s", issue.Message). + WithHint("%s", issue.Hint). + WithIdentity("user"). + WithRequestedScopes(issue.Summary.Requested...). + WithGrantedScopes(issue.Summary.Granted...). + WithMissingScopes(issue.Summary.Missing...) } fmt.Fprintln(f.IOStreams.ErrOut) diff --git a/cmd/auth/login_result_test.go b/cmd/auth/login_result_test.go new file mode 100644 index 000000000..5f14d40dc --- /dev/null +++ b/cmd/auth/login_result_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "reflect" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" +) + +// TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple asserts that the +// failed-login JSON branch (loginSucceeded == false, opts.JSON == true) wires +// requested + granted + missing scopes into the typed *PermissionError +// envelope. Consumers need the full triple to render actionable diagnostics, +// not just the missing set. +func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + + requested := []string{"docx:document", "im:message:send"} + granted := []string{"docx:document"} + missing := []string{"im:message:send"} + + err := handleLoginScopeIssue( + &LoginOptions{JSON: true}, + getLoginMsg("en"), + f, + &loginScopeIssue{ + Message: "scope insufficient", + Hint: "re-login with --scope im:message:send", + Summary: &loginScopeSummary{ + Requested: requested, + Granted: granted, + Missing: missing, + }, + }, + "", // openId empty -> loginSucceeded = false + "tester", + ) + + if err == nil { + t.Fatal("expected error, got nil") + } + var permErr *errs.PermissionError + if !errors.As(err, &permErr) { + t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err) + } + if !reflect.DeepEqual(permErr.RequestedScopes, requested) { + t.Errorf("RequestedScopes = %v, want %v", permErr.RequestedScopes, requested) + } + if !reflect.DeepEqual(permErr.GrantedScopes, granted) { + t.Errorf("GrantedScopes = %v, want %v", permErr.GrantedScopes, granted) + } + if !reflect.DeepEqual(permErr.MissingScopes, missing) { + t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, missing) + } +} diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index e7be036e2..ef943eabc 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -400,12 +400,11 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) { Granted: []string{"base:app:copy"}, }, }, "ou_user", "tester") - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected ExitError, got %v", err) + if err == nil { + t.Fatal("expected error, got nil") } - if exitErr.Code != output.ExitAuth { - t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { + t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth) } got := stderr.String() for _, want := range []string{ @@ -443,12 +442,11 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) { Granted: []string{"base:app:copy"}, }, }, "ou_user", "tester") - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected ExitError, got %v", err) + if err == nil { + t.Fatal("expected error, got nil") } - if exitErr.Code != output.ExitAuth { - t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { + t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth) } var data map[string]interface{} @@ -653,12 +651,11 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) Ctx: context.Background(), Scope: "im:message:send", }) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected ExitError, got %v", err) + if err == nil { + t.Fatal("expected error, got nil") } - if exitErr.Code != output.ExitAuth { - t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { + t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth) } got := stderr.String() for _, want := range []string{ @@ -870,6 +867,90 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) { } } +// TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty pins the +// contract that when --json is set and pollDeviceToken returns OK=false, +// stdout carries the structured authorization_failed event and stderr is +// NOT polluted with a typed envelope. The returned error is a bare +// ExitError with ExitAuth so the dispatcher only propagates the exit code +// without emitting a second envelope on top of the JSON event. +func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + + original := pollDeviceToken + t.Cleanup(func() { pollDeviceToken = original }) + pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult { + return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"} + } + + f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + AppSecret: "secret", + Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: larkauth.PathDeviceAuthorization, + Body: map[string]interface{}{ + "device_code": "device-code", + "user_code": "user-code", + "verification_uri": "https://example.com/verify", + "verification_uri_complete": "https://example.com/verify?code=123", + "expires_in": 240, + "interval": 0, + }, + }) + + err := authLoginRun(&LoginOptions{ + Factory: f, + Ctx: context.Background(), + Scope: "im:message:send", + JSON: true, + }) + if err == nil { + t.Fatal("expected error for aborted authorization") + } + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { + t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth) + } + + // stdout: device_authorization event + authorization_failed event, + // the latter carrying the abort message as a structured field. + stdoutStr := stdout.String() + if !strings.Contains(stdoutStr, `"event":"authorization_failed"`) { + t.Errorf("stdout missing authorization_failed event, got: %s", stdoutStr) + } + if !strings.Contains(stdoutStr, "user denied") { + t.Errorf("stdout missing abort message, got: %s", stdoutStr) + } + + // stderr must NOT carry a typed envelope: ErrBare propagates the exit + // code only, so the dispatcher emits nothing on stderr. The waiting-auth + // log line goes through the JSON-mode no-op `log` helper so it is also + // suppressed in JSON mode. + stderrStr := stderr.String() + if strings.Contains(stderrStr, `"type":"authentication"`) { + t.Errorf("stderr should not contain typed envelope, got: %s", stderrStr) + } + if strings.Contains(stderrStr, `"error"`) { + t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr) + } + + // Returned error must be the bare *output.ExitError signal (no envelope). + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitAuth { + t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth) + } + if exitErr.Detail != nil { + t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail) + } +} + func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) { f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ ProfileName: "default", @@ -961,8 +1042,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) { "final message of the turn", "return control to the user", "do not block on --device-code in the same turn", - "After the user confirms authorization in a later step", - "lark-cli auth login --device-code device-code", + "come back and notify", + "YOU must execute", + "lark-cli auth login --device-code ", + "Do NOT cache", + "lark-cli auth login --no-wait --json", } { if !strings.Contains(hint, want) { t.Fatalf("hint missing %q, got:\n%s", want, hint) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 3b2ae09f2..1e864fd7d 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -60,7 +61,7 @@ func authLogoutRun(opts *LogoutOptions) error { } app.Users = []core.AppUser{} if err := core.SaveMultiAppConfig(multi); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } output.PrintSuccess(f.IOStreams.ErrOut, "Logged out") return nil diff --git a/cmd/auth/qrcode.go b/cmd/auth/qrcode.go index b74672582..bc77d4f64 100644 --- a/cmd/auth/qrcode.go +++ b/cmd/auth/qrcode.go @@ -13,8 +13,8 @@ import ( "github.com/skip2/go-qrcode" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" ) @@ -63,7 +63,7 @@ For ASCII output, the result is printed to stdout with fixed size.`, // runQRCode executes the auth qrcode command. func runQRCode(opts *QRCodeOptions) error { if opts.URL == "" { - return output.Errorf(output.ExitValidation, "missing_url", "url is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "url is required").WithParam("--url") } if opts.ASCII { @@ -75,20 +75,20 @@ func runQRCode(opts *QRCodeOptions) error { } if opts.Output == "" { - return output.Errorf(output.ExitValidation, "missing_output", "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.").WithParam("--output") } if opts.Size < 32 { - return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at least 32, got %d", opts.Size)) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at least 32, got %d", opts.Size).WithParam("--size") } if opts.Size > 1024 { - return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at most 1024, got %d", opts.Size)) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at most 1024, got %d", opts.Size).WithParam("--size") } safePath, err := validate.SafeOutputPath(opts.Output) if err != nil { - return output.ErrValidation("unsafe output path: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err) } if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil { @@ -108,7 +108,7 @@ func runQRCode(opts *QRCodeOptions) error { encoder := json.NewEncoder(out) encoder.SetEscapeHTML(false) if err := encoder.Encode(result); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to write output: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to write output: %v", err).WithCause(err) } return nil @@ -118,12 +118,12 @@ func runQRCode(opts *QRCodeOptions) error { func generateImageQRCode(url string, size int, outputPath string) error { png, err := qrcode.Encode(url, qrcode.Medium, size) if err != nil { - return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to encode QR code: %v", err)) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to encode QR code: %v", err).WithCause(err) } err = vfs.WriteFile(outputPath, png, 0644) if err != nil { - return output.Errorf(output.ExitInternal, "write_error", fmt.Sprintf("failed to write QR code to %s: %v", outputPath, err)) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to write QR code to %s: %v", outputPath, err).WithCause(err) } return nil @@ -133,7 +133,7 @@ func generateImageQRCode(url string, size int, outputPath string) error { func generateASCIIQRCode(url string, w io.Writer) error { q, err := qrcode.New(url, qrcode.Medium) if err != nil { - return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to create QR code: %v", err)) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to create QR code: %v", err).WithCause(err) } fmt.Fprint(w, q.ToSmallString(false)) diff --git a/cmd/auth/qrcode_test.go b/cmd/auth/qrcode_test.go index e6969e0f0..a171026c8 100644 --- a/cmd/auth/qrcode_test.go +++ b/cmd/auth/qrcode_test.go @@ -5,7 +5,6 @@ package auth import ( "encoding/json" - "errors" "io" "os" "path/filepath" @@ -171,29 +170,15 @@ func TestNewCmdAuthQRCode_HelpText(t *testing.T) { func TestRunQRCode_MissingURL(t *testing.T) { err := runQRCode(&QRCodeOptions{URL: ""}) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) - } - if exitErr.Detail.Type != "missing_url" { - t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_url") + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } } func TestRunQRCode_MissingOutput(t *testing.T) { err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256}) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) - } - if exitErr.Detail.Type != "missing_output" { - t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_output") + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } } @@ -203,15 +188,8 @@ func TestRunQRCode_InvalidSize(t *testing.T) { Size: 16, Output: "qr.png", }) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) - } - if exitErr.Detail.Type != "invalid_size" { - t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size") + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } } @@ -221,15 +199,8 @@ func TestRunQRCode_SizeTooLarge(t *testing.T) { Size: 2048, Output: "qr.png", }) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) - } - if exitErr.Detail.Type != "invalid_size" { - t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size") + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } } @@ -239,12 +210,8 @@ func TestRunQRCode_UnsafeOutputPath(t *testing.T) { Size: 256, Output: "/etc/passwd", }) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } } @@ -329,15 +296,8 @@ func TestGenerateImageQRCode_WriteError(t *testing.T) { if err == nil { t.Fatal("expected error writing to nonexistent directory") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitInternal { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitInternal) - } - if exitErr.Detail.Type != "write_error" { - t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "write_error") + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitInternal { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitInternal) } } @@ -358,11 +318,7 @@ func TestGenerateASCIIQRCode_EmptyString(t *testing.T) { if err == nil { t.Fatal("expected error for empty string") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Detail.Type != "encode_error" { - t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "encode_error") + if err == nil { + t.Fatal("expected error, got nil") } } diff --git a/cmd/auth/scopes.go b/cmd/auth/scopes.go index c70898dd5..4de475824 100644 --- a/cmd/auth/scopes.go +++ b/cmd/auth/scopes.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/output" ) @@ -50,11 +51,23 @@ func authScopesRun(opts *ScopesOptions) error { return err } fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n") - appInfo, err := getAppInfo(opts.Ctx, f, config.AppID) + appInfo, err := getAppInfoFn(opts.Ctx, f, config.AppID) if err != nil { - return output.ErrWithHint(output.ExitAPI, "permission", - fmt.Sprintf("failed to get app scope info: %v", err), - "ensure the app has enabled the application:application:self_manage scope.") + // Discriminate by error type so transport / parse failures are not + // reclassified as PermissionError(MissingScope) — re-auth does not + // fix network / 5xx / JSON parse errors and misclassifying them + // here would mislead agents into re-auth loops. + // - typed errors pass through unchanged + // - bare errors become InternalError(SubtypeSDKError) with Cause + // preserved so callers (errors.Is) can still see the underlying + // transport/parse failure. + // Genuine permission failures are surfaced from appInfo *content*, + // not from this transport-level error path. + if errs.IsTyped(err) { + return err + } + return errs.NewInternalError(errs.SubtypeSDKError, + "failed to get app scope info: %v", err).WithCause(err) } if opts.Format == "pretty" { fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID) diff --git a/cmd/auth/scopes_test.go b/cmd/auth/scopes_test.go new file mode 100644 index 000000000..9ab748d6b --- /dev/null +++ b/cmd/auth/scopes_test.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// stubGetAppInfoErr swaps getAppInfoFn for the duration of t so authScopesRun +// observes a fixed error from the dependency. t.Cleanup restores the prior +// value so tests cannot leak through the package-level seam. +func stubGetAppInfoErr(t *testing.T, errToReturn error) { + t.Helper() + prev := getAppInfoFn + getAppInfoFn = func(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) { + return nil, errToReturn + } + t.Cleanup(func() { getAppInfoFn = prev }) +} + +// scopesTestFactory builds a Factory + ScopesOptions pair sufficient to drive +// authScopesRun. Config has a non-empty AppID so we get past the config gate +// and reach the getAppInfoFn call. +func scopesTestFactory(t *testing.T) *ScopesOptions { + t.Helper() + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", + AppSecret: "test-secret", + Brand: core.BrandFeishu, + }) + return &ScopesOptions{ + Factory: f, + Ctx: context.Background(), + Format: "json", + } +} + +// TestAuthScopesRun_NetworkErrorPassedThrough pins that a typed NetworkError +// surfaced by the dependency is not re-classified as PermissionError — +// re-auth does not fix DNS / transport failures and blanket-wrapping them +// would mislead agents into infinite re-auth loops. +func TestAuthScopesRun_NetworkErrorPassedThrough(t *testing.T) { + netErr := errs.NewNetworkError(errs.SubtypeNetworkDNS, "DNS lookup failed") + stubGetAppInfoErr(t, netErr) + + err := authScopesRun(scopesTestFactory(t)) + if err == nil { + t.Fatal("expected error, got nil") + } + + var permErr *errs.PermissionError + if errors.As(err, &permErr) { + t.Errorf("network failure must not be classified as PermissionError; got %v", permErr) + } + var gotNet *errs.NetworkError + if !errors.As(err, &gotNet) { + t.Fatalf("network failure not preserved through authScopesRun; got %T: %v", err, err) + } + if gotNet != netErr { + t.Errorf("typed network error should pass through identity-stable; got %p, want %p", gotNet, netErr) + } +} + +// TestAuthScopesRun_PermissionErrorPassedThrough pins that typed permission +// failures from the dependency also pass through — IsTyped() must not single +// out one category. +func TestAuthScopesRun_PermissionErrorPassedThrough(t *testing.T) { + permErr := errs.NewPermissionError(errs.SubtypeMissingScope, "scope X missing"). + WithMissingScopes("im:message") + stubGetAppInfoErr(t, permErr) + + err := authScopesRun(scopesTestFactory(t)) + if err == nil { + t.Fatal("expected error, got nil") + } + var got *errs.PermissionError + if !errors.As(err, &got) { + t.Fatalf("expected *PermissionError pass-through, got %T: %v", err, err) + } + if got != permErr { + t.Errorf("typed permission error should pass through identity-stable; got %p, want %p", got, permErr) + } +} + +// TestAuthScopesRun_BareErrorWrappedAsInternal pins the unclassified branch: +// a bare error (e.g. json.Unmarshal failure inside getAppInfo) surfaces as +// *InternalError{SubtypeSDKError} with the original error preserved on +// Cause so errors.Is still walks to it. +func TestAuthScopesRun_BareErrorWrappedAsInternal(t *testing.T) { + bareErr := fmt.Errorf("failed to parse response: unexpected EOF") + stubGetAppInfoErr(t, bareErr) + + err := authScopesRun(scopesTestFactory(t)) + if err == nil { + t.Fatal("expected error, got nil") + } + + var permErr *errs.PermissionError + if errors.As(err, &permErr) { + t.Errorf("bare getAppInfo error must not be classified as PermissionError; got %v", permErr) + } + + var intErr *errs.InternalError + if !errors.As(err, &intErr) { + t.Fatalf("expected *InternalError, got %T: %v", err, err) + } + if intErr.Subtype != errs.SubtypeSDKError { + t.Errorf("InternalError.Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError) + } + if !errors.Is(err, bareErr) { + t.Error("InternalError must carry bareErr via WithCause so errors.Is walks to it") + } +} diff --git a/cmd/auth/status.go b/cmd/auth/status.go index 20a8d4790..f0cf85e4d 100644 --- a/cmd/auth/status.go +++ b/cmd/auth/status.go @@ -61,7 +61,6 @@ func authStatusRun(opts *StatusOptions) error { diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify) result["identities"] = diagnostics result["identity"] = effectiveIdentity(diagnostics) - addLegacyUserFields(result, diagnostics.User) addEffectiveVerification(result, diagnostics) addStatusNote(result, diagnostics) @@ -86,29 +85,6 @@ func effectiveIdentity(d identitydiag.Result) string { } } -func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) { - if user.OpenID == "" { - return - } - result["userName"] = user.UserName - result["userOpenId"] = user.OpenID - if user.TokenStatus != "" { - result["tokenStatus"] = user.TokenStatus - } - if user.Scope != "" { - result["scope"] = user.Scope - } - if user.ExpiresAt != "" { - result["expiresAt"] = user.ExpiresAt - } - if user.RefreshExpiresAt != "" { - result["refreshExpiresAt"] = user.RefreshExpiresAt - } - if user.GrantedAt != "" { - result["grantedAt"] = user.GrantedAt - } -} - func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) { switch result["identity"] { case identityUser: diff --git a/cmd/config/bind.go b/cmd/config/bind.go index 383861ac7..2ec7843f2 100644 --- a/cmd/config/bind.go +++ b/cmd/config/bind.go @@ -12,8 +12,10 @@ import ( "github.com/charmbracelet/huh" "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/i18n" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" @@ -37,8 +39,10 @@ type BindOptions struct { // this flag because its own prompts already require human confirmation. Force bool - Lang string - langExplicit bool // true when --lang was explicitly passed + Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateBindFlags + langExplicit bool // true when --lang was explicitly passed + + UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang // Brand holds the resolved Lark product brand ("feishu" | "lark") for // the account being bound. Populated after resolveAccount; TUI stages @@ -55,7 +59,7 @@ type BindOptions struct { // NewCmdConfigBind creates the config bind subcommand. func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command { - opts := &BindOptions{Factory: f} + opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN} cmd := &cobra.Command{ Use: "bind", @@ -102,7 +106,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`, cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)") cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)") cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)") - cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)") + cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)") cmdutil.SetRisk(cmd, "write") return cmd @@ -147,7 +151,7 @@ func configBindRun(opts *BindOptions) error { if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil { return err } - applyPreferences(appConfig, opts) + applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes)) noticeUserDefaultRisk(opts) return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath) @@ -178,7 +182,7 @@ type existingBinding struct { func finalizeSource(opts *BindOptions) (string, error) { explicit := strings.TrimSpace(strings.ToLower(opts.Source)) if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" { - return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit).WithParam("--source") } var detected string @@ -195,23 +199,26 @@ func finalizeSource(opts *BindOptions) (string, error) { // before any interactive prompts — running inside Hermes with // --source openclaw (or vice versa) is almost always a mistake. if explicit != "" && detected != "" && explicit != detected { - return "", output.ErrWithHint(output.ExitValidation, "bind", - fmt.Sprintf("--source %q does not match detected Agent environment (%s)", explicit, detected), - "remove --source to auto-detect, or run this command in the correct Agent context") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "--source %q does not match detected Agent environment (%s)", explicit, detected). + WithHint("remove --source to auto-detect, or run this command in the correct Agent context"). + WithParam("--source") } // TUI: prompt for language before any downstream prompts. The source // selection itself may still be skipped entirely if --source or the - // env already pinned it. + // env already pinned it. Picker offers 2 options (中文 / English) and + // drives BOTH opts.Lang (preference) and opts.UILang (TUI rendering). if opts.IsTUI && !opts.langExplicit { - lang, err := promptLangSelection("") + lang, err := promptLangSelection() if err != nil { if err == huh.ErrUserAborted { return "", output.ErrBare(1) } - return "", err + return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err) } - opts.Lang = lang + opts.Lang = string(lang) + opts.UILang = lang } if explicit != "" { @@ -223,9 +230,10 @@ func finalizeSource(opts *BindOptions) (string, error) { if opts.IsTUI { return tuiSelectSource(opts) } - return "", output.ErrWithHint(output.ExitValidation, "bind", - "cannot determine Agent source: no --source flag and no Agent environment detected", - "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "cannot determine Agent source: no --source flag and no Agent environment detected"). + WithHint("pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context"). + WithParam("--source") } // reconcileExistingBinding reads any existing config at configPath and decides @@ -245,7 +253,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi return existingBinding{}, err } if action == "cancel" { - msg := getBindMsg(opts.Lang) + msg := getBindMsg(opts.UILang) fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled) return existingBinding{Cancelled: true}, nil } @@ -329,9 +337,10 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error if !hasStrictBotLock(previousConfigBytes) { return nil } - msg := getBindMsg(opts.Lang) - return output.ErrWithHint(output.ExitValidation, "bind", - msg.IdentityEscalationMessage, msg.IdentityEscalationHint) + msg := getBindMsg(opts.UILang) + return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, + "config bind --force", "%s", msg.IdentityEscalationMessage). + WithHint("%s", msg.IdentityEscalationHint) } // noticeUserDefaultRisk surfaces the user-identity impersonation risk on every @@ -347,14 +356,23 @@ func noticeUserDefaultRisk(opts *BindOptions) { if opts.IsTUI || opts.Identity != "user-default" { return } - msg := getBindMsg(opts.Lang) + msg := getBindMsg(opts.UILang) fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage) } // applyPreferences expands the chosen identity preset into the underlying // StrictMode + DefaultAs on the AppConfig. Always writes both fields so the // profile's intent survives later changes to global strict-mode settings. -func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) { +// preferredLang resolves the language to persist: the requested value when set, +// otherwise the prior one — so an unset --lang never clears a stored preference. +func preferredLang(requested, prior i18n.Lang) i18n.Lang { + if requested != "" { + return requested + } + return prior +} + +func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) { switch opts.Identity { case "bot-only": sm := core.StrictModeBot @@ -365,9 +383,23 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) { appConfig.StrictMode = &sm appConfig.DefaultAs = core.AsUser } - if opts.Lang != "" { - appConfig.Lang = opts.Lang + appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior) +} + +// priorLang returns the language preference recorded in a previous config, or +// "" if there is none / the bytes don't parse. Reads from CurrentApp (or Apps[0] +// fallback) — scanning all apps for the first non-empty Lang would leak the +// wrong profile's preference into a re-bind when the workspace holds multiple +// named profiles and the active one disagrees with Apps[0]. +func priorLang(previousConfigBytes []byte) i18n.Lang { + var multi core.MultiAppConfig + if json.Unmarshal(previousConfigBytes, &multi) != nil { + return "" } + if app := multi.CurrentAppConfig(""); app != nil { + return app.Lang + } + return "" } // commitBinding finalizes the bind: atomic write of the new workspace config, @@ -379,21 +411,21 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}} if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil { - return output.Errorf(output.ExitInternal, "bind", - "failed to create workspace directory: %v", err) + return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err) } data, err := json.MarshalIndent(multi, "", " ") if err != nil { - return output.Errorf(output.ExitInternal, "bind", - "failed to marshal config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err) } if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil { - return output.Errorf(output.ExitInternal, "bind", - "failed to write config %s: %v", configPath, err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err) } replaced := previousConfigBytes != nil - msg := getBindMsg(opts.Lang) + // uiMsg renders human-facing TUI text (stderr success banner). Follows + // opts.UILang — zh by default; picker can flip it to en. --lang does + // not influence the TUI language. + uiMsg := getBindMsg(opts.UILang) display := sourceDisplayName(source) if replaced { @@ -401,7 +433,11 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB } fmt.Fprintln(opts.Factory.IOStreams.ErrOut, - fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice) + fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice) + + if opts.langExplicit && opts.Lang != "" { + fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(uiMsg.LangPreferenceSet, opts.Lang)) + } // TUI mode is a human sitting at a terminal; the BindSuccess notice on // stderr is enough and a machine-readable JSON dump on stdout is just @@ -419,12 +455,17 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB "replaced": replaced, "identity": opts.Identity, } - brand := brandDisplay(string(appConfig.Brand), opts.Lang) + // JSON "message" follows the effective preference on disk (appConfig.Lang), + // not the raw --lang value: when --lang is omitted on re-bind, preferredLang + // has already inherited the prior preference into appConfig.Lang, and the + // message should respect that inherited choice. stderr above follows UILang. + prefMsg := getBindMsg(appConfig.Lang) + brand := brandDisplay(string(appConfig.Brand), appConfig.Lang) switch opts.Identity { case "bot-only": - envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand) + envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand) case "user-default": - envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display) + envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display) } resultJSON, _ := json.Marshal(envelope) @@ -461,7 +502,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core // tuiSelectSource prompts user to choose bind source. func tuiSelectSource(opts *BindOptions) (string, error) { - msg := getBindMsg(opts.Lang) + msg := getBindMsg(opts.UILang) var source string // Pre-select based on detected env signals @@ -486,7 +527,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) { huh.NewGroup( huh.NewSelect[string](). Title(msg.SelectSource). - Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))). + Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.UILang))). Options( huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"), huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"), @@ -508,7 +549,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) { // tuiSelectApp prompts the user to choose from multiple account candidates. // Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode. func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) { - msg := getBindMsg(opts.Lang) + msg := getBindMsg(opts.UILang) options := make([]huh.Option[int], 0, len(candidates)) for i, c := range candidates { label := c.AppID @@ -522,7 +563,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca form := huh.NewForm( huh.NewGroup( huh.NewSelect[int](). - Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))). + Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.UILang))). Options(options...). Value(&selected), ), @@ -539,7 +580,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca // tuiConflictPrompt shows existing binding and asks user to Force or Cancel. func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) { - msg := getBindMsg(opts.Lang) + msg := getBindMsg(opts.UILang) // Build existing binding summary existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath) @@ -588,9 +629,14 @@ func validateBindFlags(opts *BindOptions) error { switch opts.Identity { case "bot-only", "user-default": default: - return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity") } } + lang, err := cmdutil.ParseLangFlag(opts.Lang) + if err != nil { + return err + } + opts.Lang = string(lang) return nil } @@ -606,8 +652,8 @@ func validateBindFlags(opts *BindOptions) error { // DescriptionFunc approach breaks here because a longer description on // hover pushes options out of the field's initial viewport. func tuiSelectIdentity(opts *BindOptions) (string, error) { - msg := getBindMsg(opts.Lang) - brand := brandDisplay(opts.Brand, opts.Lang) + msg := getBindMsg(opts.UILang) + brand := brandDisplay(opts.Brand, opts.UILang) botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand)) userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand)) var value string diff --git a/cmd/config/bind_messages.go b/cmd/config/bind_messages.go index 375f38923..99a50536e 100644 --- a/cmd/config/bind_messages.go +++ b/cmd/config/bind_messages.go @@ -3,6 +3,8 @@ package config +import "github.com/larksuite/cli/internal/i18n" + // bindMsg holds all TUI text for config bind, supporting zh/en via --lang. // // Brand-aware strings use a %s slot where the UI-friendly product name @@ -84,6 +86,11 @@ type bindMsg struct { // require in-flow human confirmation. IdentityEscalationMessage string IdentityEscalationHint string + + // LangPreferenceSet is printed to stderr after a successful bind when the + // user explicitly passed --lang. Format: language code. Not printed when + // --lang was not explicit (i.e., the cobra default zh stayed in effect). + LangPreferenceSet string } var bindMsgZh = &bindMsg{ @@ -116,6 +123,8 @@ var bindMsgZh = &bindMsg{ IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。", IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`", + + LangPreferenceSet: "语言偏好已设置:%s", } var bindMsgEn = &bindMsg{ @@ -150,10 +159,13 @@ var bindMsgEn = &bindMsg{ IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.", IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`", + + LangPreferenceSet: "Language preference set to: %s", } -func getBindMsg(lang string) *bindMsg { - if lang == "en" { +// getBindMsg picks the zh/en TUI bundle; non-English falls back to zh. +func getBindMsg(lang i18n.Lang) *bindMsg { + if lang.IsEnglish() { return bindMsgEn } return bindMsgZh @@ -164,11 +176,11 @@ func getBindMsg(lang string) *bindMsg { // "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en — // this is the safe default when the brand hasn't been resolved yet (for // example, on the pre-binding source-selection screen). -func brandDisplay(brand, lang string) string { +func brandDisplay(brand string, lang i18n.Lang) string { if brand == "lark" || brand == "Lark" || brand == "LARK" { return "Lark" } - if lang == "en" { + if lang.IsEnglish() { return "Feishu" } return "飞书" diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index 51dc9db9d..0711e1057 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -16,12 +16,15 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" ) // assertExitError checks the full structured error in one assertion. It // accepts both *output.ExitError (used by output.ErrWithHint) and the -// typed validation error — they normalize to the same wantDetail fields. +// typed errors (ValidationError, ConfigError) — they normalize to the same +// wantDetail fields. The wantDetail.Type is matched against the typed error's +// Category string ("validation", "config", etc.). func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) { t.Helper() if err == nil { @@ -51,7 +54,18 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.Er } return } - t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError; error = %v", err, err) + var ce *errs.ConfigError + if errors.As(err, &ce) { + if got := output.ExitCodeOf(err); got != wantCode { + t.Errorf("exit code = %d, want %d", got, wantCode) + } + gotDetail := output.ErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint} + if !reflect.DeepEqual(gotDetail, wantDetail) { + t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail) + } + return + } + t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err) } // assertEnvelope decodes stdout and checks it matches want exactly — every key @@ -120,14 +134,229 @@ func TestConfigBindCmd_LangDefault(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) } - if gotOpts.Lang != "zh" { - t.Errorf("Lang = %q, want default %q", gotOpts.Lang, "zh") + if gotOpts.Lang != "" { + t.Errorf("Lang = %q, want default %q (unset)", gotOpts.Lang, "") } if gotOpts.langExplicit { t.Error("expected langExplicit=false when --lang not passed") } } +// TestConfigBindRun_InvalidLang verifies a non-empty --lang is strictly +// validated: wrong case, typos, and removed codes all exit with +// ExitValidation (code 2) and a message identifying the offending value. +// (Empty is not invalid — see TestConfigBindRun_EmptyLangIsNoOp.) +func TestConfigBindRun_InvalidLang(t *testing.T) { + saveWorkspace(t) + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + hermesHome := t.TempDir() + t.Setenv("HERMES_HOME", hermesHome) + if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil { + t.Fatalf("write .env: %v", err) + } + + cases := []struct { + name string + lang string + }{ + {"wrong case ZH", "ZH"}, + {"typo frr", "frr"}, + {"removed code ar", "ar"}, + {"unknown xx", "xx"}, + {"hyphen form zh-CN", "zh-CN"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{ + Factory: f, + Source: "hermes", + Lang: tc.lang, + langExplicit: true, + }) + if err == nil { + t.Fatalf("expected validation error for --lang %q, got nil", tc.lang) + } + exitErr, ok := err.(*output.ExitError) + if !ok { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation) + } + if !strings.Contains(exitErr.Error(), "invalid --lang") { + t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error()) + } + }) + } +} + +// TestConfigBindRun_EmptyLangIsNoOp verifies that an empty --lang (omitted or +// explicit "") is unset: it neither errors nor persists a language, while a +// non-empty short code or Feishu locale both canonicalize to the same locale. +func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) { + cases := []struct { + name string + lang string + explicit bool + wantLang i18n.Lang + }{ + {"omitted", "", false, ""}, + {"explicit empty", "", true, ""}, + {"short code", "ja", true, i18n.LangJaJP}, + {"feishu locale", "ja_jp", true, i18n.LangJaJP}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + hermesHome := t.TempDir() + t.Setenv("HERMES_HOME", hermesHome) + if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil { + t.Fatalf("write .env: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{ + Factory: f, + Source: "hermes", + Lang: tc.lang, + langExplicit: tc.explicit, + }); err != nil { + t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err) + } + + multi, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig: %v", err) + } + app := multi.CurrentAppConfig("") + if app == nil { + t.Fatal("no app persisted") + } + if app.Lang != tc.wantLang { + t.Errorf("persisted Lang = %q, want %q", app.Lang, tc.wantLang) + } + }) + } +} + +// TestConfigBindRun_OmitLangPreservesPrior guards against a re-bind without +// --lang silently dropping a previously stored preference (appConfig is rebuilt +// fresh, so commitBinding must inherit the prior Lang). +func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + hermesHome := t.TempDir() + t.Setenv("HERMES_HOME", hermesHome) + if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil { + t.Fatalf("write .env: %v", err) + } + + f1, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "ja", langExplicit: true}); err != nil { + t.Fatalf("first bind (--lang ja): %v", err) + } + f2, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil { + t.Fatalf("re-bind (no --lang): %v", err) + } + + multi, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig: %v", err) + } + if app := multi.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP { + t.Errorf("Lang after re-bind = %v, want %q (preserved)", app, i18n.LangJaJP) + } +} + +// TestPriorLang_RespectsCurrentApp guards against priorLang scanning all apps +// and silently returning a non-current profile's Lang. In a multi-profile +// workspace (set up via `profile add` before a re-bind), the active profile's +// Lang must win over a sibling profile that happens to sit earlier in the slice. +func TestPriorLang_RespectsCurrentApp(t *testing.T) { + multi := core.MultiAppConfig{ + CurrentApp: "active", + Apps: []core.AppConfig{ + {Name: "stale", AppId: "cli_stale", Lang: i18n.LangJaJP}, + {Name: "active", AppId: "cli_active", Lang: i18n.LangEnUS}, + }, + } + bytes, err := json.Marshal(multi) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if got := priorLang(bytes); got != i18n.LangEnUS { + t.Errorf("priorLang = %q, want %q (must follow CurrentApp, not Apps[0])", got, i18n.LangEnUS) + } +} + +// TestPriorLang_FallsBackToFirstAppWhenCurrentUnset covers the legacy +// single-app shape (no CurrentApp): CurrentAppConfig falls back to Apps[0], +// so a bind-written config (which always has exactly one app and no +// CurrentApp field) still inherits its Lang. +func TestPriorLang_FallsBackToFirstAppWhenCurrentUnset(t *testing.T) { + multi := core.MultiAppConfig{ + Apps: []core.AppConfig{ + {AppId: "cli_only", Lang: i18n.LangJaJP}, + }, + } + bytes, err := json.Marshal(multi) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if got := priorLang(bytes); got != i18n.LangJaJP { + t.Errorf("priorLang = %q, want %q", got, i18n.LangJaJP) + } +} + +// TestPriorLang_MalformedReturnsEmpty exercises the unparseable-bytes branch. +func TestPriorLang_MalformedReturnsEmpty(t *testing.T) { + if got := priorLang([]byte("not json")); got != "" { + t.Errorf("priorLang(malformed) = %q, want \"\"", got) + } +} + +// TestConfigBindRun_EnvelopeMessageFollowsInheritedLang guards the JSON envelope +// "message" field against regressing to opts.Lang: when --lang is omitted on +// re-bind, the inherited preference (appConfig.Lang) must drive the message +// language and the embedded brand display — otherwise an AI agent that set +// English on first bind sees Chinese in every subsequent re-bind envelope. +func TestConfigBindRun_EnvelopeMessageFollowsInheritedLang(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + hermesHome := t.TempDir() + t.Setenv("HERMES_HOME", hermesHome) + if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil { + t.Fatalf("write .env: %v", err) + } + + f1, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "en", langExplicit: true}); err != nil { + t.Fatalf("first bind (--lang en): %v", err) + } + + f2, stdout, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil { + t.Fatalf("re-bind (no --lang): %v", err) + } + + envelope := map[string]any{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("invalid JSON output: %v", err) + } + msg, _ := envelope["message"].(string) + enMsg := getBindMsg(i18n.LangEnUS) + wantMsg := fmt.Sprintf(enMsg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", i18n.LangEnUS)) + if msg != wantMsg { + t.Errorf("envelope.message = %q,\nwant %q (must follow inherited appConfig.Lang=en_us, not raw opts.Lang)", msg, wantMsg) + } +} + // ── Run function tests (aligned with TestConfigShowRun pattern) ── func TestConfigBindRun_InvalidSource(t *testing.T) { @@ -154,7 +383,7 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) { // TestFactory has IsTerminal=false by default err := configBindRun(&BindOptions{Factory: f, Source: ""}) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "bind", + Type: "validation", Message: "cannot determine Agent source: no --source flag and no Agent environment detected", Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context", }) @@ -193,7 +422,7 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "bind", + Type: "validation", Message: `--source "openclaw" does not match detected Agent environment (hermes)`, Hint: "remove --source to auto-detect, or run this command in the correct Agent context", }) @@ -209,7 +438,7 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "bind", + Type: "validation", Message: `--source "hermes" does not match detected Agent environment (openclaw)`, Hint: "remove --source to auto-detect, or run this command in the correct Agent context", }) @@ -337,8 +566,8 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) envPath := filepath.Join(hermesHome, ".env") - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "hermes", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "failed to read Hermes config: open " + envPath + ": no such file or directory", Hint: "verify Hermes is installed and configured at " + envPath, }) @@ -355,8 +584,8 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json") - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory", Hint: "verify OpenClaw is installed and configured", }) @@ -503,7 +732,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "bind", + Type: "validation", Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`, Hint: "remove --source to auto-detect, or run this command in the correct Agent context", }) @@ -521,8 +750,8 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) configPath := filepath.Join(fakeHome, ".lark-channel", "config.json") - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "lark-channel", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory", Hint: "verify lark-channel-bridge is installed and configured", }) @@ -541,8 +770,8 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "lark-channel", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "accounts.app.id missing in " + configPath, Hint: "run lark-channel-bridge's setup to populate the app credential", }) @@ -560,8 +789,8 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "lark-channel", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "accounts.app.secret is empty in " + configPath, Hint: "run lark-channel-bridge's setup to populate the app credential", }) @@ -912,12 +1141,8 @@ func TestConfigBindRun_OpenClawMultiAccount_MissingAppID(t *testing.T) { if err == nil { t.Fatal("expected error for multi-account without --app-id, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("error type = %T, want *output.ExitError", err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } } @@ -963,7 +1188,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) { // each accepted variant so every ErrDetail field (Type, Code, Message, // Hint, ConsoleURL, Detail, and any future addition) is still compared. base := output.ErrDetail{ - Type: "openclaw", + Type: "validation", Message: "multiple accounts in openclaw.json; pass --app-id ", } wantWorkFirst := base @@ -971,20 +1196,17 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) { wantPersonalFirst := base wantPersonalFirst.Hint = "available app IDs:\n cli_personal_222 (personal)\n cli_work_111 (work)" - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("error type = %T, want *output.ExitError; err = %v", err, err) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err) } - if exitErr.Detail == nil { - t.Fatal("expected non-nil error detail") - } - if !reflect.DeepEqual(*exitErr.Detail, wantWorkFirst) && - !reflect.DeepEqual(*exitErr.Detail, wantPersonalFirst) { + got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint} + if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) { t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v", - *exitErr.Detail, wantWorkFirst, wantPersonalFirst) + got, wantWorkFirst, wantPersonalFirst) } } @@ -1009,7 +1231,7 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"}) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + Type: "validation", Message: `--app-id "nonexistent" not found in openclaw.json`, Hint: "available app IDs:\n cli_only_one", }) @@ -1141,11 +1363,19 @@ func TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce(t *testing.T) { Identity: "user-default", }) msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "bind", - Message: msg.IdentityEscalationMessage, - Hint: msg.IdentityEscalationHint, - }) + var ce *errs.ConfirmationRequiredError + if !errors.As(err, &ce) { + t.Fatalf("error type = %T, want *errs.ConfirmationRequiredError; error = %v", err, err) + } + if ce.Risk != errs.RiskHighRiskWrite { + t.Errorf("Risk = %q, want %q", ce.Risk, errs.RiskHighRiskWrite) + } + if ce.Message != msg.IdentityEscalationMessage { + t.Errorf("Message mismatch:\ngot: %q\nwant: %q", ce.Message, msg.IdentityEscalationMessage) + } + if ce.Hint != msg.IdentityEscalationHint { + t.Errorf("Hint mismatch:\ngot: %q\nwant: %q", ce.Hint, msg.IdentityEscalationHint) + } // Config on disk must remain untouched — the gate runs before // commitBinding writes anything. @@ -1306,8 +1536,8 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) envPath := filepath.Join(hermesHome, ".env") - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "hermes", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "FEISHU_APP_ID not found in " + envPath, Hint: "run 'hermes setup' to configure Feishu credentials", }) @@ -1326,8 +1556,8 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) envPath := filepath.Join(hermesHome, ".env") - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "hermes", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "FEISHU_APP_SECRET not found in " + envPath, Hint: "run 'hermes setup' to configure Feishu credentials", }) @@ -1352,8 +1582,8 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "openclaw.json missing channels.feishu section", Hint: "configure Feishu in OpenClaw first", }) @@ -1380,8 +1610,8 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) { openclawPath := filepath.Join(openclawDir, "openclaw.json") f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "appSecret is empty for app cli_no_secret in " + openclawPath, Hint: "configure channels.feishu.appSecret in openclaw.json", }) @@ -1442,8 +1672,8 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "no Feishu app configured in openclaw.json", Hint: "configure channels.feishu.appId in openclaw.json", }) @@ -1474,10 +1704,14 @@ func TestGetBindMsg_En(t *testing.T) { } } -func TestGetBindMsg_UnknownLang_DefaultsToZh(t *testing.T) { - msg := getBindMsg("fr") - if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want { - t.Errorf("fr (default) SelectSource = %q, want %q", msg.SelectSource, want) +func TestGetBindMsg_NonEnLang_FallsBackToZh(t *testing.T) { + // Only zh and en TUI bundles exist; any non-English language (canonical + // locale, short code, or unrecognized value) falls back to zh. + for _, lang := range []i18n.Lang{"fr_fr", "ja_jp", "ko", "unknown", ""} { + msg := getBindMsg(lang) + if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want { + t.Errorf("getBindMsg(%q) SelectSource = %q, want %q (zh fallback)", lang, msg.SelectSource, want) + } } } @@ -1640,3 +1874,36 @@ func TestHasStrictBotLock(t *testing.T) { }) } } + +// TestConfigBindRun_LangExplicit_PrintsConfirmation covers the flag-mode +// confirmation line: when --lang is explicit, bind prints "language preference +// set" to stderr (rendered in the TUI language, embedding the preference value). +func TestConfigBindRun_LangExplicit_PrintsConfirmation(t *testing.T) { + saveWorkspace(t) + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + + hermesHome := t.TempDir() + t.Setenv("HERMES_HOME", hermesHome) + if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil { + t.Fatalf("write .env: %v", err) + } + + f, _, stderr, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{ + Factory: f, + Source: "hermes", + Identity: "bot-only", + Lang: "en", + langExplicit: true, + }) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + // The short --lang en is canonicalized to en_us before the confirmation + // echoes it back; the TUI language stays zh (flag mode, no picker). + want := fmt.Sprintf(getBindMsg(i18n.LangZhCN).LangPreferenceSet, "en_us") + if got := stderr.String(); !strings.Contains(got, want) { + t.Errorf("stderr = %q, want it to contain confirmation %q", got, want) + } +} diff --git a/cmd/config/binder.go b/cmd/config/binder.go index 8a9426674..6b04086df 100644 --- a/cmd/config/binder.go +++ b/cmd/config/binder.go @@ -9,9 +9,9 @@ import ( "path/filepath" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/binding" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/vfs" ) @@ -49,7 +49,7 @@ func newBinder(source string, opts *BindOptions) (SourceBinder, error) { case "lark-channel": return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil default: - return nil, output.ErrValidation("unsupported source: %s", source) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported source: %s", source).WithParam("--source") } } @@ -85,11 +85,10 @@ func selectCandidate( // from ListCandidates itself and never reach here. switch src { case "openclaw": - return nil, output.ErrWithHint(output.ExitValidation, src, - "no Feishu app configured in openclaw.json", - "configure channels.feishu.appId in openclaw.json") + return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "no Feishu app configured in openclaw.json"). + WithHint("configure channels.feishu.appId in openclaw.json") default: - return nil, output.ErrValidation("%s: no app configured", src) + return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "%s: no app configured", src) } } @@ -99,9 +98,9 @@ func selectCandidate( return &candidates[i], nil } } - return nil, output.ErrWithHint(output.ExitValidation, src, - fmt.Sprintf("--app-id %q not found in %s", appIDFlag, cfgBase), - fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates))) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id %q not found in %s", appIDFlag, cfgBase). + WithHint("available app IDs:\n %s", formatCandidates(candidates)). + WithParam("--app-id") } if len(candidates) == 1 { @@ -112,9 +111,9 @@ func selectCandidate( return tuiPrompt(candidates) } - return nil, output.ErrWithHint(output.ExitValidation, src, - fmt.Sprintf("multiple accounts in %s; pass --app-id ", cfgBase), - fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates))) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "multiple accounts in %s; pass --app-id ", cfgBase). + WithHint("available app IDs:\n %s", formatCandidates(candidates)). + WithParam("--app-id") } // formatCandidates renders candidates as "AppID (Label)" lines for error hints. @@ -149,14 +148,13 @@ func (b *openclawBinder) ConfigPath() string { return b.path } func (b *openclawBinder) ListCandidates() ([]Candidate, error) { cfg, err := binding.ReadOpenClawConfig(b.path) if err != nil { - return nil, output.ErrWithHint(output.ExitValidation, "openclaw", - fmt.Sprintf("cannot read %s: %v", b.path, err), - "verify OpenClaw is installed and configured") + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err). + WithHint("verify OpenClaw is installed and configured"). + WithCause(err) } if cfg.Channels.Feishu == nil { - return nil, output.ErrWithHint(output.ExitValidation, "openclaw", - "openclaw.json missing channels.feishu section", - "configure Feishu in OpenClaw first") + return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "openclaw.json missing channels.feishu section"). + WithHint("configure Feishu in OpenClaw first") } raw := binding.ListCandidateApps(cfg.Channels.Feishu) @@ -172,8 +170,7 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) { func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) { if b.cfg == nil { - return nil, output.Errorf(output.ExitInternal, "openclaw", - "internal: Build called before ListCandidates") + return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") } var selected *binding.CandidateApp @@ -184,26 +181,25 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) { } } if selected == nil { - return nil, output.Errorf(output.ExitInternal, "openclaw", - "internal: appID %q not in candidates", appID) + return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID) } if selected.AppSecret.IsZero() { - return nil, output.ErrWithHint(output.ExitValidation, "openclaw", - fmt.Sprintf("appSecret is empty for app %s in %s", selected.AppID, b.path), - "configure channels.feishu.appSecret in openclaw.json") + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path). + WithHint("configure channels.feishu.appSecret in openclaw.json") } secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv) if err != nil { - return nil, output.ErrWithHint(output.ExitValidation, "openclaw", - fmt.Sprintf("failed to resolve appSecret for %s: %v", selected.AppID, err), - fmt.Sprintf("check appSecret configuration in %s", b.path)) + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err). + WithHint("check appSecret configuration in %s", b.path). + WithCause(err) } stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain) if err != nil { - return nil, output.Errorf(output.ExitInternal, "openclaw", - "keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err) + return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err). + WithHint("use file: reference in config to bypass keychain"). + WithCause(err) } return &core.AppConfig{ @@ -229,15 +225,14 @@ func (b *hermesBinder) ConfigPath() string { return b.path } func (b *hermesBinder) ListCandidates() ([]Candidate, error) { envMap, err := readDotenv(b.path) if err != nil { - return nil, output.ErrWithHint(output.ExitValidation, "hermes", - fmt.Sprintf("failed to read Hermes config: %v", err), - fmt.Sprintf("verify Hermes is installed and configured at %s", b.path)) + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to read Hermes config: %v", err). + WithHint("verify Hermes is installed and configured at %s", b.path). + WithCause(err) } appID := envMap["FEISHU_APP_ID"] if appID == "" { - return nil, output.ErrWithHint(output.ExitValidation, "hermes", - fmt.Sprintf("FEISHU_APP_ID not found in %s", b.path), - "run 'hermes setup' to configure Feishu credentials") + return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "FEISHU_APP_ID not found in %s", b.path). + WithHint("run 'hermes setup' to configure Feishu credentials") } b.envMap = envMap return []Candidate{{AppID: appID, Label: "default"}}, nil @@ -245,24 +240,22 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) { func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) { if b.envMap == nil { - return nil, output.Errorf(output.ExitInternal, "hermes", - "internal: Build called before ListCandidates") + return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") } if b.envMap["FEISHU_APP_ID"] != appID { - return nil, output.Errorf(output.ExitInternal, "hermes", - "internal: appID %q does not match env", appID) + return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match env", appID) } appSecret := b.envMap["FEISHU_APP_SECRET"] if appSecret == "" { - return nil, output.ErrWithHint(output.ExitValidation, "hermes", - fmt.Sprintf("FEISHU_APP_SECRET not found in %s", b.path), - "run 'hermes setup' to configure Feishu credentials") + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "FEISHU_APP_SECRET not found in %s", b.path). + WithHint("run 'hermes setup' to configure Feishu credentials") } stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain) if err != nil { - return nil, output.Errorf(output.ExitInternal, "hermes", - "keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err) + return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err). + WithHint("use file: reference in config to bypass keychain"). + WithCause(err) } return &core.AppConfig{ @@ -290,14 +283,13 @@ func (b *larkChannelBinder) ConfigPath() string { return b.path } func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) { cfg, err := binding.ReadLarkChannelConfig(b.path) if err != nil { - return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", - fmt.Sprintf("cannot read %s: %v", b.path, err), - "verify lark-channel-bridge is installed and configured") + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err). + WithHint("verify lark-channel-bridge is installed and configured"). + WithCause(err) } if cfg.Accounts.App.ID == "" { - return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", - fmt.Sprintf("accounts.app.id missing in %s", b.path), - "run lark-channel-bridge's setup to populate the app credential") + return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "accounts.app.id missing in %s", b.path). + WithHint("run lark-channel-bridge's setup to populate the app credential") } b.cfg = cfg return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil @@ -305,32 +297,30 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) { func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) { if b.cfg == nil { - return nil, output.Errorf(output.ExitInternal, "lark-channel", - "internal: Build called before ListCandidates") + return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") } if b.cfg.Accounts.App.ID != appID { - return nil, output.Errorf(output.ExitInternal, "lark-channel", - "internal: appID %q does not match config", appID) + return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match config", appID) } if b.cfg.Accounts.App.Secret.IsZero() { - return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", - fmt.Sprintf("accounts.app.secret is empty in %s", b.path), - "run lark-channel-bridge's setup to populate the app credential") + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "accounts.app.secret is empty in %s", b.path). + WithHint("run lark-channel-bridge's setup to populate the app credential") } // Resolve through the same SecretInput pipeline openclaw uses, so // bridge configs can use ${VAR} / env / file / exec just like openclaw. secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv) if err != nil { - return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", - fmt.Sprintf("failed to resolve appSecret for %s: %v", appID, err), - fmt.Sprintf("check appSecret configuration in %s", b.path)) + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err). + WithHint("check appSecret configuration in %s", b.path). + WithCause(err) } stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain) if err != nil { - return nil, output.Errorf(output.ExitInternal, "lark-channel", - "keychain unavailable: %v", err) + return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err). + WithHint("use file: reference in config to bypass keychain"). + WithCause(err) } return &core.AppConfig{ @@ -389,10 +379,12 @@ func resolveHermesEnvPath() string { } // resolveLarkChannelConfigPath returns the path to lark-channel-bridge's -// config.json. Mirrors the bridge's src/config/paths.ts which hardcodes -// ~/.lark-channel/config.json with no env override — multi-instance is not -// a supported scenario today. +// source config. LARK_CHANNEL_CONFIG lets a host point bind at a projected +// single-account config without changing lark-cli's target config directory. func resolveLarkChannelConfigPath() string { + if p := os.Getenv("LARK_CHANNEL_CONFIG"); strings.TrimSpace(p) != "" { + return expandHome(p) + } home, err := vfs.UserHomeDir() if err != nil || home == "" { fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err) diff --git a/cmd/config/binder_test.go b/cmd/config/binder_test.go index febc994d3..6f1df1313 100644 --- a/cmd/config/binder_test.go +++ b/cmd/config/binder_test.go @@ -4,6 +4,7 @@ package config import ( + "path/filepath" "reflect" "testing" @@ -50,8 +51,8 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) { func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) { b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"} _, err := selectCandidate(b, nil, "", false, tuiUnreachable(t)) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "no Feishu app configured in openclaw.json", Hint: "configure channels.feishu.appId in openclaw.json", }) @@ -63,8 +64,8 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) { // even before it has a bespoke error message. b := &fakeBinder{name: "hermes", path: "/tmp/.env"} _, err := selectCandidate(b, nil, "", false, tuiUnreachable(t)) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "validation", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "hermes: no app configured", }) } @@ -100,7 +101,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) { } _, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t)) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + Type: "validation", Message: `--app-id "nonexistent" not found in openclaw.json`, Hint: "available app IDs:\n cli_work (work)\n cli_home (home)", }) @@ -117,7 +118,7 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) { } _, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t)) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + Type: "validation", Message: "multiple accounts in openclaw.json; pass --app-id ", Hint: "available app IDs:\n cli_work (work)\n cli_home (home)", }) @@ -152,7 +153,7 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) { candidates := []Candidate{{AppID: "cli_only"}} _, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t)) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + Type: "validation", Message: `--app-id "nonexistent" not found in openclaw.json`, Hint: "available app IDs:\n cli_only", }) @@ -173,3 +174,27 @@ func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) { } assertCandidate(t, got, Candidate{AppID: "cli_b"}) } + +func TestResolveLarkChannelConfigPath_Default(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("LARK_CHANNEL_CONFIG", "") + + got := resolveLarkChannelConfigPath() + want := filepath.Join(home, ".lark-channel", "config.json") + if got != want { + t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want) + } +} + +func TestResolveLarkChannelConfigPath_EnvOverride(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("LARK_CHANNEL_CONFIG", "~/bridge/projection.json") + + got := resolveLarkChannelConfigPath() + want := filepath.Join(home, "bridge", "projection.json") + if got != want { + t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want) + } +} diff --git a/cmd/config/config.go b/cmd/config/config.go index c99f6b482..f3c643fd5 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -33,6 +33,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewCmdConfigStrictMode(f)) cmd.AddCommand(NewCmdConfigPolicy(f)) cmd.AddCommand(NewCmdConfigPlugins(f)) + cmd.AddCommand(NewCmdConfigKeychainDowngrade(f)) return cmd } diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index fbf72a87c..04645c4ea 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -16,6 +16,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" ) @@ -125,15 +126,11 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) { t.Fatal("expected error") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("error type = %T, want *output.ExitError", err) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitAuth) } - if exitErr.Code != output.ExitValidation { - t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) - } - if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "no active profile" { - t.Fatalf("detail = %#v, want config/no active profile", exitErr.Detail) + if !strings.Contains(err.Error(), "no active profile") { + t.Fatalf("error = %v, want to contain 'no active profile'", err) } } @@ -151,8 +148,9 @@ func TestConfigInitCmd_LangFlag(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) } - if gotOpts.Lang != "en" { - t.Errorf("expected Lang en, got %s", gotOpts.Lang) + // --lang en is canonicalized to en_us in RunE before runF captures opts. + if gotOpts.Lang != string(i18n.LangEnUS) { + t.Errorf("expected Lang en_us, got %s", gotOpts.Lang) } if !gotOpts.langExplicit { t.Error("expected langExplicit=true when --lang is passed") @@ -173,14 +171,82 @@ func TestConfigInitCmd_LangDefault(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) } - if gotOpts.Lang != "zh" { - t.Errorf("expected default Lang zh, got %s", gotOpts.Lang) + if gotOpts.Lang != "" { + t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang) } if gotOpts.langExplicit { t.Error("expected langExplicit=false when --lang is not passed") } } +// TestSaveInitConfig_OmitLangPreservesPrior guards the single-app replace path: +// re-running init without --lang must inherit the prior preference, not clear it. +func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + + existing := &core.MultiAppConfig{Apps: []core.AppConfig{ + {AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP}, + }} + if err := core.SaveMultiAppConfig(existing); err != nil { + t.Fatalf("seed config: %v", err) + } + + if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil { + t.Fatalf("saveInitConfig (no --lang): %v", err) + } + + got, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig: %v", err) + } + if app := got.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP { + t.Errorf("Lang after re-init = %v, want %q (preserved)", app, i18n.LangJaJP) + } +} + +// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is +// strictly validated the same way bind validates: wrong-case / typo / removed +// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.) +func TestConfigInitCmd_InvalidLang(t *testing.T) { + clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + cases := []struct { + name string + lang string + }{ + {"wrong case ZH", "ZH"}, + {"typo frr", "frr"}, + {"removed code ar", "ar"}, + {"unknown xx", "xx"}, + {"hyphen form zh-CN", "zh-CN"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + cmd := NewCmdConfigInit(f, nil) + f.IOStreams.In = strings.NewReader("sec\n") + cmd.SetArgs([]string{"--lang", tc.lang, "--app-id", "x", "--app-secret-stdin"}) + err := cmd.Execute() + if err == nil { + t.Fatalf("expected validation error for --lang %q, got nil", tc.lang) + } + exitErr, ok := err.(*output.ExitError) + if !ok { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation) + } + if !strings.Contains(exitErr.Error(), "invalid --lang") { + t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error()) + } + }) + } +} + func TestHasAnyNonInteractiveFlag(t *testing.T) { tests := []struct { name string @@ -399,16 +465,65 @@ func TestConfigBlockedByExternalProvider(t *testing.T) { if matched != nil && matched != cmd && !matched.SilenceUsage { t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) - } - if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" { - t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider") + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } }) } } + +// TestValidateInitLang covers the --lang contract: empty (omitted or explicit) +// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to +// the same locale; an unrecognized value errors. +func TestValidateInitLang(t *testing.T) { + t.Run("empty is a no-op", func(t *testing.T) { + for _, explicit := range []bool{false, true} { + opts := &ConfigInitOptions{Lang: "", langExplicit: explicit} + if err := validateInitLang(opts); err != nil { + t.Fatalf("explicit=%v: expected nil error, got %v", explicit, err) + } + if opts.Lang != "" { + t.Errorf("explicit=%v: Lang = %q, want \"\" (unset)", explicit, opts.Lang) + } + } + }) + t.Run("short and locale canonicalize alike", func(t *testing.T) { + for _, in := range []string{"ja", "ja_jp"} { + opts := &ConfigInitOptions{Lang: in, langExplicit: true} + if err := validateInitLang(opts); err != nil { + t.Fatalf("--lang %q: unexpected error %v", in, err) + } + if opts.Lang != string(i18n.LangJaJP) { + t.Errorf("--lang %q normalized to %q, want %q", in, opts.Lang, i18n.LangJaJP) + } + } + }) +} + +// TestPrintLangPreferenceConfirmation covers the confirmation helper: it prints +// to stderr only when --lang explicitly set a non-empty preference. +func TestPrintLangPreferenceConfirmation(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Run("explicit non-empty prints confirmation", func(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, nil) + printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: true}) + got := stderr.String() + if !strings.Contains(got, "语言偏好") || !strings.Contains(got, "en_us") { + t.Errorf("stderr = %q, want confirmation mentioning the preference and en_us", got) + } + }) + t.Run("implicit prints nothing", func(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, nil) + printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: false}) + if got := stderr.String(); got != "" { + t.Errorf("stderr = %q, want empty when --lang is implicit", got) + } + }) + t.Run("explicit empty prints nothing", func(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, nil) + printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "", UILang: i18n.LangZhCN, langExplicit: true}) + if got := stderr.String(); got != "" { + t.Errorf("stderr = %q, want empty when --lang is empty", got) + } + }) +} diff --git a/cmd/config/default_as.go b/cmd/config/default_as.go index a5078c1e9..f1d5de4e7 100644 --- a/cmd/config/default_as.go +++ b/cmd/config/default_as.go @@ -6,9 +6,9 @@ package config import ( "fmt" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" ) @@ -41,12 +41,12 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command { value := args[0] if value != "user" && value != "bot" && value != "auto" { - return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value) } app.DefaultAs = core.Identity(value) if err := core.SaveMultiAppConfig(multi); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value) return nil diff --git a/cmd/config/init.go b/cmd/config/init.go index 837c6f511..de8f7b355 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -15,9 +15,11 @@ import ( "github.com/charmbracelet/huh" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" ) @@ -31,9 +33,13 @@ type ConfigInitOptions struct { AppSecretStdin bool // read app-secret from stdin (avoids process list exposure) Brand string New bool - Lang string - langExplicit bool // true when --lang was explicitly passed - ProfileName string // when set, create/update a named profile instead of replacing Apps[0] + + Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang + langExplicit bool // true when --lang was explicitly passed + + UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang + + ProfileName string // when set, create/update a named profile instead of replacing Apps[0] // ForceInit overrides the agent-workspace guard. Without it, running // init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller @@ -45,7 +51,7 @@ type ConfigInitOptions struct { // NewCmdConfigInit creates the config init subcommand. func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command { - opts := &ConfigInitOptions{Factory: f} + opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN} cmd := &cobra.Command{ Use: "init", @@ -63,6 +69,9 @@ if the user explicitly wants a separate app inside the Agent workspace.`, RunE: func(cmd *cobra.Command, args []string) error { opts.Ctx = cmd.Context() opts.langExplicit = cmd.Flags().Changed("lang") + if err := validateInitLang(opts); err != nil { + return err + } if err := guardAgentWorkspace(opts); err != nil { return err } @@ -77,7 +86,7 @@ if the user explicitly wants a separate app inside the Agent workspace.`, cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)") cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure") cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)") - cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)") + cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)") cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)") cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app") cmdutil.SetRisk(cmd, "write") @@ -85,6 +94,25 @@ if the user explicitly wants a separate app inside the Agent workspace.`, return cmd } +// printLangPreferenceConfirmation echoes the set preference to stderr, only +// when --lang explicitly set a non-empty value. +func printLangPreferenceConfirmation(opts *ConfigInitOptions) { + if !opts.langExplicit || opts.Lang == "" { + return + } + msg := getInitMsg(opts.UILang) + fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, opts.Lang)) +} + +func validateInitLang(opts *ConfigInitOptions) error { + lang, err := cmdutil.ParseLangFlag(opts.Lang) + if err != nil { + return err + } + opts.Lang = string(lang) + return nil +} + // guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or // Hermes Agent context, because the Agent has already provisioned an app // and 'config bind' is the right tool for hooking lark-cli into it. @@ -132,7 +160,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { config := &core.MultiAppConfig{ Apps: []core.AppConfig{{ - AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{}, + AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{}, }}, } return core.SaveMultiAppConfig(config) @@ -146,7 +174,13 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang) } cleanupOldConfig(existing, f, appId) - return saveAsOnlyApp(appId, secret, brand, lang) + var prior i18n.Lang + if existing != nil { + if app := existing.CurrentAppConfig(""); app != nil { + prior = app.Lang + } + } + return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior))) } // saveAsProfile appends or updates a named profile in the config. @@ -167,11 +201,10 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr } multi.Apps[idx].Users = []core.AppUser{} } - // Update existing profile multi.Apps[idx].AppId = appId multi.Apps[idx].AppSecret = secret multi.Apps[idx].Brand = brand - multi.Apps[idx].Lang = lang + multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang) } else { if findAppIndexByAppID(multi, profileName) >= 0 { return fmt.Errorf("profile name %q conflicts with existing appId", profileName) @@ -182,7 +215,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr AppId: appId, AppSecret: secret, Brand: brand, - Lang: lang, + Lang: i18n.Lang(lang), Users: []core.AppUser{}, }) } @@ -213,9 +246,29 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int { return -1 } +// wrapUpdateExistingProfileErr classifies the error returned by +// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError +// for blank-input) pass through unchanged so their exit code semantics +// survive; legacy *output.ExitError also passes through; everything else +// (filesystem, keychain, etc.) is wrapped as InternalError. +func wrapUpdateExistingProfileErr(err error) error { + if err == nil { + return nil + } + if errs.IsTyped(err) { + return err + } + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return err + } + return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err) +} + func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error { if existing == nil { - return output.ErrValidation("App Secret cannot be empty for new configuration") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration"). + WithParam("--app-secret") } var app *core.AppConfig @@ -223,22 +276,25 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa if idx := findProfileIndexByName(existing, profileName); idx >= 0 { app = &existing.Apps[idx] } else { - return output.ErrValidation("App Secret cannot be empty for new profile") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile"). + WithParam("--app-secret") } } else { app = existing.CurrentAppConfig("") if app == nil { - return output.ErrValidation("App Secret cannot be empty for new configuration") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration"). + WithParam("--app-secret") } } if app.AppId != appID { - return output.ErrValidation("App Secret cannot be empty when changing App ID") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty when changing App ID"). + WithParam("--app-secret") } app.AppId = appID app.Brand = brand - app.Lang = lang + app.Lang = preferredLang(i18n.Lang(lang), app.Lang) return core.SaveMultiAppConfig(existing) } @@ -250,13 +306,13 @@ func configInitRun(opts *ConfigInitOptions) error { scanner := bufio.NewScanner(f.IOStreams.In) if !scanner.Scan() { if err := scanner.Err(); err != nil { - return output.ErrValidation("failed to read secret from stdin: %v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to read secret from stdin: %v", err).WithCause(err) } - return output.ErrValidation("stdin is empty, expected app secret") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret") } opts.appSecret = strings.TrimSpace(scanner.Text()) if opts.appSecret == "" { - return output.ErrValidation("app secret read from stdin is empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty") } } @@ -268,7 +324,7 @@ func configInitRun(opts *ConfigInitOptions) error { // Validate --profile name if set if opts.ProfileName != "" { if err := core.ValidateProfileName(opts.ProfileName); err != nil { - return output.ErrValidation("%v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err) } } @@ -277,35 +333,36 @@ func configInitRun(opts *ConfigInitOptions) error { brand := parseBrand(opts.Brand) secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) + printLangPreferenceConfirmation(opts) output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand}) + if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil { + return err + } return nil } - // For interactive modes, prompt language selection if --lang was not explicitly set + // For interactive modes, prompt language selection if --lang was not explicitly set. + // Picker offers 2 options (中文 / English) and drives BOTH opts.Lang + // (preference) and opts.UILang (TUI rendering). if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() { - savedLang := "" - if existing != nil { - if app := existing.CurrentAppConfig(""); app != nil { - savedLang = app.Lang - } - } - lang, err := promptLangSelection(savedLang) + lang, err := promptLangSelection() if err != nil { if err == huh.ErrUserAborted { return output.ErrBare(1) } - return err + return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err) } - opts.Lang = lang + opts.Lang = string(lang) + opts.UILang = lang } - msg := getInitMsg(opts.Lang) + msg := getInitMsg(opts.UILang) // Mode 3: Create new app directly (--new) if opts.New { @@ -314,17 +371,21 @@ func configInitRun(opts *ConfigInitOptions) error { return err } if result == nil { - return output.ErrValidation("app creation returned no result") + return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result") } existing, _ := core.LoadMultiAppConfig() secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } + printLangPreferenceConfirmation(opts) output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand}) + if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil { + return err + } return nil } @@ -335,7 +396,8 @@ func configInitRun(opts *ConfigInitOptions) error { return err } if result == nil { - return output.ErrValidation("App ID and App Secret cannot be empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty"). + WithParam("--app-id") } existing, _ := core.LoadMultiAppConfig() @@ -344,34 +406,36 @@ func configInitRun(opts *ConfigInitOptions) error { // New secret provided (either from "create" or "existing" with input) secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } } else if result.Mode == "existing" && result.AppID != "" { // Existing app with unchanged secret — update app ID and brand only - if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil { - // Deprecated: legacy *output.ExitError passthrough; removed after typed migration. - var exitErr *output.ExitError - if errors.As(err, &exitErr) { - return err - } - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + if err := wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil { + return err } } else { - return output.ErrValidation("App ID and App Secret cannot be empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty"). + WithParam("--app-id") } if result.Mode == "existing" { output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID)) } + printLangPreferenceConfirmation(opts) + if result.AppSecret != "" { + if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil { + return err + } + } return nil } // Non-terminal: cannot run interactive mode, guide user to --new if !f.IOStreams.IsTerminal { - return output.ErrValidation("config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.") } // Mode 5: Legacy interactive (readline fallback) @@ -399,7 +463,7 @@ func configInitRun(opts *ConfigInitOptions) error { } appIdInput, err := readLine(prompt) if err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err) } prompt = "App Secret" @@ -408,7 +472,7 @@ func configInitRun(opts *ConfigInitOptions) error { } appSecretInput, err := readLine(prompt) if err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err) } prompt = "Brand (lark/feishu)" @@ -419,7 +483,7 @@ func configInitRun(opts *ConfigInitOptions) error { } brandInput, err := readLine(prompt) if err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err) } resolvedAppId := appIdInput @@ -441,16 +505,23 @@ func configInitRun(opts *ConfigInitOptions) error { } if resolvedAppId == "" || resolvedSecret.IsZero() { - return output.ErrValidation("App ID and App Secret cannot be empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty"). + WithParam("--app-id") } storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) + printLangPreferenceConfirmation(opts) + if appSecretInput != "" { + if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil { + return err + } + } return nil } diff --git a/cmd/config/init_interactive.go b/cmd/config/init_interactive.go index 0a511cd0d..0f17000a8 100644 --- a/cmd/config/init_interactive.go +++ b/cmd/config/init_interactive.go @@ -6,16 +6,17 @@ package config import ( "context" "fmt" - "net/http" "github.com/charmbracelet/huh" "github.com/larksuite/cli/internal/build" qrcode "github.com/skip2/go-qrcode" + "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/transport" ) // configInitResult holds the result of the interactive config init flow. @@ -125,8 +126,16 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er }, nil } - if appID == "" || appSecret == "" { - return nil, output.ErrValidation("App ID and App Secret cannot be empty") + switch { + case appID == "" && appSecret == "": + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty"). + WithParam("--app-id") + case appID == "": + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID cannot be empty"). + WithParam("--app-id") + case appSecret == "": + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty"). + WithParam("--app-secret") } return &configInitResult{ @@ -168,10 +177,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor } // Step 1: Request app registration (begin) - httpClient := &http.Client{} + // Use the shared proxy-plugin-aware transport so registration traffic is not + // a bypass of proxy plugin mode. + httpClient := transport.NewHTTPClient(0) authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut) if err != nil { - return nil, output.ErrAuth("app registration failed: %v", err) + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err) } // Step 2: Build and display verification URL + QR code @@ -199,7 +210,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor } result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) if err != nil { - return nil, output.ErrAuth("%v", err) + return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err) } // Step 4: Handle Lark brand special case @@ -208,12 +219,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor // fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant) result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) if err != nil { - return nil, output.ErrAuth("lark endpoint retry failed: %v", err) + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err) } } if result.ClientID == "" || result.ClientSecret == "" { - return nil, output.ErrAuth("app registration succeeded but missing client_id or client_secret") + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret") } // Determine final brand from response diff --git a/cmd/config/init_messages.go b/cmd/config/init_messages.go index 73948ca96..27dbde1f7 100644 --- a/cmd/config/init_messages.go +++ b/cmd/config/init_messages.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/huh" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/i18n" ) type initMsg struct { @@ -26,6 +27,10 @@ type initMsg struct { DetectedLarkTenant string AppCreated string ConfigSaved string + + // LangPreferenceSet is printed to stderr after a successful init when the + // user explicitly passed --lang. Format: language code. + LangPreferenceSet string } var initMsgZh = &initMsg{ @@ -43,6 +48,7 @@ var initMsgZh = &initMsg{ DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...", AppCreated: "应用配置成功! App ID: %s", ConfigSaved: "应用配置成功! App ID: %s", + LangPreferenceSet: "语言偏好已设置:%s", } var initMsgEn = &initMsg{ @@ -60,29 +66,27 @@ var initMsgEn = &initMsg{ DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...", AppCreated: "App configured! App ID: %s", ConfigSaved: "App configured! App ID: %s", + LangPreferenceSet: "Language preference set to: %s", } -func getInitMsg(lang string) *initMsg { - if lang == "en" { +// getInitMsg picks the zh/en TUI bundle; non-English falls back to zh. +func getInitMsg(lang i18n.Lang) *initMsg { + if lang.IsEnglish() { return initMsgEn } return initMsgZh } -// promptLangSelection shows an interactive language picker and returns the chosen lang code. -// savedLang is used as the pre-selected default (from existing config). -func promptLangSelection(savedLang string) (string, error) { - lang := savedLang - if lang != "en" { - lang = "zh" - } +// promptLangSelection shows the 中文/English picker and returns the chosen locale. +func promptLangSelection() (i18n.Lang, error) { + lang := i18n.LangZhCN form := huh.NewForm( huh.NewGroup( - huh.NewSelect[string](). + huh.NewSelect[i18n.Lang](). Title("Language / 语言"). Options( - huh.NewOption("中文", "zh"), - huh.NewOption("English", "en"), + huh.NewOption("中文", i18n.LangZhCN), + huh.NewOption("English", i18n.LangEnUS), ). Value(&lang), ), diff --git a/cmd/config/init_messages_test.go b/cmd/config/init_messages_test.go index 0bdaecf2a..632787ac6 100644 --- a/cmd/config/init_messages_test.go +++ b/cmd/config/init_messages_test.go @@ -6,6 +6,8 @@ package config import ( "fmt" "testing" + + "github.com/larksuite/cli/internal/i18n" ) func TestGetInitMsg_Zh(t *testing.T) { @@ -29,7 +31,7 @@ func TestGetInitMsg_En(t *testing.T) { } func TestGetInitMsg_DefaultsToZh(t *testing.T) { - for _, lang := range []string{"", "fr", "ja", "unknown"} { + for _, lang := range []i18n.Lang{"", "unknown", "xyz", "invalid"} { msg := getInitMsg(lang) if msg != initMsgZh { t.Errorf("getInitMsg(%q) should default to zh", lang) @@ -62,6 +64,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) { "DetectedLarkTenant": msg.DetectedLarkTenant, "AppCreated": msg.AppCreated, "ConfigSaved": msg.ConfigSaved, + "LangPreferenceSet": msg.LangPreferenceSet, } for name, val := range fields { if val == "" { @@ -71,7 +74,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) { } func TestInitMsg_FormatStrings(t *testing.T) { - for _, lang := range []string{"zh", "en"} { + for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} { msg := getInitMsg(lang) // AppCreated and ConfigSaved should contain %s for App ID got := fmt.Sprintf(msg.AppCreated, "cli_test123") @@ -84,3 +87,37 @@ func TestInitMsg_FormatStrings(t *testing.T) { } } } + +func TestGetInitMsg_BilingualCollapse(t *testing.T) { + // The TUI is bilingual (zh + en). Only English-bucket languages return the + // English struct — by canonical locale ("en_us") or legacy short ("en"). + // Everything else (zh, the other codes, invalid, "") returns Chinese. + tests := []struct { + lang i18n.Lang + shouldBeEn bool + }{ + {i18n.LangZhCN, false}, + {i18n.LangEnUS, true}, + {"en", true}, // legacy short value + {i18n.LangJaJP, false}, + {"fr_fr", false}, + {"invalid", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(string(tt.lang), func(t *testing.T) { + msg := getInitMsg(tt.lang) + if msg == nil { + t.Fatal("getInitMsg returned nil") + } + want := initMsgZh + if tt.shouldBeEn { + want = initMsgEn + } + if msg != want { + t.Errorf("getInitMsg(%q) returned wrong struct", tt.lang) + } + }) + } +} diff --git a/cmd/config/init_probe.go b/cmd/config/init_probe.go new file mode 100644 index 000000000..e56aa1445 --- /dev/null +++ b/cmd/config/init_probe.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" +) + +// probeTimeout is the total wall-clock budget for the credential probe step +// (covering both TAT acquisition and the subsequent probe request). +const probeTimeout = 3 * time.Second + +// runProbe runs a best-effort credential validation after config init has +// persisted the App ID and App Secret. It returns a non-nil error only for a +// deterministic credential-rejection signal; every other outcome returns nil +// so that valid configurations and transient/upstream noise never block the +// command. +// +// The function performs up to two HTTP calls in series, bounded by +// probeTimeout: +// +// 1. A TAT request using the just-saved credentials. credential.FetchTAT +// returns a typed errs.* error (via the shared classifyTATResponseCode) +// only when the server deterministically rejected the credentials — a +// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient +// (10003 / 10014) or whatever codemeta maps. That typed error is propagated +// so the root dispatcher renders the canonical envelope and `config init` +// exits non-zero — identical to how every other token-resolving command +// reports the same bad credentials. Ambiguous failures (transport errors, +// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped +// errors and are swallowed (return nil), so valid configurations are never +// disturbed by upstream noise. errs.IsTyped is the discriminator. +// +// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of +// that call (success, server error, timeout, parse failure) is always +// ignored — return nil regardless. +func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error { + if factory == nil { + return nil + } + httpClient, err := factory.HttpClient() + if err != nil { + return nil + } + + ctx, cancel := context.WithTimeout(parent, probeTimeout) + defer cancel() + + token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret) + if err != nil { + // A typed error from FetchTAT is a deterministic credential rejection + // (classifyTATResponseCode). Propagate it so config init exits with the + // same envelope the rest of the CLI uses for bad credentials. Untyped + // errors are ambiguous (transport / HTTP / parse / timeout) — stay + // silent and let the command succeed. + if errs.IsTyped(err) { + return err + } + return nil + } + + // TAT succeeded — fire the probe call. Any outcome is ignored. + url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe" + body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} diff --git a/cmd/config/init_probe_test.go b/cmd/config/init_probe_test.go new file mode 100644 index 000000000..097817d73 --- /dev/null +++ b/cmd/config/init_probe_test.go @@ -0,0 +1,288 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// fakeRT routes requests to per-path handlers and records what it saw. +type fakeRT struct { + tatHandler func(req *http.Request) (*http.Response, error) + probeHandler func(req *http.Request) (*http.Response, error) + tatCalls int + probeCalls int + probeReq *http.Request + probeBody string +} + +func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"): + f.tatCalls++ + if f.tatHandler == nil { + return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil + } + return f.tatHandler(req) + case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"): + f.probeCalls++ + f.probeReq = req + if req.Body != nil { + b, _ := io.ReadAll(req.Body) + f.probeBody = string(b) + } + if f.probeHandler == nil { + return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil + } + return f.probeHandler(req) + } + return nil, errors.New("unexpected URL: " + req.URL.String()) +} + +func jsonResp(code int, body string) *http.Response { + return &http.Response{ + StatusCode: code, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + } +} + +// fakeFactory builds a test Factory whose HttpClient is overridden to use +// the caller-supplied RoundTripper. +// +// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams, +// Credential, Keychain and FileIO wiring is in place (per repo test-factory +// guidance). The HttpClient is then swapped to our stub so we can drive +// exact HTTP responses for the probe. Config-dir isolation is set up via +// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config +// touch lands in a temp dir rather than the developer's real config. +// +// The returned buffer is the Factory's stderr. runProbe never writes to +// stderr (it propagates a typed error or stays silent), so every test asserts +// this buffer stays empty as an invariant. +func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, errBuf, _ := cmdutil.TestFactory(t, nil) + f.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + } + return f, errBuf +} + +// assertConfigRejection asserts runProbe propagated a deterministic credential +// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with +// the expected upstream code. This is the same typed error every other +// token-resolving command returns for the same bad credentials, and nothing is +// written to stderr (the root dispatcher renders the envelope). +func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) { + t.Helper() + if err == nil { + t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode) + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err) + } + if cfgErr.Category != errs.CategoryConfig { + t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig) + } + if cfgErr.Subtype != errs.SubtypeInvalidClient { + t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) + } + if cfgErr.Code != wantCode { + t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode) + } + if errBuf.Len() != 0 { + t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String()) + } +} + +// assertSilent asserts runProbe stayed quiet: no propagated error and nothing +// written to stderr. Used for every ambiguous (non-credential) outcome. +func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) { + t.Helper() + if err != nil { + t.Errorf("expected nil (silent), got error: %v", err) + } + if errBuf.Len() != 0 { + t.Errorf("expected no stderr output, got: %q", errBuf.String()) + } +} + +// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated. +func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) { + rt := &fakeRT{ + tatHandler: func(req *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil + }, + } + f, errBuf := fakeFactory(t, rt) + + err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + + if rt.probeCalls != 0 { + t.Error("probe endpoint must not be called when TAT fails") + } + assertConfigRejection(t, err, errBuf, 10003) +} + +// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta — +// the most common real-world rejection, propagated. +func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) { + rt := &fakeRT{ + tatHandler: func(req *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil + }, + } + f, errBuf := fakeFactory(t, rt) + assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014) +} + +// Any non-zero body code is a deterministic rejection and propagates (typed). +// An unrecognized code falls back to *errs.APIError via BuildAPIError — still +// typed, so the probe still surfaces it rather than swallowing. +func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) { + rt := &fakeRT{ + tatHandler: func(req *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil + }, + } + f, errBuf := fakeFactory(t, rt) + err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + if err == nil || !errs.IsTyped(err) { + t.Fatalf("expected a propagated typed error, got %T: %v", err, err) + } + if errBuf.Len() != 0 { + t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String()) + } +} + +// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential +// rejection) → silent, exit 0. +func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) { + for _, code := range []int{401, 403, 500} { + rt := &fakeRT{ + tatHandler: func(req *http.Request) (*http.Response, error) { + return jsonResp(code, `nope`), nil + }, + } + f, errBuf := fakeFactory(t, rt) + assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf) + } +} + +func TestRunProbe_TATTransportError_Silent(t *testing.T) { + rt := &fakeRT{ + tatHandler: func(req *http.Request) (*http.Response, error) { + return nil, errors.New("network down") + }, + } + f, errBuf := fakeFactory(t, rt) + assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf) +} + +func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) { + rt := &fakeRT{ + probeHandler: func(req *http.Request) (*http.Response, error) { + return jsonResp(500, `server error`), nil + }, + } + f, errBuf := fakeFactory(t, rt) + err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + if rt.probeCalls != 1 { + t.Errorf("probe should be called once, got %d", rt.probeCalls) + } + assertSilent(t, err, errBuf) +} + +func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) { + rt := &fakeRT{} + f, errBuf := fakeFactory(t, rt) + err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + if rt.tatCalls != 1 || rt.probeCalls != 1 { + t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls) + } + assertSilent(t, err, errBuf) +} + +func TestRunProbe_ProbeRequestShape(t *testing.T) { + rt := &fakeRT{} + f, _ := fakeFactory(t, rt) + if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if rt.probeReq == nil { + t.Fatal("probe request not captured") + } + if rt.probeReq.Method != http.MethodPost { + t.Errorf("probe method = %s, want POST", rt.probeReq.Method) + } + if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" { + t.Errorf("probe URL = %s", got) + } + if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" { + t.Errorf("Authorization = %q, want Bearer t-ok", got) + } + if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) { + t.Errorf("probe body missing from field: %s", rt.probeBody) + } +} + +func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) { + rt := &fakeRT{} + f, _ := fakeFactory(t, rt) + if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rt.probeReq == nil { + t.Fatal("probe request not captured") + } + if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") { + t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host) + } +} + +func TestRunProbe_HTTPClientError_Silent(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, errBuf, _ := cmdutil.TestFactory(t, nil) + f.HttpClient = func() (*http.Client, error) { + return nil, errors.New("client init failed") + } + assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf) +} + +func TestRunProbe_TimeoutHonored(t *testing.T) { + rt := &fakeRT{ + tatHandler: func(req *http.Request) (*http.Response, error) { + <-req.Context().Done() + return nil, req.Context().Err() + }, + } + f, errBuf := fakeFactory(t, rt) + + start := time.Now() + err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + elapsed := time.Since(start) + + if elapsed > 4*time.Second { + t.Errorf("runProbe took %v, expected <= ~3s", elapsed) + } + // A timeout is an ambiguous failure (context deadline → untyped), so it + // must stay silent and not block. + assertSilent(t, err, errBuf) +} diff --git a/cmd/config/init_test.go b/cmd/config/init_test.go new file mode 100644 index 000000000..7df9bbee7 --- /dev/null +++ b/cmd/config/init_test.go @@ -0,0 +1,133 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "errors" + "fmt" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each +// must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2: +// SubtypeInvalidClient is reserved for IAM rejection of malformed credentials, +// not for missing user input. + +func TestUpdateExistingProfileWithoutSecret_NilConfig_EmitsValidationError(t *testing.T) { + err := updateExistingProfileWithoutSecret(nil, "", "cli_test", core.BrandFeishu, "en") + assertValidationParam(t, err, "--app-secret") +} + +func TestUpdateExistingProfileWithoutSecret_UnknownProfile_EmitsValidationError(t *testing.T) { + existing := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "default", + AppId: "app-default", + AppSecret: core.PlainSecret("secret-default"), + Brand: core.BrandFeishu, + }}, + } + err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", core.BrandFeishu, "en") + assertValidationParam(t, err, "--app-secret") +} + +func TestUpdateExistingProfileWithoutSecret_NoCurrentApp_EmitsValidationError(t *testing.T) { + existing := &core.MultiAppConfig{ + CurrentApp: "missing", + Apps: []core.AppConfig{{ + Name: "default", + AppId: "app-default", + AppSecret: core.PlainSecret("secret-default"), + Brand: core.BrandFeishu, + }}, + } + err := updateExistingProfileWithoutSecret(existing, "", "cli_test", core.BrandFeishu, "en") + assertValidationParam(t, err, "--app-secret") +} + +func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t *testing.T) { + existing := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "default", + AppId: "app-default", + AppSecret: core.PlainSecret("secret-default"), + Brand: core.BrandFeishu, + }}, + } + err := updateExistingProfileWithoutSecret(existing, "", "cli_different", core.BrandFeishu, "en") + assertValidationParam(t, err, "--app-secret") +} + +// wrapUpdateExistingProfileErr is the caller-side classifier for the error +// returned by updateExistingProfileWithoutSecret. It must preserve typed-error +// exit semantics (regression: typed ValidationError was being downgraded to +// InternalError by the legacy *output.ExitError-only passthrough). + +func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) { + if got := wrapUpdateExistingProfileErr(nil); got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T) { + in := errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile"). + WithParam("--app-secret") + got := wrapUpdateExistingProfileErr(in) + assertValidationParam(t, got, "--app-secret") + // Exit code must remain ExitValidation (2), not ExitInternal (5). + if code := output.ExitCodeOf(got); code != output.ExitValidation { + t.Errorf("ExitCodeOf = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + // Must NOT be wrapped as *InternalError. + var intErr *errs.InternalError + if errors.As(got, &intErr) { + t.Errorf("typed ValidationError was downgraded to *InternalError: %v", got) + } +} + +func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) { + in := &output.ExitError{Code: 7, Err: errors.New("legacy")} + got := wrapUpdateExistingProfileErr(in) + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got) + } + if exitErr.Code != 7 { + t.Errorf("Code = %d, want 7", exitErr.Code) + } +} + +func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) { + in := fmt.Errorf("disk full") + got := wrapUpdateExistingProfileErr(in) + var intErr *errs.InternalError + if !errors.As(got, &intErr) { + t.Fatalf("expected *errs.InternalError, got %T: %v", got, got) + } + if intErr.Subtype != errs.SubtypeSDKError { + t.Errorf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError) + } +} + +// assertValidationParam asserts err is *ValidationError with the given Param. +func assertValidationParam(t *testing.T, err error, wantParam string) { + t.Helper() + if err == nil { + t.Fatal("expected error, got nil") + } + var valErr *errs.ValidationError + if !errors.As(err, &valErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if valErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument) + } + if valErr.Param != wantParam { + t.Errorf("Param = %q, want %q", valErr.Param, wantParam) + } +} diff --git a/cmd/config/keychain_downgrade.go b/cmd/config/keychain_downgrade.go new file mode 100644 index 000000000..cf3551858 --- /dev/null +++ b/cmd/config/keychain_downgrade.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build darwin + +package config + +import ( + "fmt" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// NewCmdConfigKeychainDowngrade creates the macOS-only subcommand that pins +// the master key to the local file fallback (master.key.file) so subsequent +// operations bypass the OS Keychain. Useful inside sandboxes like Codex +// where the system Keychain is unreachable. +func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "keychain-downgrade", + Short: "Downgrade keychain storage to a local file (macOS only)", + Long: `Materialize the master key from the macOS system Keychain into a local file +under ~/Library/Application Support/lark-cli/master.key.file, then pin all +subsequent reads to that file. + +Intended workflow: run this once from an interactive Terminal session on +macOS (where the system Keychain is reachable). After it finishes, +sandboxed / automation / CI runs of lark-cli on the same machine will read +the master key from the local file and no longer need the OS Keychain. + +This is the supported fix for environments like the Codex sandbox where the +system Keychain is blocked. Running keychain-downgrade from inside such a +sandbox will itself fail with "keychain access blocked" — that is expected; +run it from an interactive macOS session instead. + +The OS Keychain entry is preserved as a cold backup; nothing is deleted there. +The command is idempotent: re-running it on an already-downgraded install +reports "already downgraded" and exits 0.`, + RunE: func(cmd *cobra.Command, args []string) error { + return configKeychainDowngradeRun(f) + }, + } + cmdutil.SetRisk(cmd, "write") + return cmd +} + +func configKeychainDowngradeRun(f *cmdutil.Factory) error { + service := keychain.LarkCliService + keyPath := keychain.MasterKeyFilePath(service) + + result, err := keychain.DowngradeMasterKeyToFile(service) + if err != nil { + return errs.NewInternalError(errs.SubtypeSDKError, + "keychain downgrade failed: %v", err). + WithHint("This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design."). + WithCause(err) + } + + switch result { + case keychain.DowngradeAlreadyDone: + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("keychain already downgraded; subsequent operations read from %s", keyPath)) + case keychain.DowngradeUsedKeychainKey: + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("downgraded: copied master key from system Keychain to %s. Subsequent operations will read from file, bypassing the OS Keychain (useful inside sandboxes like Codex).", keyPath)) + case keychain.DowngradeCreatedNewKey: + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("system Keychain was empty; generated a new master key and wrote it to %s. The OS Keychain was not modified.", keyPath)) + } + return nil +} diff --git a/cmd/config/keychain_downgrade_other.go b/cmd/config/keychain_downgrade_other.go new file mode 100644 index 000000000..afa1563ce --- /dev/null +++ b/cmd/config/keychain_downgrade_other.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !darwin + +package config + +import ( + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdConfigKeychainDowngrade is registered on all platforms so that +// `lark-cli config --help` reads the same everywhere. On non-macOS it +// refuses with a clear message. +func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command { + _ = f + cmd := &cobra.Command{ + Use: "keychain-downgrade", + Short: "Downgrade keychain storage to a local file (macOS only)", + Long: `Downgrade keychain storage to a local file. This subcommand is only supported on macOS; on this platform the keychain layer already uses local files.`, + RunE: func(cmd *cobra.Command, args []string) error { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "keychain-downgrade is only supported on macOS") + }, + } + return cmd +} diff --git a/cmd/config/plugins.go b/cmd/config/plugins.go index a50d47075..fc1738337 100644 --- a/cmd/config/plugins.go +++ b/cmd/config/plugins.go @@ -82,8 +82,8 @@ func runConfigPluginsShow(f *cmdutil.Factory) error { "version": p.Version, "capabilities": p.Capabilities, } - if p.Rule != nil { - entry["rule"] = p.Rule + if len(p.Rules) > 0 { + entry["rules"] = p.Rules } entry["hooks"] = map[string]any{ "observers": p.Observers, diff --git a/cmd/config/policy.go b/cmd/config/policy.go index 78f2b10a7..16217c4e1 100644 --- a/cmd/config/policy.go +++ b/cmd/config/policy.go @@ -59,16 +59,20 @@ func runConfigPolicyShow(f *cmdutil.Factory) error { "source_name": sourceName, "denied_paths": active.DeniedPaths, } - if active.Rule != nil { - out["rule"] = map[string]any{ - "name": active.Rule.Name, - "description": active.Rule.Description, - "allow": active.Rule.Allow, - "deny": active.Rule.Deny, - "max_risk": active.Rule.MaxRisk, - "identities": active.Rule.Identities, - "allow_unannotated": active.Rule.AllowUnannotated, + if len(active.Rules) > 0 { + rules := make([]map[string]any, 0, len(active.Rules)) + for _, r := range active.Rules { + rules = append(rules, map[string]any{ + "name": r.Name, + "description": r.Description, + "allow": r.Allow, + "deny": r.Deny, + "max_risk": r.MaxRisk, + "identities": r.Identities, + "allow_unannotated": r.AllowUnannotated, + }) } + out["rules"] = rules } output.PrintJson(f.IOStreams.Out, out) return nil diff --git a/cmd/config/policy_test.go b/cmd/config/policy_test.go index 05d8a180b..502d6b2a6 100644 --- a/cmd/config/policy_test.go +++ b/cmd/config/policy_test.go @@ -57,7 +57,7 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) { MaxRisk: "read", } cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{ - Rule: rule, + Rules: []*platform.Rule{rule}, Source: cmdpolicy.ResolveSource{ Kind: cmdpolicy.SourcePlugin, Name: "secaudit", @@ -83,12 +83,16 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) { if got["denied_paths"] != float64(42) { t.Errorf("denied_paths = %v, want 42", got["denied_paths"]) } - ruleMap, ok := got["rule"].(map[string]any) + rulesAny, ok := got["rules"].([]any) + if !ok || len(rulesAny) != 1 { + t.Fatalf("rules field missing or wrong shape: %v", got["rules"]) + } + ruleMap, ok := rulesAny[0].(map[string]any) if !ok { - t.Fatalf("rule field missing or wrong type") + t.Fatalf("rules[0] wrong type") } if ruleMap["name"] != "secaudit" { - t.Errorf("rule.name = %v", ruleMap["name"]) + t.Errorf("rules[0].name = %v", ruleMap["name"]) } } @@ -101,7 +105,7 @@ func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) { t.Cleanup(cmdpolicy.ResetActiveForTesting) cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{ - Rule: &platform.Rule{Name: "my-yaml-rule"}, + Rules: []*platform.Rule{{Name: "my-yaml-rule"}}, Source: cmdpolicy.ResolveSource{ Kind: cmdpolicy.SourceYAML, Name: "/Users/alice/.lark-cli/policy.yml", diff --git a/cmd/config/remove.go b/cmd/config/remove.go index 324f7e58c..74dd0e847 100644 --- a/cmd/config/remove.go +++ b/cmd/config/remove.go @@ -6,6 +6,7 @@ package config import ( "fmt" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -42,14 +43,14 @@ func configRemoveRun(opts *ConfigRemoveOptions) error { config, err := core.LoadMultiAppConfig() if err != nil || config == nil || len(config.Apps) == 0 { - return output.ErrValidation("not configured yet") + return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured yet") } // Save empty config first. If this fails, keep secrets and tokens intact so the // existing config can still be retried instead of ending up half-removed. empty := &core.MultiAppConfig{Apps: []core.AppConfig{}} if err := core.SaveMultiAppConfig(empty); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } // Clean up keychain entries for all apps after config is cleared. diff --git a/cmd/config/show.go b/cmd/config/show.go index 1f0f12ffc..5526f0254 100644 --- a/cmd/config/show.go +++ b/cmd/config/show.go @@ -9,6 +9,7 @@ import ( "os" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" @@ -47,14 +48,14 @@ func configShowRun(opts *ConfigShowOptions) error { if errors.Is(err, os.ErrNotExist) { return core.NotConfiguredError() } - return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err) + return errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err) } if config == nil || len(config.Apps) == 0 { return core.NotConfiguredError() } app := config.CurrentAppConfig(f.Invocation.Profile) if app == nil { - return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli profile list") + return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list") } users := "(no logged-in users)" if len(app.Users) > 0 { diff --git a/cmd/config/strict_mode.go b/cmd/config/strict_mode.go index 6bac82424..46610585a 100644 --- a/cmd/config/strict_mode.go +++ b/cmd/config/strict_mode.go @@ -7,9 +7,9 @@ import ( "context" "fmt" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" ) @@ -73,14 +73,14 @@ explicit user confirmation — never run on your own initiative.`, func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error { if global { - return output.ErrValidation("--reset cannot be used with --global") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with --global").WithParam("--reset") } if len(args) > 0 { - return output.ErrValidation("--reset cannot be used with a value argument") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with a value argument").WithParam("--reset") } app.StrictMode = nil if err := core.SaveMultiAppConfig(multi); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)") return nil @@ -104,7 +104,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App switch mode { case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff: default: - return output.ErrValidation("invalid value %q, valid values: bot | user | off", value) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid value %q, valid values: bot | user | off", value) } // Capture the old mode at the SAME scope being changed, so we can warn @@ -144,7 +144,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App } if err := core.SaveMultiAppConfig(multi); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) { diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index 9314ebfc9..2a5265b12 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -19,6 +19,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/identitydiag" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/update" ) @@ -152,7 +153,9 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) } } - httpClient := &http.Client{} + // Use the shared proxy-plugin-aware transport so connectivity checks reflect + // the real egress path (and are blocked when proxy plugin fails closed). + httpClient := transport.NewHTTPClient(0) mcpURL := ep.MCP + "/mcp" type probeResult struct { diff --git a/cmd/error_auth_hint.go b/cmd/error_auth_hint.go index 937194431..2b7402144 100644 --- a/cmd/error_auth_hint.go +++ b/cmd/error_auth_hint.go @@ -23,12 +23,8 @@ import ( // applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a // "current command requires scope(s): X, Y" hint when the underlying error is // a need_user_authorization signal AND the current command declares scopes -// locally (via shortcut registration or service-method metadata). -// -// Stage-1: this typed path is dormant — no production code returns a typed -// *errs.AuthenticationError. Kept so per-domain stage-2 migrations can plug -// in without re-architecting. The active stage-1 path is -// enrichMissingScopeError below, which operates on legacy *output.ExitError. +// locally (via shortcut registration or service-method metadata). Existing +// Hint text is preserved; scopes are appended on a new line. func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) { if err == nil || f == nil { return @@ -55,12 +51,10 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) { // enrichMissingScopeError appends a "current command requires scope(s): X" // hint to a legacy *output.ExitError when the underlying error carries the // need_user_authorization marker AND the current command declares scopes -// locally. Matches pre-PR behaviour byte-for-byte; lives on the legacy -// envelope path until per-domain stage-2 typed migration. +// locally. // -// Deprecated: stage-1 enrichment for the legacy *output.ExitError surface. -// Stage-2 typed migration will lift this into AuthenticationError.Hint on -// the typed envelope via applyNeedAuthorizationHint and remove this helper. +// Deprecated: enrichment for the legacy envelope; the typed path is +// applyNeedAuthorizationHint above. func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) { if exitErr == nil || exitErr.Detail == nil { return @@ -155,47 +149,7 @@ func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []s if methodMap == nil { return nil } - return declaredScopesForMethod(methodMap, identity) -} - -// declaredScopesForMethod returns all requiredScopes when present; otherwise it -// resolves the single recommended scope from the method's scopes list. -func declaredScopesForMethod(method map[string]interface{}, identity string) []string { - if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 { - return interfaceStrings(requiredRaw) - } - - rawScopes, _ := method["scopes"].([]interface{}) - if len(rawScopes) == 0 { - return nil - } - recommended := registry.SelectRecommendedScope(rawScopes, identity) - if recommended == "" { - for _, raw := range rawScopes { - if scope, ok := raw.(string); ok && scope != "" { - recommended = scope - break - } - } - } - if recommended == "" { - return nil - } - return []string{recommended} -} - -// interfaceStrings converts a []interface{} containing strings into a compact -// []string, skipping empty or non-string values. -func interfaceStrings(values []interface{}) []string { - scopes := make([]string, 0, len(values)) - for _, value := range values { - scope, ok := value.(string) - if !ok || scope == "" { - continue - } - scopes = append(scopes, scope) - } - return scopes + return registry.DeclaredScopesForMethod(methodMap, identity) } // shortcutSupportsIdentity reports whether a shortcut supports the requested diff --git a/cmd/platform_bootstrap.go b/cmd/platform_bootstrap.go index ef2ac6b73..9b1b57ac3 100644 --- a/cmd/platform_bootstrap.go +++ b/cmd/platform_bootstrap.go @@ -36,47 +36,71 @@ const userPolicyFileName = "policy.yml" // pluginRules carries Plugin.Restrict() contributions collected from // the InstallAll phase; nil/empty is fine. func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error { - yamlPath, err := userPolicyPath() - if err != nil { - // No user home dir means we cannot locate the policy. Treat - // the same as "file missing": no pruning, no error. This keeps - // non-interactive CI environments (no HOME set) running. - yamlPath = "" + // Plugin rules shadow the yaml source entirely (Resolve: plugin > + // yaml). When a plugin contributed rules we therefore do NOT even + // read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy + // error once a plugin is present, so reading a malformed yaml here + // would let an unrelated broken file on the user's machine abort a + // plugin-governed binary -- exactly the file the plugin is supposed + // to shadow. Skipping the read keeps the shadow contract honest. + var ( + yamlRules []*platform.Rule + yamlPath string + ) + if len(pluginRules) == 0 { + p, perr := userPolicyPath() + if perr != nil { + // No user home dir means we cannot locate the policy. Treat + // the same as "file missing": no pruning, no error. This keeps + // non-interactive CI environments (no HOME set) running. + p = "" + } + yamlPath = p + loaded, lerr := cmdpolicy.LoadYAMLPolicy(yamlPath) + if lerr != nil { + // Yaml-only failures are fail-OPEN at the caller (warn and + // continue), but the active-policy snapshot is process-global + // and may still carry data from a previous build in long-lived + // embedders / tests. Clear it explicitly so `config policy + // show` reports "no policy" instead of a stale rule that + // doesn't reflect the current command tree. + cmdpolicy.SetActive(nil) + return lerr + } + yamlRules = loaded } - yamlRule, err := cmdpolicy.LoadYAMLPolicy(yamlPath) - if err != nil { - // Yaml-only failures are fail-OPEN at the caller (warn and - // continue), but the active-policy snapshot is process-global - // and may still carry data from a previous build in long-lived - // embedders / tests. Clear it explicitly so `config policy - // show` reports "no policy" instead of a stale rule that - // doesn't reflect the current command tree. - cmdpolicy.SetActive(nil) - return err - } - - rule, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{ + rules, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{ PluginRules: pluginRules, - YAMLRule: yamlRule, + YAMLRules: yamlRules, YAMLPath: yamlPath, }) if err != nil { cmdpolicy.SetActive(nil) return err } - if rule == nil { + if len(rules) == 0 { cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source}) return nil } - engine := cmdpolicy.New(rule) + // RuleName attributes a denial to a specific rule in the envelope. + // With a single rule that is unambiguous and preserves the legacy + // envelope verbatim; with several rules a denial means "no rule + // granted it", which has no single owner, so the field is left empty + // and reason_code=no_matching_rule carries the meaning instead. + ruleName := "" + if len(rules) == 1 { + ruleName = rules[0].Name + } + + engine := cmdpolicy.NewSet(rules) decisions := engine.EvaluateAll(rootCmd) - denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name) + denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName) cmdpolicy.Apply(rootCmd, denied) cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{ - Rule: rule, + Rules: rules, Source: source, DeniedPaths: len(denied), }) diff --git a/cmd/platform_bootstrap_test.go b/cmd/platform_bootstrap_test.go index 4fe814453..308eb1636 100644 --- a/cmd/platform_bootstrap_test.go +++ b/cmd/platform_bootstrap_test.go @@ -13,6 +13,8 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/output" ) @@ -184,6 +186,39 @@ func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) { } } +// When a plugin contributed rules, a malformed user policy.yml must NOT +// abort: plugin rules shadow yaml entirely, so the broken file is never +// read. Regression -- previously LoadYAMLPolicy ran first and an +// unrelated broken yaml on the user's machine could fatal a +// plugin-governed binary (build.go fail-CLOSES on policy errors when a +// plugin is present). +func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) { + cfgDir := tmpHome(t) + t.Cleanup(cmdpolicy.ResetActiveForTesting) + writePolicy(t, cfgDir, "::: not yaml :::") // broken on purpose + + pluginRules := []cmdpolicy.PluginRule{ + {PluginName: "secaudit", Rule: &platform.Rule{ + Name: "docs-only", + Allow: []string{"docs/**"}, + MaxRisk: "write", + }}, + } + root := fakeTree(t) + if err := applyUserPolicyPruning(root, pluginRules); err != nil { + t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err) + } + + // Plugin rule actually applied: im/+send is outside docs/** -> hidden. + if send := findLeaf(t, root, "im", "+send"); !send.Hidden { + t.Errorf("im/+send should be hidden by plugin rule (not in docs/** allow)") + } + // docs/+update is within allow and at/below max_risk -> stays visible. + if update := findLeaf(t, root, "docs", "+update"); update.Hidden { + t.Errorf("docs/+update should remain visible under plugin rule") + } +} + // Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside // Resolve and produces an error. This is the safety contract: a typo in // the rule must not silently lower the pruning bar. diff --git a/cmd/profile/add.go b/cmd/profile/add.go index a657bccb9..e05946d62 100644 --- a/cmd/profile/add.go +++ b/cmd/profile/add.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" ) @@ -40,7 +41,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)") cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin") cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark") - cmd.Flags().StringVar(&lang, "lang", "zh", "language for interactive prompts (zh or en)") + cmd.Flags().StringVar(&lang, "lang", "", "language preference (e.g. zh or zh_cn)") cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding") _ = cmd.MarkFlagRequired("name") @@ -55,6 +56,12 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, return output.ErrValidation("%v", err) } + langPref, err := cmdutil.ParseLangFlag(lang) + if err != nil { + return err + } + lang = string(langPref) + // Read secret from stdin if !appSecretStdin { return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret") @@ -115,7 +122,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, AppId: appID, AppSecret: secret, Brand: parsedBrand, - Lang: lang, + Lang: i18n.Lang(lang), Users: []core.AppUser{}, }) diff --git a/cmd/profile/profile_test.go b/cmd/profile/profile_test.go index 83667d554..3cd724720 100644 --- a/cmd/profile/profile_test.go +++ b/cmd/profile/profile_test.go @@ -13,6 +13,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/vfs" ) @@ -51,6 +52,56 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) { } } +// TestProfileAddRun_Lang covers the unified --lang contract on profile add: +// short codes and Feishu locales both canonicalize to the same stored locale, +// empty stores no preference, and an unrecognized value errors. +func TestProfileAddRun_Lang(t *testing.T) { + t.Run("short and locale canonicalize and persist alike", func(t *testing.T) { + for _, in := range []string{"ja", "ja_jp"} { + setupProfileConfigDir(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.IOStreams.In = strings.NewReader("secret\n") + if err := profileAddRun(f, "p", "app-p", true, "feishu", in, false); err != nil { + t.Fatalf("--lang %q: profileAddRun() error = %v", in, err) + } + saved, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig() error = %v", err) + } + if app := saved.FindApp("p"); app == nil || app.Lang != i18n.LangJaJP { + t.Errorf("--lang %q: stored Lang = %v, want %q", in, app, i18n.LangJaJP) + } + } + }) + + t.Run("empty stores no preference", func(t *testing.T) { + setupProfileConfigDir(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.IOStreams.In = strings.NewReader("secret\n") + if err := profileAddRun(f, "p", "app-p", true, "feishu", "", false); err != nil { + t.Fatalf("profileAddRun() error = %v", err) + } + saved, _ := core.LoadMultiAppConfig() + if app := saved.FindApp("p"); app == nil || app.Lang != "" { + t.Errorf("stored Lang = %v, want \"\" (unset)", app) + } + }) + + t.Run("invalid lang errors", func(t *testing.T) { + setupProfileConfigDir(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.IOStreams.In = strings.NewReader("secret\n") + err := profileAddRun(f, "p", "app-p", true, "feishu", "ZH", false) + if err == nil { + t.Fatal("expected validation error for --lang ZH, got nil") + } + exitErr, ok := err.(*output.ExitError) + if !ok || exitErr.Code != output.ExitValidation { + t.Fatalf("expected ExitValidation, got %T: %v", err, err) + } + }) +} + func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) { setupProfileConfigDir(t) multi := &core.MultiAppConfig{ diff --git a/cmd/root.go b/cmd/root.go index 5e6dd5dd0..11fdf0a5f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,23 +4,22 @@ package cmd import ( - "bytes" "context" - "encoding/json" "errors" "fmt" - "io" "os" "sort" - "strconv" "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/platform" + internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errclass" + "github.com/larksuite/cli/internal/errcompat" "github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" @@ -49,20 +48,6 @@ EXAMPLES: # Generic API call lark-cli api GET /open-apis/calendar/v4/calendars -FLAGS: - --params URL/query parameters JSON - --data request body JSON (POST/PATCH/PUT/DELETE) - --as identity type: user | bot - --format output format: json (default) | ndjson | table | csv | pretty - --page-all automatically paginate through all pages - --page-size page size (0 = use API default) - --page-limit max pages to fetch with --page-all (default: 10, 0 for unlimited) - --page-delay delay in ms between pages (default: 200, only with --page-all) - -o, --output output file path for binary responses - --jq jq expression to filter JSON output - -q shorthand for --jq - --dry-run print request without executing - AI AGENT SKILLS: lark-cli pairs with AI agent skills (Claude Code, etc.) that teach the agent Lark API patterns, best practices, and workflows. @@ -201,43 +186,66 @@ func configureFlagCompletions(args []string) { // and returns the process exit code. // // Dispatch order: -// 1. *errs.SecurityPolicyError: keeps the legacy custom envelope -// (type=auth_error, string code, retryable, challenge_url) and exit 1. -// Carve-out from the typed taxonomy — wire migration deferred to a later PR. -// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError): -// render via the typed envelope writer, which lifts extension fields -// (missing_scopes, console_url, ...) to the top level. Routed by +// 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError) +// are promoted via errcompat to their typed errs/ counterparts, with the +// original preserved in the Cause chain. +// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError, +// *errs.SecurityPolicyError, *errs.AuthenticationError): render via the +// typed envelope writer, which lifts extension fields (missing_scopes, +// console_url, challenge_url, ...) to the top level. Routed by // errs.CategoryOf via ExitCodeOf. -// 3. *core.ConfigError + Legacy *output.ExitError: asExitError adapts them -// to a legacy envelope; written via WriteErrorEnvelope. Stage-1 keeps -// this path so existing wire shapes are preserved byte-for-byte until -// per-domain typed migration in stage 2+. +// 3. Legacy *output.ExitError: asExitError adapts it to the legacy +// envelope, written via WriteErrorEnvelope. // 4. Cobra errors (required flags, unknown commands, etc.): plain text. func handleRootError(f *cmdutil.Factory, err error) int { errOut := f.IOStreams.ErrOut - // SecurityPolicyError keeps the legacy custom envelope (string codes, - // challenge_url, retryable) and exit code 1 — its wire shape predates the - // typed taxonomy and downstream OAuth/policy consumers depend on it. - // The taxonomy migration for this category is deferred to a later PR. - var spErr *errs.SecurityPolicyError - if errors.As(err, &spErr) { - writeSecurityPolicyError(errOut, spErr) - return 1 + // Promote legacy error shapes into typed errs/ before envelope marshal. + // NeedAuthorizationError check is first because it is the more specific + // shape; *core.ConfigError check follows. errors.As preserves the original + // in the Cause chain, so external errors.As(&core.ConfigError{}) consumers + // (cmd/auth/list.go, cmd/doctor/doctor.go, ...) still match. + // + // Outer-typed short-circuit: if err is already a typed *errs.* error, + // skip PromoteXxxError so the producer's Subtype / Hint / extension + // fields are not overwritten by a coarser promoted shape derived from a + // legacy error buried in its Cause chain. Promotion is only for legacy + // untyped entry points. + if !isOuterTypedError(err) { + var needAuthErr *internalauth.NeedAuthorizationError + if errors.As(err, &needAuthErr) { + err = errcompat.PromoteAuthError(needAuthErr) + } else { + var cfgErr *core.ConfigError + if errors.As(err, &cfgErr) { + err = errcompat.PromoteConfigError(cfgErr) + } + } } - // *core.ConfigError flows raw to the legacy envelope path in stage 1 - // (asExitError → output.ErrWithHint). Typed migration via - // errcompat.PromoteConfigError happens in stage 2+. - // When the typed error is a need_user_authorization signal, fold in the // current command's declared scopes as a Hint so the user/AI sees the // concrete scope(s) to re-auth with. The hint is computed on the fly from // local shortcut/service metadata — it never depends on server state. applyNeedAuthorizationHint(f, err) + // Staged dispatch: capture the typed exit code BEFORE attempting the + // envelope write. WriteTypedErrorEnvelope is best-effort on the wire + // (partial-write still returns true) so the exit code we read here is + // preserved even if stderr is torn — torn stderr must not downgrade + // typed exits 3/4/6/10 to the legacy "Error:" path with exit 1. + // WriteTypedErrorEnvelope still returns false when err carries no + // Problem; in that case we fall through to the legacy bridge below. + typedExit := output.ExitCodeOf(err) if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) { - return output.ExitCodeOf(err) + return typedExit + } + + // Partial-failure (batch / multi-status): the ok:false result envelope is + // already on stdout; set the exit code and write nothing to stderr. + var pfErr *output.PartialFailureError + if errors.As(err, &pfErr) { + return pfErr.Code } if exitErr := asExitError(err); exitErr != nil { @@ -256,52 +264,19 @@ func handleRootError(f *cmdutil.Factory, err error) int { return 1 } -// writeSecurityPolicyError writes the security-policy-specific JSON envelope. -// This wire format intentionally differs from the typed envelope writer: it -// uses string codes ("challenge_required"/"access_denied"), a "auth_error" -// type literal, and a top-level "retryable" field — the shape OAuth/policy -// consumers have been parsing since before the typed taxonomy existed. -func writeSecurityPolicyError(w io.Writer, spErr *errs.SecurityPolicyError) { - var codeStr string - switch spErr.Subtype { - case errs.SubtypeChallengeRequired: - codeStr = "challenge_required" - case errs.SubtypeAccessDenied: - codeStr = "access_denied" - default: - codeStr = strconv.Itoa(spErr.Code) - } - - errData := map[string]interface{}{ - "type": "auth_error", - "code": codeStr, - "message": spErr.Message, - "retryable": false, - } - if spErr.ChallengeURL != "" { - errData["challenge_url"] = spErr.ChallengeURL - } - if spErr.Hint != "" { - errData["hint"] = spErr.Hint - } - - env := map[string]interface{}{"ok": false, "error": errData} - - buffer := &bytes.Buffer{} - encoder := json.NewEncoder(buffer) - encoder.SetEscapeHTML(false) - encoder.SetIndent("", " ") - if encErr := encoder.Encode(env); encErr != nil { - fmt.Fprintln(w, `{"ok":false,"error":{"type":"internal_error","code":"marshal_error","message":"failed to marshal error"}}`) - return - } - fmt.Fprint(w, buffer.String()) +// isOuterTypedError returns true if err is a typed *errs.* error AT THE +// TOP OF THE CHAIN (not buried inside Unwrap). Used by handleRootError +// to gate PromoteXxxError so a producer's outer typed envelope is never +// overwritten by a coarser shape derived from its legacy Cause. +func isOuterTypedError(err error) bool { + _, ok := err.(errs.TypedError) + return ok } // asExitError converts known structured error types to *output.ExitError. // Returns nil for unrecognized errors (e.g. cobra flag errors). // -// Deprecated: legacy *output.ExitError bridge; removed after typed migration. +// Deprecated: legacy *output.ExitError bridge. func asExitError(err error) *output.ExitError { var cfgErr *core.ConfigError if errors.As(err, &cfgErr) { @@ -417,65 +392,55 @@ func installTipsHelpFunc(root *cobra.Command) { }) } -// enrichPermissionError adds console_url and improves the hint for legacy -// *output.ExitError permission errors. Differentiates between: -// - LarkErrAppScopeNotEnabled (99991672): app has not enabled the scope -// - LarkErrUserScopeInsufficient (99991679) / LarkErrUserNotAuthorized: -// user has not authorized the scope → hint to auth login -// - default: other permission errors → console + auth-login fallback +// enrichPermissionError rewrites the legacy *output.ExitError envelope so its +// Message + Hint match the per-subtype canonical text produced by the typed +// dispatcher path (errclass.CanonicalPermissionMessage / errclass.PermissionHint). +// This guarantees a caller observing the wire envelope cannot tell whether +// the error reached the dispatcher via the legacy *ExitError bridge or via +// the typed *errs.PermissionError fast path. // -// Deprecated: stage-1 enrichment for the legacy *output.ExitError envelope. -// Stage-2 typed migration will lift this into PermissionError.MissingScopes -// + ConsoleURL on the typed envelope and remove this helper. +// Deprecated: legacy *output.ExitError enrichment; typed PermissionError +// values produced by errclass.BuildAPIError already carry MissingScopes + +// ConsoleURL directly. func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) { - if exitErr.Detail == nil || exitErr.Detail.Type != "permission" { + if exitErr.Detail == nil { return } - // Extract required scopes from API error detail (shared helper) - scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail) - if len(scopes) == 0 { + // Only the legacy permission-class envelope types route here. "app_status" + // covers 99991662 (app_disabled) / 99991673 (app_unavailable); "permission" + // covers the four scope-class codes (99991672 / 99991676 / 99991679 / 230027). + if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "app_status" { return } + larkCode := exitErr.Detail.Code + meta, ok := errclass.LookupCodeMeta(larkCode) + if !ok || meta.Category != errs.CategoryAuthorization { + return + } + + // Extract required scopes from API error detail (shared helper). May be + // empty for app-status codes — canonical message + hint still apply. + missing := registry.ExtractRequiredScopes(exitErr.Detail.Detail) + cfg, err := f.Config() if err != nil { return } - // Select the recommended (least-privilege) scope - recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant") - - // Build admin console URL with the recommended scope - consoleURL := registry.BuildConsoleScopeURL(cfg.Brand, cfg.AppID, recommended) + // Reuse the same console URL builder as the typed path so both wire + // envelopes carry identical console_url values for the same input. + consoleURL := errclass.ConsoleURL(string(cfg.Brand), cfg.AppID, missing) // Clear raw API detail — useful info is now in message/hint/console_url. exitErr.Detail.Detail = nil - isBot := f.ResolvedIdentity.IsBot() - larkCode := exitErr.Detail.Code - switch larkCode { - case output.LarkErrUserScopeInsufficient, output.LarkErrUserNotAuthorized: - exitErr.Detail.Message = fmt.Sprintf("User not authorized: required scope %s [%d]", recommended, larkCode) - if isBot { - exitErr.Detail.Hint = "enable the scope in developer console (see console_url)" - } else { - exitErr.Detail.Hint = fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended) - } - exitErr.Detail.ConsoleURL = consoleURL - - case output.LarkErrAppScopeNotEnabled: - exitErr.Detail.Message = fmt.Sprintf("App scope not enabled: required scope %s [%d]", recommended, larkCode) - exitErr.Detail.Hint = "enable the scope in developer console (see console_url)" - exitErr.Detail.ConsoleURL = consoleURL - - default: - exitErr.Detail.Message = fmt.Sprintf("Permission denied: required scope %s [%d]", recommended, larkCode) - if isBot { - exitErr.Detail.Hint = "enable the scope in developer console (see console_url)" - } else { - exitErr.Detail.Hint = fmt.Sprintf( - "enable scope in console (see console_url), or run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended) - } - exitErr.Detail.ConsoleURL = consoleURL + identity := string(f.ResolvedIdentity) + if identity == "" { + identity = "user" } + + exitErr.Detail.Message = errclass.CanonicalPermissionMessage(meta.Subtype, cfg.AppID, missing, exitErr.Detail.Message) + exitErr.Detail.Hint = errclass.PermissionHint(missing, identity, meta.Subtype, consoleURL) + exitErr.Detail.ConsoleURL = consoleURL } diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index d19fec034..104ae4e8a 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -281,7 +281,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn OK: false, Identity: "bot", Error: &output.ErrDetail{ - Type: "command_denied", + Type: "validation", Message: `strict mode is "user", only user-identity commands are available`, Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", }, @@ -300,7 +300,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv OK: false, Identity: "user", Error: &output.ErrDetail{ - Type: "command_denied", + Type: "validation", Message: `strict mode is "bot", only bot-identity commands are available`, Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", }, @@ -345,7 +345,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop OK: false, Identity: "user", Error: &output.ErrDetail{ - Type: "command_denied", + Type: "validation", Message: `strict mode is "bot", only bot-identity commands are available`, Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", }, @@ -384,11 +384,8 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) { }) } -// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp -// produces no skills key in the composed notice. Users who installed -// skills via `npx skills add` (no stamp) must not see the misleading -// "not installed" notice — only `lark-cli update` users opt into the -// drift tracker. +// TestSetupNotices_ColdStart_NoNotice verifies that missing state +// produces no skills key in the composed notice. func TestSetupNotices_ColdStart_NoNotice(t *testing.T) { clearNoticeEnv(t) dir := t.TempDir() @@ -419,13 +416,13 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) { } } -// TestSetupNotices_InSync verifies that a matching stamp produces no +// TestSetupNotices_InSync verifies that matching state produces no // skills key in the composed notice. func TestSetupNotices_InSync(t *testing.T) { clearNoticeEnv(t) dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.21"); err != nil { + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil { t.Fatal(err) } @@ -452,13 +449,13 @@ func TestSetupNotices_InSync(t *testing.T) { } } -// TestSetupNotices_Drift verifies a mismatching stamp produces the +// TestSetupNotices_Drift verifies mismatching state produces the // drift message with both current and target populated. func TestSetupNotices_Drift(t *testing.T) { clearNoticeEnv(t) dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.20"); err != nil { + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil { t.Fatal(err) } @@ -507,7 +504,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) { clearNoticeEnv(t) dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.20"); err != nil { + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil { t.Fatal(err) } diff --git a/cmd/root_test.go b/cmd/root_test.go index f2d437e96..3ab78ceb2 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -7,6 +7,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "strings" "testing" @@ -20,6 +21,7 @@ import ( internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" ) @@ -137,81 +139,96 @@ func TestIsCompletionCommand(t *testing.T) { // TestPromoteConfigError_* lives with the implementation in // internal/errcompat/promote_test.go. -// TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope pins the carve-out -// for *errs.SecurityPolicyError: it does NOT go through the typed envelope -// writer. Downstream OAuth/policy consumers parse a wire format that -// predates the typed taxonomy and depend on: -// - error.type == "auth_error" (not the Category literal "policy") -// - error.code is a string ("challenge_required" / "access_denied"), not a number -// - error.retryable is present at the top of the error object -// - exit code 1 (not ExitContentSafety 6) -// -// Migration of this category to the typed envelope is deferred to a later PR. -func TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope(t *testing.T) { +// TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that +// *errs.SecurityPolicyError flows through the canonical typed envelope +// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype, +// top-level identity, exit code 6 — after the dispatcher carve-out is removed. +func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - cases := []struct { - name string - subtype errs.Subtype - code int - wantCode string - }{ - {"challenge_required", errs.SubtypeChallengeRequired, 21000, "challenge_required"}, - {"access_denied", errs.SubtypeAccessDenied, 21001, "access_denied"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, nil) - errOut := &bytes.Buffer{} - f.IOStreams.ErrOut = errOut + t.Run("21000 challenge_required", func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + errOut := &bytes.Buffer{} + f.IOStreams.ErrOut = errOut - spErr := &errs.SecurityPolicyError{ - Problem: errs.Problem{ - Category: errs.CategoryPolicy, - Subtype: tc.subtype, - Code: tc.code, - Message: "blocked by access policy", - Hint: "complete challenge in your browser", - }, - ChallengeURL: "https://example.com/challenge", - } + spErr := &errs.SecurityPolicyError{ + Problem: errs.Problem{ + Category: errs.CategoryPolicy, + Subtype: errs.SubtypeChallengeRequired, + Code: 21000, + Message: "blocked by access policy", + Hint: "complete challenge in your browser", + }, + ChallengeURL: "https://example.com/challenge", + } - gotExit := handleRootError(f, spErr) - if gotExit != 1 { - t.Errorf("exit code = %d, want 1 (legacy carve-out)", gotExit) - } + gotExit := handleRootError(f, spErr) + if gotExit != int(output.ExitContentSafety) { + t.Errorf("exit code = %d, want %d (ExitContentSafety)", gotExit, output.ExitContentSafety) + } - var env map[string]any - if err := json.Unmarshal(errOut.Bytes(), &env); err != nil { - t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String()) - } - errObj, ok := env["error"].(map[string]any) - if !ok { - t.Fatalf("envelope missing top-level error object: %s", errOut.String()) - } - if got := errObj["type"]; got != "auth_error" { - t.Errorf("error.type = %v, want %q", got, "auth_error") - } - if got := errObj["code"]; got != tc.wantCode { - t.Errorf("error.code = %v (%T), want %q (string)", got, got, tc.wantCode) - } - if got, ok := errObj["retryable"].(bool); !ok || got { - t.Errorf("error.retryable = %v (%T), want false (bool)", errObj["retryable"], errObj["retryable"]) - } - if got := errObj["challenge_url"]; got != "https://example.com/challenge" { - t.Errorf("error.challenge_url = %v, want challenge url", got) - } - if got := errObj["hint"]; got != "complete challenge in your browser" { - t.Errorf("error.hint = %v, want hint message", got) - } - // And the typed-only fields must NOT appear on this envelope. - for _, leaked := range []string{"subtype", "missing_scopes", "console_url"} { - if _, exists := errObj[leaked]; exists { - t.Errorf("error.%s leaked into legacy security envelope: %v", leaked, errObj[leaked]) - } - } - }) - } + var env map[string]any + if err := json.Unmarshal(errOut.Bytes(), &env); err != nil { + t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String()) + } + errObj, ok := env["error"].(map[string]any) + if !ok { + t.Fatalf("envelope missing top-level error object: %s", errOut.String()) + } + if got := errObj["type"]; got != "policy" { + t.Errorf("error.type = %v, want %q", got, "policy") + } + if got := errObj["subtype"]; got != "challenge_required" { + t.Errorf("error.subtype = %v, want %q", got, "challenge_required") + } + if got, ok := errObj["code"].(float64); !ok || int(got) != 21000 { + t.Errorf("error.code = %v (%T), want 21000 (number)", errObj["code"], errObj["code"]) + } + if got := errObj["challenge_url"]; got != "https://example.com/challenge" { + t.Errorf("error.challenge_url = %v, want challenge url", got) + } + if got := errObj["hint"]; got != "complete challenge in your browser" { + t.Errorf("error.hint = %v, want hint message", got) + } + if _, exists := errObj["retryable"]; exists { + t.Errorf("error.retryable leaked into canonical envelope: %v", errObj["retryable"]) + } + }) + + t.Run("21001 access_denied", func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + errOut := &bytes.Buffer{} + f.IOStreams.ErrOut = errOut + + spErr := &errs.SecurityPolicyError{ + Problem: errs.Problem{ + Category: errs.CategoryPolicy, + Subtype: errs.SubtypeAccessDenied, + Code: 21001, + Message: "access denied", + }, + } + + gotExit := handleRootError(f, spErr) + if gotExit != int(output.ExitContentSafety) { + t.Errorf("exit code = %d, want %d", gotExit, output.ExitContentSafety) + } + + var env map[string]any + if err := json.Unmarshal(errOut.Bytes(), &env); err != nil { + t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String()) + } + errObj := env["error"].(map[string]any) + if got := errObj["type"]; got != "policy" { + t.Errorf("error.type = %v, want %q", got, "policy") + } + if got := errObj["subtype"]; got != "access_denied" { + t.Errorf("error.subtype = %v, want %q", got, "access_denied") + } + if got, ok := errObj["code"].(float64); !ok || int(got) != 21001 { + t.Errorf("error.code = %v, want 21001 (number)", errObj["code"]) + } + }) } // newAuthErrorWithNeedAuthMarker builds a typed *errs.AuthenticationError whose Message @@ -230,6 +247,77 @@ func newAuthErrorWithNeedAuthMarker() *errs.AuthenticationError { } } +// failingWriter writes up to limit bytes then returns io.ErrShortWrite on +// the write that would push past the limit. Used to simulate a stderr that +// dies mid-envelope. +type failingWriter struct { + limit int + n int +} + +func (f *failingWriter) Write(p []byte) (int, error) { + if f.n+len(p) > f.limit { + canWrite := f.limit - f.n + if canWrite < 0 { + canWrite = 0 + } + f.n += canWrite + return canWrite, io.ErrShortWrite + } + f.n += len(p) + return len(p), nil +} + +// TestHandleRootError_PartialWritePreservesExitCode pins that when the +// stderr write fails mid-envelope, handleRootError still returns the typed +// exit code (ExitAuth=3 for AuthenticationError), not fall through to the +// plain "Error:" path with exit 1. ExitCodeOf is computed from the typed +// err BEFORE the envelope write so the exit code is preserved even when +// the consumer's stderr pipe dies. +func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + w := &failingWriter{limit: 20} + f.IOStreams.ErrOut = w + + err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired") + exit := handleRootError(f, err) + if exit != int(output.ExitAuth) { + t.Errorf("exit = %d, want %d (typed exit code preserved despite write failure)", exit, int(output.ExitAuth)) + } +} + +// TestHandleRootError_TypedOuterShortCircuitsPromote pins that when a typed +// *errs.AuthenticationError carries a legacy *NeedAuthorizationError in its +// Cause chain, the dispatcher does NOT run PromoteAuthError — doing so +// would replace the producer's TokenExpired subtype + custom hint with the +// promoted shape's TokenMissing. +func TestHandleRootError_TypedOuterShortCircuitsPromote(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + errOut := &bytes.Buffer{} + f.IOStreams.ErrOut = errOut + + innerLegacy := &internalauth.NeedAuthorizationError{UserOpenId: "u_123"} + outer := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired"). + WithHint("custom producer hint"). + WithCause(innerLegacy) + + exit := handleRootError(f, outer) + if exit != int(output.ExitAuth) { + t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth)) + } + got := errOut.String() + if !strings.Contains(got, `"subtype": "token_expired"`) { + t.Errorf("envelope lost producer Subtype TokenExpired; got %s", got) + } + if !strings.Contains(got, "custom producer hint") { + t.Errorf("envelope lost producer Hint; got %s", got) + } +} + // TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT pins // that a typed AuthenticationError carrying the need_user_authorization marker gets a // declared-scopes Hint appended when the current command is a registered @@ -357,3 +445,136 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) { t.Errorf("expected appended hint %q, got %q", want, authErr.Hint) } } + +// TestEnrichPermissionError_CanonicalConvergence pins that the legacy +// *output.ExitError dispatch path produces the same canonical Message + Hint +// + ConsoleURL as the typed *errs.PermissionError dispatch path. Both paths +// share errclass.CanonicalPermissionMessage / errclass.PermissionHint / +// errclass.ConsoleURL — so a wire consumer cannot tell which path produced +// the envelope. +func TestEnrichPermissionError_CanonicalConvergence(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + cases := []struct { + name string + larkCode int + legacyErrType string + wantMsgSubstrs []string + wantHintSubstrs []string + wantConsoleURL bool + wantNoAuthLogin bool // hint must not suggest `auth login` + }{ + { + name: "99991672 app_scope_not_applied", + larkCode: 99991672, + legacyErrType: "permission", + wantMsgSubstrs: []string{"access denied", "app cli_test", "drive:drive:read"}, + wantHintSubstrs: []string{"developer console", "open.feishu.cn"}, + wantConsoleURL: true, + wantNoAuthLogin: true, + }, + { + name: "99991679 missing_scope", + larkCode: 99991679, + legacyErrType: "permission", + wantMsgSubstrs: []string{"unauthorized", "user authorization"}, + wantHintSubstrs: []string{"lark-cli auth login"}, + }, + { + name: "99991673 app_unavailable", + larkCode: 99991673, + legacyErrType: "app_status", + wantMsgSubstrs: []string{"unauthorized app", "app cli_test", "not properly installed"}, + wantHintSubstrs: []string{"tenant admin", "install status"}, + }, + { + name: "99991662 app_disabled", + larkCode: 99991662, + legacyErrType: "app_status", + wantMsgSubstrs: []string{"app cli_test", "not in use", "currently disabled"}, + wantHintSubstrs: []string{"tenant admin", "re-enable"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu, + }) + f.ResolvedIdentity = core.AsUser + + // Mimic the wire shape ErrAPI produces: legacy *ExitError with + // Detail.Type populated by ClassifyLarkError, Detail.Detail + // carrying the permission_violations block so ExtractRequiredScopes + // can recover the missing scope. + scopeForDetail := "drive:drive:read" + exitErr := &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: tc.legacyErrType, + Code: tc.larkCode, + Message: "upstream raw message — must be replaced", + Detail: map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": scopeForDetail}, + }, + }, + }, + } + enrichPermissionError(f, exitErr) + + for _, sub := range tc.wantMsgSubstrs { + if !strings.Contains(exitErr.Detail.Message, sub) { + t.Errorf("Message %q missing substring %q", exitErr.Detail.Message, sub) + } + } + if exitErr.Detail.Message == "upstream raw message — must be replaced" { + t.Errorf("Message must be rewritten to canonical text; got upstream verbatim") + } + for _, sub := range tc.wantHintSubstrs { + if !strings.Contains(exitErr.Detail.Hint, sub) { + t.Errorf("Hint %q missing substring %q", exitErr.Detail.Hint, sub) + } + } + if tc.wantNoAuthLogin && strings.Contains(exitErr.Detail.Hint, "auth login") { + t.Errorf("Hint must not suggest `auth login` for this subtype; got %q", exitErr.Detail.Hint) + } + if tc.wantConsoleURL && exitErr.Detail.ConsoleURL == "" { + t.Error("ConsoleURL should be populated when missing scopes are present") + } + }) + } +} + +// TestEnrichPermissionError_SkipsUnrelatedTypes pins that an ExitError whose +// Detail.Type is neither "permission" nor "app_status" is left untouched — +// no Message rewrite, no Hint rewrite, no ConsoleURL injection. +func TestEnrichPermissionError_SkipsUnrelatedTypes(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu, + }) + f.ResolvedIdentity = core.AsUser + + for _, ty := range []string{"api_error", "validation", "rate_limit", "auth"} { + exitErr := &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: ty, + Code: 99991400, + Message: "untouched", + Hint: "original hint", + }, + } + enrichPermissionError(f, exitErr) + if exitErr.Detail.Message != "untouched" { + t.Errorf("type=%q: Message was rewritten unexpectedly: %q", ty, exitErr.Detail.Message) + } + if exitErr.Detail.Hint != "original hint" { + t.Errorf("type=%q: Hint was rewritten unexpectedly: %q", ty, exitErr.Detail.Hint) + } + if exitErr.Detail.ConsoleURL != "" { + t.Errorf("type=%q: ConsoleURL should not be injected; got %q", ty, exitErr.Detail.ConsoleURL) + } + } +} diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index e4114c5bc..5276052e0 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/internal/schema" "github.com/larksuite/cli/internal/util" "github.com/spf13/cobra" ) @@ -24,7 +25,8 @@ type SchemaOptions struct { Ctx context.Context // Positional args - Path string + Path string // first positional, when only one is given + ExtraArgs []string // 2nd+ positional args (space-separated form) // Flags Format string @@ -359,13 +361,16 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co opts := &SchemaOptions{Factory: f} cmd := &cobra.Command{ - Use: "schema [path]", + Use: "schema [path | service resource method]", Short: "View API method parameters, types, and scopes", - Args: cobra.MaximumNArgs(1), + Args: cobra.MaximumNArgs(8), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { opts.Path = args[0] } + if len(args) > 1 { + opts.ExtraArgs = args[1:] + } opts.Ctx = cmd.Context() if runF != nil { return runF(opts) @@ -380,60 +385,108 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp }) - cmdutil.SetRisk(cmd, "read") + cmdutil.SetRisk(cmd, cmdutil.RiskRead) return cmd } // completeSchemaPath provides tab-completion for the schema path argument. -// It handles dotted resource names (e.g. app.table.fields) by iterating all -// resources and classifying each as a prefix-match or fully-matched. +// It handles both legacy dotted resource names (e.g. app.table.fields) and the +// newer space-separated form (e.g. `schema im messages reply`). func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if len(args) > 0 { - return nil, cobra.ShellCompDirectiveNoFileComp - } + mode := f.ResolveStrictMode(cmd.Context()) - parts := strings.Split(toComplete, ".") - - // Level 1: complete service names - if len(parts) <= 1 { - var completions []string - for _, s := range registry.ListFromMetaProjects() { - if strings.HasPrefix(s, toComplete) { - completions = append(completions, s+".") + // Case 1: legacy "single dotted arg" path — no previous args yet + if len(args) == 0 { + parts := strings.Split(toComplete, ".") + if len(parts) <= 1 { + var completions []string + for _, s := range registry.ListFromMetaProjects() { + if strings.HasPrefix(s, toComplete) { + completions = append(completions, s+".") + } + } + return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + } + serviceName := parts[0] + spec := registry.LoadFromMeta(serviceName) + if spec == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + spec = filterSpecByStrictMode(spec, mode) + resources, _ := spec["resources"].(map[string]interface{}) + if resources == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + afterService := strings.Join(parts[1:], ".") + completions := completeSchemaPathForSpec(serviceName, resources, afterService) + allTrailingDot := len(completions) > 0 + for _, c := range completions { + if !strings.HasSuffix(c, ".") { + allTrailingDot = false + break } } - return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + directive := cobra.ShellCompDirectiveNoFileComp + if allTrailingDot { + directive |= cobra.ShellCompDirectiveNoSpace + } + return completions, directive } - serviceName := parts[0] + // Case 2: space-form, args already has segments + // Walk down service -> resource(s) -> method based on existing args + serviceName := args[0] spec := registry.LoadFromMeta(serviceName) if spec == nil { return nil, cobra.ShellCompDirectiveNoFileComp } - mode := f.ResolveStrictMode(cmd.Context()) spec = filterSpecByStrictMode(spec, mode) resources, _ := spec["resources"].(map[string]interface{}) if resources == nil { return nil, cobra.ShellCompDirectiveNoFileComp } - afterService := strings.Join(parts[1:], ".") - completions := completeSchemaPathForSpec(serviceName, resources, afterService) - - allTrailingDot := len(completions) > 0 - for _, c := range completions { - if !strings.HasSuffix(c, ".") { - allTrailingDot = false - break + // args[1:] are resource path segments (possibly partial); current + // toComplete is the next segment under cursor. + consumed := args[1:] + resource, _, remaining := findResourceByPath(resources, consumed) + if resource == nil { + // Suggest top-level resource names that match toComplete + var completions []string + for resName := range resources { + if strings.HasPrefix(resName, toComplete) { + completions = append(completions, resName) + } + } + sort.Strings(completions) + return completions, cobra.ShellCompDirectiveNoFileComp + } + if len(remaining) > 0 { + // Already typed past the resource — suggest methods + methods, _ := resource["methods"].(map[string]interface{}) + methods = filterMethodsByStrictMode(methods, mode) + var completions []string + for mName := range methods { + if strings.HasPrefix(mName, toComplete) { + completions = append(completions, mName) + } + } + sort.Strings(completions) + return completions, cobra.ShellCompDirectiveNoFileComp + } + // Resource matched exactly, suggest methods + methods, _ := resource["methods"].(map[string]interface{}) + methods = filterMethodsByStrictMode(methods, mode) + var completions []string + for mName := range methods { + if strings.HasPrefix(mName, toComplete) { + completions = append(completions, mName) } } - directive := cobra.ShellCompDirectiveNoFileComp - if allTrailingDot { - directive |= cobra.ShellCompDirectiveNoSpace - } - return completions, directive + sort.Strings(completions) + return completions, cobra.ShellCompDirectiveNoFileComp } } @@ -469,94 +522,231 @@ func schemaRun(opts *SchemaOptions) error { out := opts.Factory.IOStreams.Out mode := opts.Factory.ResolveStrictMode(opts.Ctx) - if opts.Path == "" { - printServices(out) - return nil + // args may have arrived as a single string (legacy single-arg path) or + // split into multiple — normalize to a single args slice. + var rawArgs []string + if opts.Path != "" { + rawArgs = []string{opts.Path} } - - parts := strings.Split(opts.Path, ".") - - serviceName := parts[0] - spec := registry.LoadFromMeta(serviceName) - if spec == nil { - return output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("Unknown service: %s", serviceName), - fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", "))) - } - - if len(parts) == 1 { - if opts.Format == "pretty" { - printResourceList(out, spec, mode) + if len(opts.ExtraArgs) > 0 { + if opts.Path != "" { + rawArgs = append([]string{opts.Path}, opts.ExtraArgs...) } else { - output.PrintJson(out, filterSpecByStrictMode(spec, mode)) + rawArgs = append([]string(nil), opts.ExtraArgs...) } - return nil } + parts := schema.ParsePath(rawArgs) + if opts.Format == "pretty" { + return runPrettyMode(out, parts, mode) + } + return runJSONMode(out, parts, mode) +} + +// runJSONMode dispatches list/single envelope output based on parts. +// JSON mode uses embedded data only (bypasses remote overlay) so envelope +// output is deterministic across machines. +func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error { + filter := strictModeFilter(mode) + + switch len(parts) { + case 0: + envs := schema.AssembleAll(filter) + output.PrintJson(out, envs) + return nil + case 1: + spec := registry.EmbeddedSpec(parts[0]) + if spec == nil { + return errUnknownEmbeddedService(parts[0]) + } + envs := schema.AssembleService(parts[0], spec, filter) + output.PrintJson(out, envs) + return nil + default: + return runJSONForPath(out, parts, filter) + } +} + +// runJSONForPath handles len(parts) >= 2: try resource match first, fallback +// to single-method match. Uses embedded data only. +func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error { + serviceName := parts[0] + spec := registry.EmbeddedSpec(serviceName) + if spec == nil { + return errUnknownEmbeddedService(serviceName) + } resources, _ := spec["resources"].(map[string]interface{}) resource, resName, remaining := findResourceByPath(resources, parts[1:]) if resource == nil { - var resNames []string + var names []string for k := range resources { - resNames = append(resNames, k) + names = append(names, k) } + sort.Strings(names) return output.ErrWithHint(output.ExitValidation, "validation", fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")), - fmt.Sprintf("Available: %s", strings.Join(resNames, ", "))) + fmt.Sprintf("Available: %s", strings.Join(names, ", "))) } - if len(remaining) == 0 { - if opts.Format == "pretty" { - fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset) - methods, _ := resource["methods"].(map[string]interface{}) - methods = filterMethodsByStrictMode(methods, mode) - for _, mName := range sortedKeys(methods) { - m, _ := methods[mName].(map[string]interface{}) - httpMethod := registry.GetStrFromMap(m, "httpMethod") - desc := registry.GetStrFromMap(m, "description") - fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset) - } - fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.%s\n", output.Dim, serviceName, resName, output.Reset) - } else { - // For JSON output, filter methods in a copy to avoid mutating the registry. - if mode.IsActive() { - filtered := make(map[string]interface{}) - for k, v := range resource { - filtered[k] = v - } - if methods, ok := resource["methods"].(map[string]interface{}); ok { - filtered["methods"] = filterMethodsByStrictMode(methods, mode) - } - output.PrintJson(out, filtered) - } else { - output.PrintJson(out, resource) - } - } + // Resource-scoped envelope array + envs := assembleResource(serviceName, resName, resource, filter) + output.PrintJson(out, envs) return nil } + methodName := remaining[0] + methods, _ := resource["methods"].(map[string]interface{}) + method, ok := methods[methodName].(map[string]interface{}) + if !ok { + var names []string + for k := range methods { + names = append(names, k) + } + sort.Strings(names) + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName), + fmt.Sprintf("Available: %s", strings.Join(names, ", "))) + } + if len(remaining) > 1 { + // Method exists but caller appended extra segments — reject so they + // don't silently get this method's schema when they typo'd the path. + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown path: %s.%s.%s", + serviceName, resName, strings.Join(remaining, ".")), + fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve", + methodName, strings.Join(remaining[1:], "."))) + } + if filter != nil && !filter(method) { + // Method exists in spec but filtered out by strict mode + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName), + "Use --as user / --as bot to switch") + } + env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method) + output.PrintJson(out, env) + return nil +} +func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope { + methods, _ := resource["methods"].(map[string]interface{}) + resourcePath := []string{resName} + var envs []schema.Envelope + for methodName, raw := range methods { + method, ok := raw.(map[string]interface{}) + if !ok { + continue + } + if filter != nil && !filter(method) { + continue + } + envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method)) + } + sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name }) + return envs +} + +// runPrettyMode preserves the existing legacy pretty rendering verbatim. +// All printServices/printResourceList/printMethodDetail calls stay unchanged. +func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error { + if len(parts) == 0 { + printServices(out) + return nil + } + serviceName := parts[0] + spec := registry.LoadFromMeta(serviceName) + if spec == nil { + return errUnknownService(serviceName) + } + if len(parts) == 1 { + printResourceList(out, spec, mode) + return nil + } + resources, _ := spec["resources"].(map[string]interface{}) + resource, resName, remaining := findResourceByPath(resources, parts[1:]) + if resource == nil { + var names []string + for k := range resources { + names = append(names, k) + } + sort.Strings(names) + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")), + fmt.Sprintf("Available: %s", strings.Join(names, ", "))) + } + if len(remaining) == 0 { + fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset) + methods, _ := resource["methods"].(map[string]interface{}) + methods = filterMethodsByStrictMode(methods, mode) + for _, mName := range sortedKeys(methods) { + m, _ := methods[mName].(map[string]interface{}) + httpMethod := registry.GetStrFromMap(m, "httpMethod") + desc := registry.GetStrFromMap(m, "description") + fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset) + } + fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.%s\n", output.Dim, serviceName, resName, output.Reset) + return nil + } methodName := remaining[0] methods, _ := resource["methods"].(map[string]interface{}) methods = filterMethodsByStrictMode(methods, mode) method, ok := methods[methodName].(map[string]interface{}) if !ok { - var mNames []string + var names []string for k := range methods { - mNames = append(mNames, k) + names = append(names, k) } + sort.Strings(names) return output.ErrWithHint(output.ExitValidation, "validation", fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName), - fmt.Sprintf("Available: %s", strings.Join(mNames, ", "))) + fmt.Sprintf("Available: %s", strings.Join(names, ", "))) } - - if opts.Format == "pretty" { - printMethodDetail(out, spec, resName, methodName, method) - } else { - output.PrintJson(out, method) + if len(remaining) > 1 { + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown path: %s.%s.%s", + serviceName, resName, strings.Join(remaining, ".")), + fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve", + methodName, strings.Join(remaining[1:], "."))) } + printMethodDetail(out, spec, resName, methodName, method) return nil } +// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns +// nil if strict mode is not active. +func strictModeFilter(mode core.StrictMode) schema.MethodFilter { + if !mode.IsActive() { + return nil + } + token := registry.IdentityToAccessToken(string(mode.ForcedIdentity())) + return func(method map[string]interface{}) bool { + tokens, _ := method["accessTokens"].([]interface{}) + if tokens == nil { + return true // permissive when meta_data lacks accessTokens + } + for _, t := range tokens { + if s, _ := t.(string); s == token { + return true + } + } + return false + } +} + +func errUnknownService(name string) error { + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown service: %s", name), + fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", "))) +} + +// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded +// services (no overlay) because JSON mode itself bypasses overlay; suggesting +// overlay-only services would mislead callers when those services subsequently +// fail to resolve in envelope output. +func errUnknownEmbeddedService(name string) error { + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown service: %s", name), + fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", "))) +} + // filterSpecByStrictMode returns a shallow copy of spec with each resource's methods // filtered by strict mode. Returns the original spec when strict mode is off. func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} { diff --git a/cmd/schema/schema_test.go b/cmd/schema/schema_test.go index da4129302..cb9e51c8b 100644 --- a/cmd/schema/schema_test.go +++ b/cmd/schema/schema_test.go @@ -5,6 +5,7 @@ package schema import ( "bytes" + "encoding/json" "strings" "testing" @@ -33,17 +34,165 @@ func TestSchemaCmd_FlagParsing(t *testing.T) { } } -func TestSchemaCmd_NoArgs(t *testing.T) { +func TestSchemaCmd_NoArgs_Pretty(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, nil) cmd := NewCmdSchema(f, nil) - cmd.SetArgs([]string{}) - err := cmd.Execute() - if err != nil { + cmd.SetArgs([]string{"--format", "pretty"}) + if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(stdout.String(), "Available services") { - t.Error("expected service list output") + t.Error("expected service list in pretty mode") + } +} + +func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{}) // default --format json + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := strings.TrimSpace(stdout.String()) + if !strings.HasPrefix(out, "[") { + head := out + if len(head) > 80 { + head = head[:80] + } + t.Errorf("expected JSON array root, first 80 chars:\n%s", head) + } + var envs []map[string]interface{} + if err := json.Unmarshal([]byte(out), &envs); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if len(envs) < 193 { + t.Errorf("envelopes count = %d, want >= 193", len(envs)) + } +} + +func TestSchemaCmd_JSONIsEnvelope(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{"im.images.create", "--format", "json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("not valid JSON: %v\n%s", err, stdout.String()) + } + if env["name"] != "im images create" { + t.Errorf("name = %v, want \"im images create\"", env["name"]) + } + for _, key := range []string{"description", "inputSchema", "outputSchema", "_meta"} { + if _, ok := env[key]; !ok { + t.Errorf("missing top-level key: %s", key) + } + } + meta, _ := env["_meta"].(map[string]interface{}) + if meta["envelope_version"] != "1.0" { + t.Errorf("envelope_version = %v, want \"1.0\"", meta["envelope_version"]) + } +} + +func TestSchemaCmd_SpaceSeparatedPath_EqualsDotted(t *testing.T) { + f1, out1, _, _ := cmdutil.TestFactory(t, nil) + cmd1 := NewCmdSchema(f1, nil) + cmd1.SetArgs([]string{"im", "images", "create"}) + if err := cmd1.Execute(); err != nil { + t.Fatalf("space form failed: %v", err) + } + + f2, out2, _, _ := cmdutil.TestFactory(t, nil) + cmd2 := NewCmdSchema(f2, nil) + cmd2.SetArgs([]string{"im.images.create"}) + if err := cmd2.Execute(); err != nil { + t.Fatalf("dotted form failed: %v", err) + } + + if out1.String() != out2.String() { + t.Errorf("space and dotted forms produced different output") + } +} + +func TestSchemaCmd_ServiceListIsArray(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{"im"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var envs []map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envs); err != nil { + t.Fatalf("unmarshal failed: %v\n%s", err, stdout.String()) + } + if len(envs) == 0 { + t.Fatal("expected non-empty array for service im") + } + for _, e := range envs { + name, _ := e["name"].(string) + if !strings.HasPrefix(name, "im ") { + t.Errorf("envelope name %q does not start with \"im \"", name) + } + } +} + +func TestSchemaCmd_HighRiskYesInjection(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{"im.messages.delete"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + is, _ := env["inputSchema"].(map[string]interface{}) + props, _ := is["properties"].(map[string]interface{}) + if _, ok := props["yes"]; !ok { + t.Errorf("inputSchema.properties.yes missing for high-risk-write command") + } +} + +func TestSchemaCmd_NoYesForReadRisk(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{"im.reactions.list"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + is, _ := env["inputSchema"].(map[string]interface{}) + props, _ := is["properties"].(map[string]interface{}) + if _, ok := props["yes"]; ok { + t.Errorf("yes property should not appear for risk=read command") + } +} + +func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{"im.images.create", "--format", "pretty"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + // Existing pretty rendering surfaces these markers — they must still appear + for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} { + if !strings.Contains(out, want) { + t.Errorf("pretty output missing marker %q", want) + } } } diff --git a/cmd/service/service.go b/cmd/service/service.go index c895c2bc8..125cc584f 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -9,11 +9,13 @@ import ( "io" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/util" @@ -222,7 +224,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { } if opts.PageAll && opts.Output != "" { - return output.ErrValidation("--output and --page-all are mutually exclusive") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --page-all are mutually exclusive").WithParam("--output") } if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil { return err @@ -271,12 +273,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format) } - // Stage 1: enrich the 99991679 (LarkErrUserScopeInsufficient) response - // with a per-method recommended `--scope` hint, matching the pre-PR - // behaviour. Per-domain typed migration in stage 2+ will lift this - // into PermissionError.MissingScopes / ConsoleURL on the typed - // envelope; until then the legacy ExitError envelope is preserved. - checkErr := scopeAwareChecker(scopes, opts.As.IsBot()) + // Scope-insufficient (99991679) and all other Lark API codes route through + // errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError + // with MissingScopes / Identity / ConsoleURL populated from the response. + checkErr := ac.CheckResponse if opts.PageAll { return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, @@ -300,51 +300,6 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { }) } -// scopeAwareChecker returns an error checker that enriches the -// LarkErrUserScopeInsufficient (99991679) business error with a -// per-method recommended `--scope` hint. All other non-zero codes fall -// through to legacy output.ErrAPI (matching pre-PR behaviour). The -// identity parameter is accepted to match the client.ResponseOptions -// CheckError signature; isBotMode is captured from the enclosing call so -// the recommended scope reflects the caller's identity at request time. -// -// Deprecated: stage-1 enrichment for the legacy *output.ExitError envelope. -// Stage-2 typed migration will lift this into PermissionError.MissingScopes -// + ConsoleURL on the typed envelope and remove this helper. -func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}, core.Identity) error { - return func(result interface{}, _ core.Identity) error { - resultMap, ok := result.(map[string]interface{}) - if !ok || resultMap == nil { - return nil - } - code, _ := util.ToFloat64(resultMap["code"]) - if code == 0 { - return nil - } - larkCode := int(code) - msg := registry.GetStrFromMap(resultMap, "msg") - - if larkCode == output.LarkErrUserScopeInsufficient && len(scopes) > 0 { - identity := "user" - if isBotMode { - identity = "tenant" - } - recommended := registry.SelectRecommendedScope(scopes, identity) - // Stage-1 carve-out: this restores the pre-PR scope-insufficient - // enrichment (recommended scope + auth-login hint) on the legacy - // envelope. The typed migration in stage 2+ will lift this into - // PermissionError.MissingScopes / ConsoleURL on the typed wire. - return output.ErrWithHint(output.ExitAPI, "permission", - fmt.Sprintf("insufficient permissions: [%d] %s", larkCode, msg), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended)) - } - - // Stage-1 carve-out: matches pre-PR behaviour (legacy ExitError + - // ClassifyLarkError). Typed migration is stage-2+. - return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"]) - } -} - // checkServiceScopes pre-checks user scopes before making the API call. func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error { if ctx.Err() != nil { @@ -366,9 +321,7 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider } } if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 { - return output.ErrWithHint(output.ExitAuth, "missing_scope", - fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))) + return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing) } return nil } @@ -388,9 +341,24 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider } } recommended := registry.SelectRecommendedScope(scopes, "user") - return output.ErrWithHint(output.ExitAPI, "permission", - fmt.Sprintf("insufficient permissions (required scope: %s)", recommended), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended)) + return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended}) +} + +// newPreflightMissingScopeError constructs a PermissionError for the local +// pre-flight scope check that converges byte-for-byte with the dispatcher's +// BuildAPIError path. Uses the canonical helpers in internal/errclass so +// Hint and Message stay in lock-step with the server-response classifier. +// ConsoleURL is deliberately omitted: the dispatcher only sets it for +// SubtypeAppScopeNotApplied (bot-perspective dev-action recovery), and this +// pre-flight path is user-perspective SubtypeMissingScope whose recovery is +// `lark-cli auth login --scope ...`, not a console deep-link. +func newPreflightMissingScopeError(brand, appID, identity string, missing []string) *errs.PermissionError { + consoleURL := errclass.ConsoleURL(brand, appID, missing) + return errs.NewPermissionError(errs.SubtypeMissingScope, + "%s", errclass.CanonicalPermissionMessage(errs.SubtypeMissingScope, appID, missing, "")). + WithHint("%s", errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope, consoleURL)). + WithMissingScopes(missing...). + WithIdentity(identity) } // buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest. @@ -412,7 +380,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd return client.RawApiRequest{}, nil, err } if opts.Params == "-" && opts.Data == "-" { - return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)") + return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--params and --data cannot both read from stdin (-)").WithParam("--params") } params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO) if err != nil { @@ -429,13 +397,14 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd } val, ok := params[name] if !ok || util.IsEmptyValue(val) { - return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("missing required path parameter: %s", name), - fmt.Sprintf("lark-cli schema %s", schemaPath)) + return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "missing required path parameter: %s", name). + WithHint("lark-cli schema %s", schemaPath). + WithParam(name) } valStr := fmt.Sprintf("%v", val) if err := validate.ResourceName(valStr, name); err != nil { - return client.RawApiRequest{}, nil, output.ErrValidation("%s", err) + return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(name).WithCause(err) } url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1) delete(params, name) @@ -451,9 +420,10 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd required, _ := p["required"].(bool) isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size") if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) { - return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("missing required query parameter: %s", name), - fmt.Sprintf("lark-cli schema %s", schemaPath)) + return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "missing required query parameter: %s", name). + WithHint("lark-cli schema %s", schemaPath). + WithParam(name) } if exists && !util.IsEmptyValue(value) { queryParams[name] = value @@ -488,7 +458,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd return client.RawApiRequest{}, nil, err } if _, ok := dataFields.(map[string]any); !ok { - return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file") + return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a JSON object when used with --file").WithParam("--data") } } diff --git a/cmd/update/update.go b/cmd/update/update.go index c9035cd5c..6b8ce5091 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -31,15 +31,18 @@ var ( currentVersion = func() string { return build.Version } currentOS = runtime.GOOS newUpdater = func() *selfupdate.Updater { return selfupdate.New() } + syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { return skillscheck.SyncSkills(opts) } ) func isWindows() bool { return currentOS == osWindows } -// normalizeVersion canonicalizes a version string for stamp comparison. +// normalizeVersion canonicalizes a version string for state comparison. // Strips a leading "v" so versions written from Makefile (git describe → // "v1.0.0") and npm (no prefix → "1.0.0") compare equal. func normalizeVersion(s string) string { - return strings.TrimPrefix(strings.TrimSpace(s), "v") + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "v") + return strings.TrimPrefix(s, "V") } func releaseURL(version string) string { @@ -121,7 +124,9 @@ func updateRun(opts *UpdateOptions) error { cur := currentVersion() updater := newUpdater() - updater.CleanupStaleFiles() + if !opts.Check { + updater.CleanupStaleFiles() + } output.PendingNotice = nil // 1. Fetch latest version @@ -137,13 +142,9 @@ func updateRun(opts *UpdateOptions) error { // 3. Compare versions if !opts.Force && !update.IsNewer(latest, cur) { - // Run skills sync before returning — covers the case where the - // binary is already current but skills were never synced. - // Stamp dedup makes this a no-op if skills are already in sync. - // Skip side-effects under --check (pure report path per spec §3.6). - var skillsResult *selfupdate.NpmResult + var skillsResult *skillscheck.SyncResult if !opts.Check { - skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force) + skillsResult = runSkillsAndState(updater, io, cur, opts.Force) } return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check) } @@ -185,16 +186,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s "message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest), "url": releaseURL(latest), "changelog": changelogURL(), } - // skills_status: pure report, no side effect, no stamp write. - // ReadStamp errors are silently swallowed — if we can't read the - // stamp we just omit the block rather than fail the --check. - if stamp, err := skillscheck.ReadStamp(); err == nil { - out["skills_status"] = map[string]interface{}{ - "current": stamp, - "target": cur, - "in_sync": stamp == cur, - } - } + applySkillsStatus(out, cur) output.PrintJson(io.Out, out) return nil } @@ -210,7 +202,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s } func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error { - skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force) + skillsResult := runSkillsAndState(updater, io, cur, opts.Force) reason := detect.ManualReason() if opts.JSON { @@ -288,10 +280,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, return output.ErrBare(output.ExitAPI) } - // Skills update (best-effort) — uses runSkillsAndStamp so the - // stamp gets persisted on success and dedup applies if a previous - // run already stamped this version. - skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force) + skillsResult := runSkillsAndState(updater, io, latest, opts.Force) if opts.JSON { result := map[string]interface{}{ @@ -328,27 +317,21 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest)) } -// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the -// stamp on success. Skips the npx invocation when the stamp already -// matches stampVersion (unless force is true). The stamp write failure -// emits a warning to io.ErrOut but does NOT fail the update command — -// best-effort. ReadStamp errors are swallowed (fail-closed: treated as -// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp -// dedup; otherwise returns the underlying *NpmResult with Err semantics -// from RunSkillsUpdate. -func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult { +func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult { if !force { - if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) { + if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) { return nil } } - r := updater.RunSkillsUpdate() - if r.Err == nil { - if err := skillscheck.WriteStamp(stampVersion); err != nil { - fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err) - } + result := syncSkills(skillscheck.SyncOptions{ + Version: stateVersion, + Force: force, + Runner: updater, + }) + if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") { + fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err) } - return r + return result } // reportAlreadyUpToDate emits the JSON / pretty output for the @@ -356,7 +339,7 @@ func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stamp // fields derived from skillsResult. When check is true, this is the pure // report path (spec §3.6): no side-effects, JSON envelope uses // skills_status (spec §4.2) instead of skills_action. -func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error { +func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error { if opts.JSON { out := map[string]interface{}{ "ok": true, "previous_version": cur, "current_version": cur, @@ -364,16 +347,7 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late "message": fmt.Sprintf("lark-cli %s is already up to date", cur), } if check { - // Pure report — read stamp directly, emit skills_status block. - // ReadStamp errors are silently swallowed — if we can't read - // the stamp we just omit the block rather than fail the --check. - if stamp, err := skillscheck.ReadStamp(); err == nil { - out["skills_status"] = map[string]interface{}{ - "current": stamp, - "target": cur, - "in_sync": stamp == cur, - } - } + applySkillsStatus(out, cur) } else { applySkillsResult(out, skillsResult) } @@ -387,36 +361,70 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late return nil } -// applySkillsResult mutates the JSON envelope to include skills_action -// (and skills_warning when failed). nil result = "in_sync" (dedup hit). -func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) { +func applySkillsStatus(env map[string]interface{}, target string) { + state, readable, err := skillscheck.ReadState() + if err != nil || !readable || state.Version == "" { + return + } + status := map[string]interface{}{ + "current": state.Version, + "target": target, + "in_sync": normalizeVersion(state.Version) == normalizeVersion(target), + } + if len(state.OfficialSkills) > 0 { + status["official"] = len(state.OfficialSkills) + } + if len(state.UpdatedSkills) > 0 { + status["updated"] = len(state.UpdatedSkills) + } + if len(state.SkippedDeletedSkills) > 0 { + status["skipped_deleted"] = state.SkippedDeletedSkills + } + env["skills_status"] = status +} + +func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) { switch { case r == nil: env["skills_action"] = "in_sync" case r.Err != nil: env["skills_action"] = "failed" env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err) - if detail := strings.TrimSpace(r.Stderr.String()); detail != "" { - env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput) - } + env["skills_summary"] = skillsSummary(r) default: env["skills_action"] = "synced" + env["skills_summary"] = skillsSummary(r) } } -// emitSkillsTextHints prints human-readable feedback about the skills -// sync result for non-JSON output. -func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) { +func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} { + summary := map[string]interface{}{ + "official": len(r.Official), + "updated": len(r.Updated), + "added": len(r.Added), + "skipped_deleted": len(r.SkippedDeleted), + } + if len(r.Failed) > 0 { + summary["failed"] = r.Failed + } + return summary +} + +func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) { switch { case r == nil: - // dedup hit — silent (already up to date) case r.Err != nil: fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err) - if detail := strings.TrimSpace(r.Stderr.String()); detail != "" { - fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail)) + if len(r.Failed) > 0 { + fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", ")) } - fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n") + fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n") + case r.Force: + fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official)) default: - fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK()) + fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted)) + if len(r.SkippedDeleted) > 0 { + fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n") + } } } diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index 250aa83db..5cfe52477 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -5,13 +5,14 @@ package cmdupdate import ( "bytes" + "context" "encoding/json" "errors" "fmt" - "os" - "path/filepath" + "os/exec" "strings" "testing" + "time" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -28,7 +29,6 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe } // mockDetect sets up newUpdater to return an Updater with the given DetectResult. -// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later. func mockDetect(t *testing.T, result selfupdate.DetectResult) { t.Helper() origNew := newUpdater @@ -41,22 +41,53 @@ func mockDetect(t *testing.T, result selfupdate.DetectResult) { } // mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once. -func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, - npmFn func(string) *selfupdate.NpmResult, - skillsFn func() *selfupdate.NpmResult) { +func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(string) *selfupdate.NpmResult) { t.Helper() origNew := newUpdater newUpdater = func() *selfupdate.Updater { u := selfupdate.New() u.DetectOverride = func() selfupdate.DetectResult { return result } u.NpmInstallOverride = npmFn - u.SkillsUpdateOverride = skillsFn u.VerifyOverride = func(string) error { return nil } + u.SkillsCommandOverride = successfulSkillsCommand() return u } t.Cleanup(func() { newUpdater = origNew }) } +func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult { + return func(args ...string) *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + switch strings.Join(args, " ") { + case "-y skills add https://open.feishu.cn --list": + r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n") + case "-y skills ls -g": + r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n") + default: + } + return r + } +} + +func TestNormalizeVersion(t *testing.T) { + tests := []struct { + input string + want string + }{ + {input: "1.2.3", want: "1.2.3"}, + {input: "v1.2.3", want: "1.2.3"}, + {input: "V1.2.3", want: "1.2.3"}, + {input: " v1.2.3 ", want: "1.2.3"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := normalizeVersion(tt.input); got != tt.want { + t.Fatalf("normalizeVersion(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + func TestUpdateAlreadyUpToDate_JSON(t *testing.T) { f, stdout, _ := newTestFactory(t) @@ -168,9 +199,7 @@ func TestUpdateManual_Human(t *testing.T) { } func TestUpdateNpm_JSON(t *testing.T) { - // Isolate config dir: this test mocks fetchLatest="2.0.0" and lets - // runSkillsAndStamp → WriteStamp succeed, which without isolation would - // clobber the real ~/.lark-cli/skills.stamp with "2.0.0". + // Isolate config dir because skills sync writes skills-state.json. t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) @@ -186,7 +215,6 @@ func TestUpdateNpm_JSON(t *testing.T) { mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, - func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() @@ -216,7 +244,6 @@ func TestUpdateNpm_Human(t *testing.T) { mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, - func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() @@ -230,7 +257,7 @@ func TestUpdateNpm_Human(t *testing.T) { } func TestUpdateForce_JSON(t *testing.T) { - // Same stamp-isolation rationale as TestUpdateNpm_JSON. + // Same state-isolation rationale as TestUpdateNpm_JSON. t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) @@ -246,7 +273,6 @@ func TestUpdateForce_JSON(t *testing.T) { mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, - func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() @@ -323,7 +349,7 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) { } func TestUpdateDevVersion_JSON(t *testing.T) { - // Same stamp-isolation rationale as TestUpdateNpm_JSON. + // Same state-isolation rationale as TestUpdateNpm_JSON. t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) @@ -339,7 +365,6 @@ func TestUpdateDevVersion_JSON(t *testing.T) { mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, - func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() @@ -451,8 +476,8 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing. u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.VerifyOverride = func(string) error { return errors.New("bad binary") } u.RestoreAvailableOverride = func() bool { return false } - u.SkillsUpdateOverride = func() *selfupdate.NpmResult { - t.Fatal("skills update should not run when binary verification fails") + u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult { + t.Fatal("skills sync should not run when binary verification fails") return nil } return u @@ -649,7 +674,7 @@ func TestPermissionHint(t *testing.T) { func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) { // With the rename trick, Windows npm installs can now auto-update. - // Same stamp-isolation rationale as TestUpdateNpm_JSON. + // Same state-isolation rationale as TestUpdateNpm_JSON. t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) @@ -668,7 +693,6 @@ func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) { mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, - func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() @@ -750,7 +774,6 @@ func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) { mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, - func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() @@ -785,8 +808,7 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) { } u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.VerifyOverride = func(string) error { return nil } - // Skills update fails - u.SkillsUpdateOverride = func() *selfupdate.NpmResult { + u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult { r := &selfupdate.NpmResult{} r.Stderr.WriteString("npx: command not found") r.Err = fmt.Errorf("exit status 127") @@ -812,8 +834,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) { if !strings.Contains(out, "skills_warning") { t.Errorf("expected skills_warning in output, got: %s", out) } - if !strings.Contains(out, "skills_detail") { - t.Errorf("expected skills_detail in output, got: %s", out) + if !strings.Contains(out, "skills_summary") { + t.Errorf("expected skills_summary in output, got: %s", out) } } @@ -838,7 +860,7 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) { } u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.VerifyOverride = func(string) error { return nil } - u.SkillsUpdateOverride = func() *selfupdate.NpmResult { + u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult { r := &selfupdate.NpmResult{} r.Stderr.WriteString("npx: command not found") r.Err = fmt.Errorf("exit status 127") @@ -861,100 +883,96 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) { if !strings.Contains(out, "Skills update failed") { t.Errorf("expected skills failure warning, got: %s", out) } - if !strings.Contains(out, "npx -y skills add") { - t.Errorf("expected manual skills command hint, got: %s", out) + if !strings.Contains(out, "lark-cli update --force") { + t.Errorf("expected force retry hint, got: %s", out) } } -// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable -// for direct calls to internals like runSkillsAndStamp that write to -// io.ErrOut. +// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers. func newTestIO() *cmdutil.IOStreams { return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}) } -func TestRunSkillsAndStamp_DedupHit(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.21"); err != nil { +func TestRunSkillsAndState_DedupHit(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil { t.Fatal(err) } called := false updater := &selfupdate.Updater{ - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { called = true return &selfupdate.NpmResult{} }, } - got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false) + got := runSkillsAndState(updater, newTestIO(), "1.0.21", false) if got != nil { - t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got) + t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got) } if called { - t.Error("SkillsUpdateOverride called, want skipped due to dedup") + t.Error("SkillsCommandOverride called, want skipped due to dedup") } } -func TestRunSkillsAndStamp_DedupForceBypass(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.21"); err != nil { +func TestRunSkillsAndState_DedupForceBypass(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil { t.Fatal(err) } called := false updater := &selfupdate.Updater{ - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { called = true - return &selfupdate.NpmResult{} + return successfulSkillsCommand()(args...) }, } - got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true) - if got == nil { - t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil") + got := runSkillsAndState(updater, newTestIO(), "1.0.21", true) + if got == nil || got.Err != nil { + t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got) } if !called { - t.Error("SkillsUpdateOverride not called with force=true") + t.Error("SkillsCommandOverride not called with force=true") } } -func TestRunSkillsAndStamp_SuccessWritesStamp(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - updater := &selfupdate.Updater{ - SkillsUpdateOverride: func() *selfupdate.NpmResult { - return &selfupdate.NpmResult{} - }, - } - got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false) +func TestRunSkillsAndState_SuccessWritesState(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()} + got := runSkillsAndState(updater, newTestIO(), "1.0.21", false) if got == nil || got.Err != nil { - t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got) + t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got) } - stamp, _ := skillscheck.ReadStamp() - if stamp != "1.0.21" { - t.Errorf("stamp = %q, want \"1.0.21\"", stamp) + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.21" { + t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version) } } -func TestRunSkillsAndStamp_FailureKeepsOldStamp(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.20"); err != nil { +func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil { t.Fatal(err) } updater := &selfupdate.Updater{ - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { r := &selfupdate.NpmResult{} r.Err = fmt.Errorf("npx failed") return r }, } - got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false) + got := runSkillsAndState(updater, newTestIO(), "1.0.21", false) if got == nil || got.Err == nil { - t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got) + t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got) } - stamp, _ := skillscheck.ReadStamp() - if stamp != "1.0.20" { - t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp) + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.20" { + t.Errorf("state.Version = %q, want \"1.0.20\" (failure must not overwrite)", state.Version) } } @@ -973,8 +991,7 @@ func TestTruncate(t *testing.T) { } func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) origFetch := fetchLatest origCur := currentVersion @@ -987,9 +1004,9 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) { t.Cleanup(func() { newUpdater = origNew }) newUpdater = func() *selfupdate.Updater { return &selfupdate.Updater{ - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { skillsCalled = true - return &selfupdate.NpmResult{} + return successfulSkillsCommand()(args...) }, } } @@ -1000,17 +1017,19 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) { t.Fatalf("updateRun() err = %v, want nil", err) } if !skillsCalled { - t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called") + t.Error("skills sync not called in already-up-to-date branch") } - stamp, _ := skillscheck.ReadStamp() - if stamp != "1.0.21" { - t.Errorf("stamp = %q, want \"1.0.21\"", stamp) + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.21" { + t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version) } } func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) origFetch := fetchLatest origCur := currentVersion @@ -1029,9 +1048,9 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) { ResolvedPath: "/usr/local/bin/lark-cli", } }, - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { skillsCalled = true - return &selfupdate.NpmResult{} + return successfulSkillsCommand()(args...) }, } } @@ -1042,17 +1061,19 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) { t.Fatalf("updateRun() err = %v, want nil", err) } if !skillsCalled { - t.Error("RunSkillsUpdate not called in manual branch, want called") + t.Error("skills sync not called in manual branch") } - stamp, _ := skillscheck.ReadStamp() - if stamp != "1.0.21" { - t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp) + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.21" { + t.Errorf("state.Version = %q, want \"1.0.21\" (manual path records current binary)", state.Version) } } -func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) +func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) origFetch := fetchLatest origCur := currentVersion @@ -1075,9 +1096,9 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) { return &selfupdate.NpmResult{} }, VerifyOverride: func(expectedVersion string) error { return nil }, - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { skillsCalled = true - return &selfupdate.NpmResult{} + return successfulSkillsCommand()(args...) }, } } @@ -1088,18 +1109,25 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) { t.Fatalf("updateRun() err = %v, want nil", err) } if !skillsCalled { - t.Error("RunSkillsUpdate not called in npm branch") + t.Error("skills sync not called in npm branch") } - stamp, _ := skillscheck.ReadStamp() - if stamp != "1.0.22" { - t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp) + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.22" { + t.Errorf("state.Version = %q, want \"1.0.22\" (npm path records latest binary)", state.Version) } } func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.20"); err != nil { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := skillscheck.WriteState(skillscheck.SkillsState{ + Version: "1.0.20", + OfficialSkills: []string{"lark-calendar", "lark-mail"}, + UpdatedSkills: []string{"lark-calendar"}, + SkippedDeletedSkills: []string{"lark-mail"}, + }); err != nil { t.Fatal(err) } @@ -1117,9 +1145,9 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) { DetectOverride: func() selfupdate.DetectResult { return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true} }, - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { skillsCalled = true - return &selfupdate.NpmResult{} + return successfulSkillsCommand()(args...) }, } } @@ -1130,7 +1158,7 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) { t.Fatalf("updateRun(--check) err = %v, want nil", err) } if skillsCalled { - t.Error("RunSkillsUpdate called under --check, want skipped (pure report)") + t.Error("skills sync called under --check, want skipped") } var env map[string]interface{} @@ -1144,12 +1172,14 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) { if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false { t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status) } + if status["official"] != float64(2) || status["updated"] != float64(1) { + t.Errorf("skills_status counts = %+v, want official:2 updated:1", status) + } } func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := skillscheck.WriteStamp("1.0.20"); err != nil { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil { t.Fatal(err) } @@ -1164,9 +1194,9 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) { t.Cleanup(func() { newUpdater = origNew }) newUpdater = func() *selfupdate.Updater { return &selfupdate.Updater{ - SkillsUpdateOverride: func() *selfupdate.NpmResult { + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { skillsCalled = true - return &selfupdate.NpmResult{} + return successfulSkillsCommand()(args...) }, } } @@ -1177,12 +1207,15 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) { t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err) } if skillsCalled { - t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)") + t.Error("skills sync called under --check (already-latest), want skipped") } - stamp, _ := skillscheck.ReadStamp() - if stamp != "1.0.20" { - t.Errorf("stamp mutated to %q under --check, want \"1.0.20\" (pure report must not write stamp)", stamp) + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.20" { + t.Errorf("state.Version mutated to %q under --check, want \"1.0.20\"", state.Version) } var env map[string]interface{} @@ -1204,39 +1237,248 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) { } } -// TestRunSkillsAndStamp_StampWriteFailureWarns verifies the stderr warning -// emission when RunSkillsUpdate succeeds but WriteStamp fails. -func TestRunSkillsAndStamp_StampWriteFailureWarns(t *testing.T) { - // Force WriteStamp to fail by pointing config dir at a path that exists - // as a regular file (so MkdirAll fails). - tmp := t.TempDir() - badPath := filepath.Join(tmp, "blocker") - if err := os.WriteFile(badPath, []byte("not-a-dir"), 0o644); err != nil { - t.Fatal(err) +func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) { + origSync := syncSkills + syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { + return &skillscheck.SyncResult{Err: fmt.Errorf("skills synced but state not written: denied")} } - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath) + t.Cleanup(func() { syncSkills = origSync }) f, _, stderr := newTestFactory(t) - updater := &selfupdate.Updater{ - SkillsUpdateOverride: func() *selfupdate.NpmResult { - return &selfupdate.NpmResult{} // success - }, + got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false) + if got == nil || got.Err == nil { + t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got) } - got := runSkillsAndStamp(updater, f.IOStreams, "1.0.21", false) - if got == nil || got.Err != nil { - t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got) - } - if !strings.Contains(stderr.String(), "warning: skills synced but stamp not written") { + if !strings.Contains(stderr.String(), "warning: skills synced but state not written") { t.Errorf("stderr does not contain warning: %q", stderr.String()) } } -// TestEmitSkillsTextHints_Success verifies the "Skills updated" success -// message is printed to ErrOut on a successful (Err == nil) result. func TestEmitSkillsTextHints_Success(t *testing.T) { f, _, stderr := newTestFactory(t) - emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success + emitSkillsTextHints(f.IOStreams, &skillscheck.SyncResult{Official: []string{"lark-calendar"}, Updated: []string{"lark-calendar"}}) if !strings.Contains(stderr.String(), "Skills updated") { t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String()) } } + +// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that +// verifies "lark-cli update" correctly triggers skills sync and rewrites the +// state file. It calls the real npx skills CLI, so the test is skipped when +// npx or the skills registry is unavailable (e.g. no network or fork PRs). +func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) { + // Phase 1: Verify the real npx skills CLI is available; skip otherwise. + if _, err := exec.LookPath("npx"); err != nil { + t.Skipf("npx not found in PATH: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil { + t.Skipf("real skills CLI unavailable: %v", err) + } + globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output() + if err != nil { + t.Skipf("real global skills CLI unavailable: %v", err) + } + localSkills := skillscheck.ParseSkillsList(string(globalOut)) + if err := ctx.Err(); err != nil { + t.Skipf("real skills CLI availability check timed out: %v", err) + } + + // Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19. + // lark-doc and lark-mail are recorded as skipped/deleted, meaning the user + // intentionally removed them while they were still official skills. + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + before := skillscheck.SkillsState{ + Version: "1.0.19", + OfficialSkills: []string{"lark-approval", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"}, + UpdatedSkills: []string{"lark-approval", "lark-apps", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"}, + AddedOfficialSkills: []string{}, + SkippedDeletedSkills: []string{}, + UpdatedAt: "2026-05-20T00:00:00Z", + } + if err := skillscheck.WriteState(before); err != nil { + t.Fatal(err) + } + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() before update = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.19" { + t.Fatalf("state.Version before update = %q, want 1.0.19", state.Version) + } + + // Phase 3: Mock version functions so the update command believes it has + // upgraded from 1.0.19 to 1.0.20, then execute "lark-cli update --json". + // This triggers SyncSkills which calls the real npx skills add command. + origFetch := fetchLatest + origVersion := currentVersion + t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion }) + fetchLatest = func() (string, error) { return "1.0.20", nil } + currentVersion = func() string { return "1.0.20" } + + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("lark-cli update --json err = %v, want nil", err) + } + + // Phase 4: Verify the state file was rewritten with the new version, + // non-empty official/updated skill lists, and a refreshed timestamp. + state, readable, err = skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.20" { + t.Errorf("state.Version after update = %q, want 1.0.20", state.Version) + } + if len(state.OfficialSkills) == 0 { + t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state) + } + if len(state.UpdatedSkills) == 0 { + t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state) + } + if state.UpdatedAt == "" || state.UpdatedAt == before.UpdatedAt { + t.Errorf("state.UpdatedAt = %q, want refreshed non-empty timestamp", state.UpdatedAt) + } + // Verify that previously-skipped skills are handled correctly: + // - If locally installed → should appear in UpdatedSkills (updated to latest) + // - If locally absent → should NOT be force-restored in UpdatedSkills, + // and should remain in SkippedDeletedSkills + for _, skill := range []string{"lark-doc", "lark-mail"} { + if containsString(localSkills, skill) { + if !containsString(state.UpdatedSkills, skill) { + t.Errorf("state.UpdatedSkills = %v, want installed skill %q updated", state.UpdatedSkills, skill) + } + continue + } + if containsString(state.UpdatedSkills, skill) { + t.Errorf("state.UpdatedSkills = %v, want deleted skill %q not restored without --force", state.UpdatedSkills, skill) + } + if !containsString(state.SkippedDeletedSkills, skill) { + t.Errorf("state.SkippedDeletedSkills = %v, want deleted skill %q preserved when still official", state.SkippedDeletedSkills, skill) + } + } + + // Phase 5: Verify the JSON output structure is parseable and contains + // the expected action fields for AI agent consumption. + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String()) + } + if env["action"] != "already_up_to_date" { + t.Errorf("action = %v, want already_up_to_date", env["action"]) + } + if env["skills_action"] != "synced" { + t.Errorf("skills_action = %v, want synced", env["skills_action"]) + } +} + +// TestUpdateCommand_SkillsSyncColdStart verifies that when skills-state.json does +// not exist (cold start), the update command installs all official skills and +// writes a fresh state file. No skill should appear in SkippedDeletedSkills +// because there is no previous state to preserve user deletions from. +// This is a live integration test that calls the real npx skills CLI; it is +// skipped when npx or the skills registry is unavailable. +func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) { + // Phase 1: Verify the real npx skills CLI is available; skip otherwise. + if _, err := exec.LookPath("npx"); err != nil { + t.Skipf("npx not found in PATH: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil { + t.Skipf("real skills CLI unavailable: %v", err) + } + globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output() + if err != nil { + t.Skipf("real global skills CLI unavailable: %v", err) + } + localSkills := skillscheck.ParseSkillsList(string(globalOut)) + if err := ctx.Err(); err != nil { + t.Skipf("real skills CLI availability check timed out: %v", err) + } + + // Phase 2: Use an isolated config dir with no pre-existing skills-state.json. + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if _, readable, _ := skillscheck.ReadState(); readable { + t.Fatal("skills-state.json should not exist before update") + } + + // Phase 3: Mock version functions so the update command believes it is at + // v1.0.20, then execute "lark-cli update --json". This triggers SyncSkills + // which calls the real npx skills add command. + origFetch := fetchLatest + origVersion := currentVersion + t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion }) + fetchLatest = func() (string, error) { return "1.0.20", nil } + currentVersion = func() string { return "1.0.20" } + + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("lark-cli update --json err = %v, want nil", err) + } + + // Phase 4: Verify the state file was created with all official skills in + // UpdatedSkills and nothing in SkippedDeletedSkills (cold start = no prior + // deletions to honor). Locally installed skills should appear in UpdatedSkills. + state, readable, err := skillscheck.ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.20" { + t.Errorf("state.Version = %q, want 1.0.20", state.Version) + } + if len(state.OfficialSkills) == 0 { + t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state) + } + if len(state.UpdatedSkills) == 0 { + t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state) + } + if state.UpdatedAt == "" { + t.Error("state.UpdatedAt is empty, want non-empty timestamp") + } + // All locally installed official skills must appear in UpdatedSkills. + officialSet := map[string]bool{} + for _, s := range state.OfficialSkills { + officialSet[s] = true + } + for _, skill := range localSkills { + if !officialSet[skill] { + continue + } + if !containsString(state.UpdatedSkills, skill) { + t.Errorf("state.UpdatedSkills = %v, want locally installed official skill %q updated", state.UpdatedSkills, skill) + } + } + // No skill should be in SkippedDeletedSkills on cold start — there is no + // previous state recording a user deletion to preserve. + if len(state.SkippedDeletedSkills) != 0 { + t.Errorf("state.SkippedDeletedSkills = %v, want empty on cold start", state.SkippedDeletedSkills) + } + + // Phase 5: Verify the JSON output structure is parseable and contains + // the expected action fields for AI agent consumption. + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String()) + } + if env["action"] != "already_up_to_date" { + t.Errorf("action = %v, want already_up_to_date", env["action"]) + } + if env["skills_action"] != "synced" { + t.Errorf("skills_action = %v, want synced", env["skills_action"]) + } +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/errs/ERROR_CONTRACT.md b/errs/ERROR_CONTRACT.md index e834be18b..b13bea963 100644 --- a/errs/ERROR_CONTRACT.md +++ b/errs/ERROR_CONTRACT.md @@ -34,10 +34,12 @@ in production? See **Troubleshooting**. 6. Wrapping is idempotent: re-wrapping an already-typed error returns it unchanged across the `errors.As` / `errors.Unwrap` chain. 7. For the typed-envelope path, exit codes derive from `Category` only - via `output.ExitCodeForCategory`. Two stage-1 exceptions: - `SecurityPolicyError` always exits `1` (fixed by its legacy envelope), - and unmigrated `*output.ExitError` producers carry a hand-set `Code`; - both are retired in the legacy-removal stage. + via `output.ExitCodeForCategory` — including `SecurityPolicyError`, + which exits `6` via `CategoryPolicy`. Unmigrated `*output.ExitError` + producers still carry a hand-set `Code` until they finish migrating. + `output.ErrBare(code)` is the lone exception: a deliberate + predicate-command signal that bypasses the envelope (see + **Predicate commands** below). ## Wire format @@ -73,9 +75,11 @@ Typed errors render to **stderr** as one JSON object per process exit: | `error.retryable` | wire-stable | `true` when present; omitted when `false` | | per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` | -Carve-out: `SecurityPolicyError` keeps the legacy -`{type: "auth_error", code: "challenge_required"|"access_denied", ...}` -envelope until its consumers migrate. Removal is staged in **Migration**. +`SecurityPolicyError` renders through the same typed envelope as every +other category. `error.type` is `"policy"`, `error.subtype` is one of +`challenge_required` / `access_denied`, and process exit is `6` via +`CategoryPolicy`. The legacy `auth_error` envelope at exit `1` has been +retired. ## Categories @@ -115,10 +119,11 @@ Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`. │ ▼ cmd/root.go handleRootError dispatches: - ├─ *errs.SecurityPolicyError → legacy "auth_error" JSON envelope; exit 1 + ├─ output.ErrBare(code) → no envelope (stdout already written); exit = code ├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err) - ├─ *core.ConfigError → asExitError adapts to legacy envelope ↓ - ├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code + │ (includes *errs.SecurityPolicyError → policy envelope, exit 6) + ├─ *core.ConfigError → promoted to typed via errcompat ↑ + ├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code └─ untyped / Cobra error → plain "Error: " (no envelope); exit 1 ``` @@ -127,6 +132,54 @@ stderr. Untyped errors (including Cobra's "required flag missing" / unknown subcommand messages) print plain text and exit `1` — consumers must tolerate that fallback. +### Predicate commands (`output.ErrBare`) + +A small class of commands is **predicates**: they answer a yes/no +question and signal the answer through the shell exit code so callers +can write `if cmd; then ... fi`. `lark-cli auth check` is the canonical +example — its `README` contract is `exit 0 = ok, 1 = missing`. + +These commands deliberately: + +1. write a structured JSON answer to **stdout** themselves, and +2. return `output.ErrBare(exitCode)` to communicate the exit code to + the dispatcher without producing a `stderr` envelope. + +`output.ErrBare` is **not** an error in the typed-envelope sense — it +carries no category, subtype, or message. It is a one-bit output- +control signal that lives outside the contract for the same reason +`grep -q` / `diff` / `systemctl is-active` set non-zero exit codes +without printing anything to stderr: pollution of stderr by a +predicate's negative answer would break `2>/dev/null` log hygiene in +caller scripts. + +New code should not reach for `ErrBare` unless the command is +genuinely a predicate. Anything carrying recoverable error content +belongs in a typed `*errs.XxxError` — or, for a batch result, in the +partial-failure outcome below. + +### Partial failure (batch / multi-status) + +A batch command (e.g. `drive +push` / `+pull` / `+sync`) that processes +many items can finish in a third state, neither full success nor a single +error: some items succeeded and some failed. Its primary output is the +per-item result, so it does **not** belong in a `stderr` error envelope. + +Such a command returns `runtime.OutPartialFailure(data, meta)`, which: + +1. writes the full result to **stdout** as an `ok:false` envelope — the + summary and every per-item outcome (succeeded *and* failed) stay + machine-readable, exactly as a successful `Out(...)` would carry them, + but with `ok` honestly reporting failure; and +2. returns `*output.PartialFailureError`, a typed exit signal the + dispatcher maps to a non-zero exit code while writing nothing further + to `stderr`. + +This is distinct from `ErrBare` (a predicate's one-bit answer) and from a +typed `*errs.XxxError` (a `stderr` error envelope): a partial failure is a +*result*, reported on stdout, that also failed. Consumers branch on +`ok == false` and then read `data.summary` / `data.items[]`. + ## Consumers ### Go (in-process) @@ -183,17 +236,25 @@ reworded without notice. ### Quick reference +The canonical producer surface is the **builder API in `errs/types.go`** (per type: struct + `NewXxxError` + chained `WithX` setters live in one place): +each `NewXxxError(subtype, format, args...)` locks `Category` at the +constructor name, requires `Subtype` + `Message` positionally, and exposes +optional fields via chained `.WithX(...)` setters. Struct literals remain +legal for framework dynamic paths (e.g. classifier fanout) but the lint +`CheckTypedErrorCompleteness` still requires `Category` + `Subtype` + +`Message` on any literal it sees. + | Situation | Use | |-----------|-----| -| Bad user input | `&errs.ValidationError{...}` or `output.ErrValidation(msg)` | -| Login required | `&errs.AuthenticationError{...}` | +| Bad user input | `errs.NewValidationError(subtype, msg).WithParam("--flag")` | +| Login required | `errs.NewAuthenticationError(errs.SubtypeTokenMissing, msg)` | | Token lacks scope | `errclass.BuildAPIError(resp, ctx)` | -| Local config missing | `&errs.ConfigError{...}` | -| Transport failure | `&errs.NetworkError{...}` | +| Local config missing | `errs.NewConfigError(errs.SubtypeNotConfigured, msg)` | +| Transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTimeout, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport`) | | Lark API error | `errclass.BuildAPIError(resp, ctx)` | -| SDK / decode bug | `&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, ...}}` | -| Policy block | `&errs.SecurityPolicyError{...}` or `&errs.ContentSafetyError{...}` | -| Needs `--yes` | `&errs.ConfirmationRequiredError{...}` | +| SDK / decode bug | `errs.NewInternalError(errs.SubtypeSDKError, msg).WithCause(err)` | +| Policy block | `errs.NewSecurityPolicyError(subtype, msg).WithChallengeURL(url)` or `errs.NewContentSafetyError(subtype, msg).WithRules(...)` | +| Needs `--yes` | `errs.NewConfirmationRequiredError(risk, action, msg)` | ### Authoring discipline @@ -242,8 +303,9 @@ Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory` maps `Category` to the shell code. A new exit-code requirement means a new `Category`, not a one-off override at the call site. -(Legacy `*output.ExitError` and `SecurityPolicyError` retain hand-set -codes during stage 1.) +(Legacy `*output.ExitError` retains hand-set codes until removal; +`SecurityPolicyError` retains a hand-set code on main until the framework +migration PR retires the carve-out — see **Migration**.) #### Split `Message`, `Hint`, and `Cause` @@ -265,15 +327,10 @@ do not inline its `.Error()` into `Message`. Conforming: ```go -return &errs.NetworkError{ - Problem: errs.Problem{ - Category: errs.CategoryNetwork, - Subtype: errs.SubtypeNetworkTransport, - Message: "request to /open-apis failed after 3 retries", - Hint: "check connectivity and retry; set --log-level debug if it persists", - }, - Cause: ioErr, -} +return errs.NewNetworkError(errs.SubtypeNetworkTransport, + "request to /open-apis failed after 3 retries"). + WithHint("check connectivity and retry; set --log-level debug if it persists"). + WithCause(ioErr) ``` Non-conforming: @@ -294,43 +351,51 @@ For positional arguments, use the canonical name without dashes ### Constructing typed errors -The minimal struct literal: +Prefer the **builder API**. The constructor pins `Category` + `Subtype` + +`Message`, the chained setters fill optional fields, and the resulting +value retains its concrete `*XxxError` pointer through the chain so +type-specific setters remain reachable to the end: ```go -return &errs.ValidationError{ - Problem: errs.Problem{ - Category: errs.CategoryValidation, - Subtype: errs.SubtypeInvalidArgument, - Message: fmt.Sprintf("--data must be a valid JSON object: %v", parseErr), - }, - Param: "--data", -} +return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--data must be a valid JSON object: %v", parseErr). + WithParam("--data") ``` +Why builder over struct literal: + +- `Category` is locked at the function name — caller cannot mis-specify it +- `Subtype` and `Message` are positional arguments — `go build` rejects + the call site if either is missing +- The chain reads top-down: required identity first, optional fields after +- Message is `fmt.Sprintf`-formatted from `(format, args...)`, matching + `fmt.Errorf` muscle memory and avoiding a separate `Sprintf` line + +Struct literals remain legal — `CheckTypedErrorCompleteness` continues to +enforce `Category` + `Subtype` + `Message` on any literal it sees — and +the framework classifier (`internal/errclass/classify.go`) still uses +them on the dynamic dispatch path where a `Problem` value is composed +once and wrapped per Category branch. Outside that pattern, new code +should reach for the builder. + Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`) -remain callable during migration; new code should prefer the struct -literal so `Hint`, `Param`, `Cause`, and other extension fields stay -available per [Split `Message`, `Hint`, and `Cause`](#split-message-hint-and-cause). +remain callable during migration but are `// Deprecated:` — new code goes +through the builder. #### Shortcut `Execute` walkthrough Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy form is `output.ErrValidation("--duration-minutes must be between 1 and -1440")`. The typed migration target: +1440")`. The typed migration target (builder form): ```go Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { duration := runtime.Int("duration-minutes") if duration < 1 || duration > 1440 { - return &errs.ValidationError{ - Problem: errs.Problem{ - Category: errs.CategoryValidation, - Subtype: errs.SubtypeInvalidArgument, - Message: fmt.Sprintf("--duration-minutes must be between 1 and 1440, got %d", duration), - Hint: "pass a value in [1, 1440]", - }, - Param: "--duration-minutes", - } + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--duration-minutes must be between 1 and 1440, got %d", duration). + WithHint("pass a value in [1, 1440]"). + WithParam("--duration-minutes") } _, err := runtime.DoAPI(req, opts) @@ -360,7 +425,7 @@ cover the decision: | Source | Decision | Example | |--------|----------|---------| | Helper returned a typed `*errs.*Error` | Return unchanged | `return err` | -| Helper returned an untyped error tied to user input (`strconv.Atoi`, `json.Unmarshal`, …) | Construct a typed error; put the untyped error in `Cause` | `return &errs.ValidationError{Problem: ..., Cause: jsonErr}` | +| Helper returned an untyped error tied to user input (`strconv.Atoi`, `json.Unmarshal`, …) | Construct a typed error; put the untyped error in `Cause` | `return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --data: %v", jsonErr).WithCause(jsonErr)` | | SDK call via `runtime.DoAPI` failed | Return unchanged — the framework boundary already wrapped it | `return err` | | Invariant broken (must-not-happen state) | Lift with `errs.WrapInternal`, set a `Message` describing the invariant | `return errs.WrapInternal(fmt.Errorf("identity resolver returned nil: %w", err))` | @@ -391,8 +456,11 @@ through `runtime.DoAPI`. #### Add a Subtype -1. Add a constant in `errs/subtypes.go` (framework) or - `errs/subtypes_service_.go` (service). +1. Add a constant in `errs/subtypes.go` under the right Category block. + Subtypes are framework-shared — service-specific Subtypes are an + anti-pattern (the wire `code` field already identifies the source + service; Subtype encodes cross-service semantics like `not_found`, + `quota_exceeded`). 2. If it maps from a Lark code, register the mapping in `internal/errclass/codemeta_.go`. 3. Add a dispatch test in `internal/errclass/classify_test.go`. @@ -409,10 +477,9 @@ emits a warning to keep them visible. Rare; the existing structs cover the 9 Categories with room. If you must: -1. Add the struct in `errs/types.go` embedding `errs.Problem`, with a - nil-receiver-safe `Unwrap()` if it carries `Cause`. +1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field. 2. Add an `IsXxx` predicate in `errs/predicates.go`. -3. Add a wire-format pin in `errs/marshal_test.go`. +3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_builder_test.go`. `CheckProblemEmbed` enforces the `Problem` embed at lint time. New top-level wire fields are forbidden — per-Subtype data goes into the @@ -448,51 +515,36 @@ will be removed once business migration completes. ## Migration -The error-contract refactor lands in stages. This PR is **stage 1**, and -its scope is **strictly framework-only**: every production wire shape -matches pre-PR byte-for-byte (additive fields only where the legacy slot -had no subtype emission). Stage 1 ships infrastructure; behavioural -migration of any specific path lives in later stages. +**Strategy shift (2026-05-26).** The original plan (`docs/design/errors-refactor/spec.md` v2.12 §9) was a centrally-driven 4-PR rollout — framework → auth domain → multi-pilot → full-repo + legacy removal. That plan is **superseded** by a hybrid model: framework owner ships framework-level hardening (including a typed `*errs.*Error` migration of `internal/**`) as one focused PR; business-domain typed migration is **self-service** via [`docs/errors-guide.md`](../docs/errors-guide.md) and the builder API, with no central sweep timeline. -Stages: +Why the shift: 800+ legacy call sites split across 8+ business domains do not all share a single reviewer's bandwidth, and the contract is now expressive enough that each domain owner can migrate their own code from the guide without coordinating with framework owner. -1. **Framework slice — this PR.** Ships the `errs/` typed taxonomy, - classifier (`internal/errclass`), promotion stub (`internal/errcompat`, - passthrough in stage 1), dispatcher hook (`WriteTypedErrorEnvelope`), - and six lint guards (forbidigo + five AST checks). Wire shapes - preserved byte-for-byte versus pre-PR, with **one intentional semantic - fix**: config-class errors (`*core.ConfigError`) now exit `3` instead - of `2`, aligning with `ExitCodeForCategory` (config errors share the - auth exit slot per the taxonomy). The classifier and promote helpers - are *shipped but unused* in production paths — they exist so stage 2+ - migrations can plug in without re-architecting. -2. **`SecurityPolicyError` typed envelope** — replace the legacy - `type: "auth_error"` carve-out with the typed shape. -3. **Business-domain migration**, one PR per domain in declared order: - `task → drive → calendar → im → mail → whiteboard → contact`. Each PR - moves the domain's `output.ErrAPI(...)` / `output.ErrAuth(...)` / - `output.ErrWithHint(...)` call sites to typed constructors or - `BuildAPIError`, removes its Deprecated annotations, and announces the - wire change explicitly. -4. **Framework-boundary migration**: `client.WrapDoAPIError` and - `client.WrapJSONResponseParseError` flip to typed wrap; - `client.CheckResponse` adopts `errclass.BuildAPIError`; - `internal/client/client.go resolveAccessToken` adopts the typed - `NeedAuthorizationError → *errs.AuthenticationError` recognition; - `cmd/auth/scopes.go` and `cmd/service/service.go` adopt typed - `*errs.PermissionError`; `errcompat.PromoteConfigError` lifts the - `Type="config"` (and later `Type="auth"`) branches to typed. -5. **Legacy removal** — once `git grep '\*output\.ExitError'` returns no - production hits, delete `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, - `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and - `ErrorEnvelope`. +### Current state -During migration, helper assertions accept both shapes (see -`shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) -so the build stays green domain-by-domain. +1. **Framework slice — ✅ shipped (PR #984).** The `errs/` typed taxonomy, classifier (`internal/errclass`), promotion stub (`internal/errcompat`, passthrough), dispatcher hook (`WriteTypedErrorEnvelope`), and the `lint/errscontract` AST guards. Wire shapes preserved byte-for-byte versus pre-PR, with **one intentional semantic fix**: config-class errors (`*core.ConfigError`) now exit `3` instead of `2`, aligning with `ExitCodeForCategory` (config errors share the auth exit slot per the taxonomy). The classifier and promote helpers are *shipped but unused* in production paths — they exist so framework migration can plug in without re-architecting. -Before / after at a call site (illustrative — actually performed in -stage 3): +2. **Builder API — ✅ shipped (this branch).** `errs/types.go` adds the canonical producer surface (`errs.NewXxxError(subtype, format, args...).WithX(...)`) for all 10 typed types, alongside each struct declaration. Constructor signature pins `Category` (via function name) and `Subtype` + `Message` (positional), so the producer cannot mis-specify any of the three identity fields. Optional fields chain through `.WithX(...)` setters that preserve the concrete pointer type. + +### Next: framework migration PR (planned) + +A single PR consolidates the work the original §9 spec split across PRs 2–4 — restricted to framework code, no business sweep: + +- **Migrate `internal/**` typed construction to the builder API.** ~16 call sites in `internal/errclass/classify.go` (BuildAPIError fanout), `internal/auth/transport.go` (SecurityPolicy), `internal/auth/uat_client.go`, `internal/errcompat/promote*.go`, `internal/client/client.go`, `internal/client/api_errors.go`. +- **Land the framework-side semantic changes** previously scoped to spec §9 PR 2: `SecurityPolicyError` exit `1→6`, `WrapDoAPIError` typed (`*NetworkError` with subtype timeout/tls/dns/server_error/transport, `*InternalError` for JSON-decode), `WrapJSONResponseParseError` typed, `errcompat.PromoteConfigError` real Type routing, `PromoteAuthError` helper + dispatcher wiring, 10 credential Lark codes registered in codeMeta, 99991543 config classification, `resolveAccessToken` typed `*AuthenticationError`, `BuildAPIError` filling `*PermissionError.MissingScopes` / `Identity` / `ConsoleURL`, deletion of `scopeAwareChecker`. +- **Add `forbidigo` rule** banning `output.Err*` constructors in `shortcuts/**` and `cmd/**` (mirrors the contract that new business code must use the builder). +- **CHANGELOG** lists the resulting ~10 shell-exit-code shifts in one release entry (vs the spec §1 spread of 11 — the remaining one site lives in `task` business code). + +### Business-domain migration (self-service, no central timeline) + +Each business package migrates its own `output.Err*` call sites to the builder when convenient — typically batched within one domain. The guide at [`docs/errors-guide.md`](../docs/errors-guide.md) walks owners through the 8 typical error modes (validation / authorization / authentication / config / network / api / internal / policy) with real `file:line` examples from main. The three-layer extension model (add Subtype / add field / add Category) handles cases the existing taxonomy does not cover. + +Helper assertions accept both shapes during migration (see `shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) so domain migrations stay green incrementally. + +### Legacy removal + +Deferred until business migration completion approaches the asymptote. `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and `ErrorEnvelope` are `// Deprecated:` today and stay callable. No fixed removal date. + +### Before / after at a call site ```go // before (legacy) @@ -502,6 +554,16 @@ return output.ErrAPI(larkCode, "create event failed", resp.RawBody()) return errclass.BuildAPIError(parsedResp, cc) ``` +```go +// before (legacy validation) +return output.ErrValidation("--duration-minutes must be between 1 and 1440") + +// after (builder) +return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--duration-minutes must be between 1 and 1440, got %d", duration). + WithParam("--duration-minutes") +``` + ## Troubleshooting **Envelope shows `type=api subtype=unknown` for what should be a more diff --git a/errs/marshal_test.go b/errs/marshal_test.go index d9ae9158e..b495ad78f 100644 --- a/errs/marshal_test.go +++ b/errs/marshal_test.go @@ -55,6 +55,28 @@ func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) { } } +func TestPermissionError_RequestedGrantedMarshal(t *testing.T) { + err := NewPermissionError(SubtypeMissingScope, "partial grant"). + WithRequestedScopes("docx:document", "im:message:send"). + WithGrantedScopes("docx:document"). + WithMissingScopes("im:message:send") + + b, e := json.Marshal(err) + if e != nil { + t.Fatal(e) + } + got := string(b) + for _, want := range []string{ + `"requested_scopes":["docx:document","im:message:send"]`, + `"granted_scopes":["docx:document"]`, + `"missing_scopes":["im:message:send"]`, + } { + if !strings.Contains(got, want) { + t.Errorf("envelope missing %s\nactual: %s", want, got) + } + } +} + func TestValidationError_MarshalJSON(t *testing.T) { ve := &ValidationError{ Problem: Problem{Category: CategoryValidation, Subtype: SubtypeInvalidArgument, Message: "bad"}, @@ -116,33 +138,26 @@ func TestConfigError_MarshalJSON(t *testing.T) { func TestNetworkError_MarshalJSON(t *testing.T) { ne := &NetworkError{ - Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTransport, Message: "transport"}, - CauseKind: "timeout", + Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"}, } b, _ := json.Marshal(ne) s := string(b) for _, want := range []string{ `"type":"network"`, - `"subtype":"transport"`, - `"cause":"timeout"`, + `"subtype":"timeout"`, } { if !strings.Contains(s, want) { t.Errorf("missing %q in %s", want, s) } } - - // CauseKind omitempty when "" - ne2 := &NetworkError{Problem: Problem{Category: CategoryNetwork, Message: "x"}} - b2, _ := json.Marshal(ne2) - if strings.Contains(string(b2), `"cause"`) { - t.Errorf("cause should be omitted when empty; got %s", b2) + if strings.Contains(s, `"cause"`) { + t.Errorf("cause field should no longer be on the wire; got %s", s) } } func TestAPIError_MarshalJSON(t *testing.T) { ae := &APIError{ Problem: Problem{Category: CategoryAPI, Subtype: SubtypeRateLimit, Code: 99991400, Message: "slow", Retryable: true}, - Detail: map[string]any{"raw": "value"}, } b, _ := json.Marshal(ae) s := string(b) @@ -151,19 +166,39 @@ func TestAPIError_MarshalJSON(t *testing.T) { `"subtype":"rate_limit"`, `"code":99991400`, `"retryable":true`, - `"detail":{`, - `"raw":"value"`, } { if !strings.Contains(s, want) { t.Errorf("missing %q in %s", want, s) } } +} - // Detail omitempty when nil - ae2 := &APIError{Problem: Problem{Category: CategoryAPI, Message: "x"}} - b2, _ := json.Marshal(ae2) - if strings.Contains(string(b2), `"detail"`) { - t.Errorf("detail should be omitted when nil; got %s", b2) +// TestProblem_MarshalJSON_Troubleshooter pins the upstream Lark API +// troubleshooter URL (resp.error.troubleshooter) surfacing on the wire under +// "troubleshooter". Carried via Problem so any typed error that embeds it +// inherits the field — populated by errclass.BuildAPIError before the +// category switch. +func TestProblem_MarshalJSON_Troubleshooter(t *testing.T) { + ae := &APIError{ + Problem: Problem{ + Category: CategoryAPI, + Subtype: SubtypeUnknown, + Code: 99991400, + Message: "x", + Troubleshooter: "https://open.feishu.cn/document/troubleshoot/abc", + }, + } + b, _ := json.Marshal(ae) + s := string(b) + if !strings.Contains(s, `"troubleshooter":"https://open.feishu.cn/document/troubleshoot/abc"`) { + t.Errorf("missing troubleshooter in %s", s) + } + + // Absent Troubleshooter must omit the wire key. + bare := &APIError{Problem: Problem{Category: CategoryAPI, Message: "x"}} + b2, _ := json.Marshal(bare) + if strings.Contains(string(b2), `"troubleshooter"`) { + t.Errorf("absent Troubleshooter must omit wire key; got %s", string(b2)) } } @@ -185,6 +220,32 @@ func TestSecurityPolicyError_MarshalJSON(t *testing.T) { } } +// Pin per-Subtype symmetry: SubtypeAccessDenied must serialize the same +// envelope shape as SubtypeChallengeRequired so callers can switch on +// subtype without conditional field probing. The constructor + builder +// path (mirroring how callsites actually construct these) is exercised +// here rather than the struct literal, since SubtypeAccessDenied is the +// path threaded through cmd/* sites that surface policy-deny outcomes. +func TestSecurityPolicyError_MarshalJSON_AccessDenied(t *testing.T) { + err := NewSecurityPolicyError(SubtypeAccessDenied, "user denied"). + WithChallengeURL("https://chal.example/2") + + b, e := json.Marshal(err) + if e != nil { + t.Fatal(e) + } + got := string(b) + for _, want := range []string{ + `"type":"policy"`, + `"subtype":"access_denied"`, + `"challenge_url":"https://chal.example/2"`, + } { + if !strings.Contains(got, want) { + t.Errorf("envelope missing %s\nactual: %s", want, got) + } + } +} + func TestContentSafetyError_MarshalJSON(t *testing.T) { cse := &ContentSafetyError{ Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("content_blocked"), Message: "blocked"}, diff --git a/errs/predicates.go b/errs/predicates.go index d3b1c8e92..736aefb9a 100644 --- a/errs/predicates.go +++ b/errs/predicates.go @@ -86,3 +86,12 @@ func IsAuthentication(err error) bool { var x *AuthenticationError; return error // IsConfig reports whether err is a *ConfigError. func IsConfig(err error) bool { var x *ConfigError; return errors.As(err, &x) } + +// IsTyped reports whether err is or wraps any of the typed *errs.* errors +// in this package (i.e. implements the TypedError interface). Used by call +// sites that need to pass already-classified errors through unchanged +// instead of blanket-rewrapping them as a different category. +func IsTyped(err error) bool { + var t TypedError + return errors.As(err, &t) +} diff --git a/errs/problem.go b/errs/problem.go index f9270f269..0208c8733 100644 --- a/errs/problem.go +++ b/errs/problem.go @@ -14,16 +14,21 @@ package errs // never appears on the wire. // - No DocURL field. PermissionError carries the same intent via its typed // ConsoleURL extension; other typed errors do not link out. +// - Troubleshooter is the upstream Lark API's diagnostic URL (resp.error. +// troubleshooter). Carried universally so any classified error can surface +// it; populated by errclass.BuildAPIError when the upstream response +// includes it, otherwise absent. // - Retryable uses omitempty so only `true` is emitted; consumers treat // absence as false. type Problem struct { - Category Category `json:"type"` - Subtype Subtype `json:"subtype,omitempty"` - Code int `json:"code,omitempty"` - Message string `json:"message"` - Hint string `json:"hint,omitempty"` - LogID string `json:"log_id,omitempty"` - Retryable bool `json:"retryable,omitempty"` + Category Category `json:"type"` + Subtype Subtype `json:"subtype,omitempty"` + Code int `json:"code,omitempty"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + LogID string `json:"log_id,omitempty"` + Troubleshooter string `json:"troubleshooter,omitempty"` + Retryable bool `json:"retryable,omitempty"` } // Error satisfies the standard `error` interface. A nil receiver is treated diff --git a/errs/subtypes.go b/errs/subtypes.go index 35b2d2788..68a5dd377 100644 --- a/errs/subtypes.go +++ b/errs/subtypes.go @@ -12,7 +12,8 @@ const ( // CategoryValidation subtypes const ( - SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment) + SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment) + SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment) ) // CategoryAuthentication subtypes @@ -34,7 +35,8 @@ const ( SubtypeAppScopeNotApplied Subtype = "app_scope_not_applied" // app did not apply for this scope on the open platform SubtypeTokenScopeInsufficient Subtype = "token_scope_insufficient" // token was issued without this scope (RFC 6750 alignment) SubtypeAppUnavailable Subtype = "app_unavailable" // app status unavailable - SubtypeAppNotInstalled Subtype = "app_not_installed" // app not enabled / not installed in this tenant + SubtypeAppDisabled Subtype = "app_disabled" // app currently disabled in this tenant (was installed/enabled before) + SubtypePermissionDenied Subtype = "permission_denied" // resource-level permission denial (authenticated but lacks rights for this resource, HTTP 403 / gRPC PERMISSION_DENIED alignment) ) // CategoryConfig subtypes @@ -46,7 +48,11 @@ const ( // CategoryNetwork subtypes const ( - SubtypeNetworkTransport Subtype = "transport" // transport-layer failure (timeout / TLS / DNS / 5xx); see NetworkError.CauseKind + SubtypeNetworkTransport Subtype = "transport" // fallback when no more-specific network subtype matches + SubtypeNetworkTimeout Subtype = "timeout" // dial / read timeout + SubtypeNetworkTLS Subtype = "tls" // TLS handshake / cert failure + SubtypeNetworkDNS Subtype = "dns" // DNS resolution failure + SubtypeNetworkServer Subtype = "server_error" // upstream HTTP 5xx ) // CategoryAPI subtypes @@ -57,6 +63,10 @@ const ( SubtypeCrossBrand Subtype = "cross_brand" // operation crosses brand boundary (feishu vs lark, not supported) SubtypeInvalidParameters Subtype = "invalid_parameters" // API-side parameter validation rejected the request SubtypeOwnershipMismatch Subtype = "ownership_mismatch" // caller is not the resource owner + SubtypeNotFound Subtype = "not_found" // referenced resource does not exist (HTTP 404 alignment) + SubtypeServerError Subtype = "server_error" // upstream server-side transient error (HTTP 5xx alignment, retryable) + SubtypeQuotaExceeded Subtype = "quota_exceeded" // resource quota / collection size limit reached (assignees, followers, members, etc.) + SubtypeAlreadyExists Subtype = "already_exists" // idempotency violation: resource already exists in target state ) // CategoryPolicy subtypes (security-policy envelope shape) @@ -69,7 +79,12 @@ const ( 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) + SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save) // Generic untyped error lifted to InternalError uses SubtypeUnknown. ) -// CategoryConfirmation subtypes intentionally have no declarations yet. +// CategoryConfirmation subtypes +const ( + SubtypeConfirmationRequired Subtype = "confirmation_required" // high-risk operation needs explicit --yes +) diff --git a/errs/subtypes_service_task.go b/errs/subtypes_service_task.go deleted file mode 100644 index 04464411f..000000000 --- a/errs/subtypes_service_task.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package errs - -// Service-specific Subtype declarations. Per-service files follow the -// naming pattern subtypes_service_.go so the framework's closed -// Subtype enum stays readable while service taxonomies remain visible. - -// Task service subtypes — consumed by internal/errclass/codemeta_task.go. -const ( - SubtypeTaskInvalidParams Subtype = "task_invalid_params" - SubtypeTaskPermissionDenied Subtype = "task_permission_denied" - SubtypeTaskNotFound Subtype = "task_not_found" - SubtypeTaskConflict Subtype = "task_conflict" - SubtypeTaskServerError Subtype = "task_server_error" - SubtypeTaskAssigneeLimit Subtype = "task_assignee_limit" - SubtypeTaskFollowerLimit Subtype = "task_follower_limit" - SubtypeTaskTasklistMemberLimit Subtype = "task_tasklist_member_limit" - SubtypeTaskReminderExists Subtype = "task_reminder_exists" -) diff --git a/errs/types.go b/errs/types.go index aa5718445..a253aa9e6 100644 --- a/errs/types.go +++ b/errs/types.go @@ -3,13 +3,80 @@ package errs +import ( + "fmt" + "slices" +) + +// formatMessage applies fmt.Sprintf only when args are present, so a +// caller passing a literal message with a stray "%" (e.g. "disk 100% full") +// is not rendered as "%!(NOVERB)". `go vet -printf` catches most accidental +// format misuse upstream; this guard makes the constructor safe even when +// the message string is dynamically composed. +func formatMessage(format string, args []any) string { + if len(args) == 0 { + return format + } + return fmt.Sprintf(format, args...) +} + +// Typed error types and their builder APIs. +// +// Each typed error has: +// - A struct embedding Problem, with type-specific extension fields +// - A nil-safe Unwrap() method when the struct carries a Cause field +// - A NewXxxError(subtype, format, args...) constructor — Category locked +// by the function name, Subtype + Message positional and required +// - Chainable WithX(...) setters that return the concrete *XxxError pointer +// so type-specific setters remain reachable to the end of the chain +// +// Preferred shape for new code: +// +// return errs.NewValidationError(errs.SubtypeInvalidArgument, +// "invalid --start: %v", err). +// WithHint("expected RFC3339, e.g. 2026-05-26T10:00:00Z"). +// WithParam("--start") +// +// Category is locked by the constructor name — it can never be mis-specified +// at the call site. Subtype + Message are required positional arguments so the +// compiler refuses to build a typed error missing either identity field. +// Subtype well-formedness is enforced at PR time by the lint guard +// CheckDeclaredSubtype (`lint/errscontract`), not at runtime, to avoid +// coupling the typed package to a registry. ad_hoc_* subtypes are accepted +// at runtime; CheckAdHocSubtype emits a follow-up warning. + +// TypedError is implemented by all typed errors in this package. +// It identifies a value as a typed envelope producer to the dispatcher, +// which uses it to short-circuit promotion when the outer error is +// already typed (avoiding overwrite of producer-set Subtype/Hint). +type TypedError interface { + error + ProblemDetail() *Problem +} + +// ============================== ValidationError ============================== + // ValidationError is the typed error for CategoryValidation. // Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap; // it is intentionally not serialized. type ValidationError struct { Problem - Param string `json:"param,omitempty"` - Cause error `json:"-"` + Param string `json:"param,omitempty"` + Params []InvalidParam `json:"params,omitempty"` + Cause error `json:"-"` +} + +// InvalidParam is one structured validation diagnostic: the parameter that +// failed (Name) and why (Reason). It mirrors an RFC 7807 "invalid-params" +// item (RFC 7807 §3.1 extension members). +// +// The wire key on ValidationError is "params" rather than "invalid_params" +// because the enclosing envelope already carries type:"validation", so the +// "invalid" qualifier would be redundant on the wire. The Go type keeps the +// InvalidParam prefix because, at package level, the name must self-describe. +type InvalidParam struct { + Name string `json:"name"` + Reason string `json:"reason"` } // Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse @@ -22,6 +89,65 @@ func (e *ValidationError) Unwrap() error { return e.Cause } +// Error returns the typed error message. Nil-safe — falls back to "" when the +// receiver is a typed nil pointer, mirroring the embedded Problem.Error() guard +// that promote-through-value-embed would otherwise bypass. +func (e *ValidationError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +// NewValidationError constructs a *ValidationError with Category locked to +// CategoryValidation and Message formatted via fmt.Sprintf(format, args...). +func NewValidationError(subtype Subtype, format string, args ...any) *ValidationError { + return &ValidationError{ + Problem: Problem{ + Category: CategoryValidation, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *ValidationError) WithHint(format string, args ...any) *ValidationError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *ValidationError) WithLogID(logID string) *ValidationError { + e.LogID = logID + return e +} + +func (e *ValidationError) WithCode(code int) *ValidationError { + e.Code = code + return e +} + +func (e *ValidationError) WithRetryable() *ValidationError { + e.Retryable = true + return e +} + +func (e *ValidationError) WithParam(param string) *ValidationError { + e.Param = param + return e +} + +func (e *ValidationError) WithParams(params ...InvalidParam) *ValidationError { + e.Params = append(e.Params, params...) + return e +} + +func (e *ValidationError) WithCause(cause error) *ValidationError { + e.Cause = cause + return e +} + +// =========================== AuthenticationError ============================= + // AuthenticationError is the typed error for CategoryAuthentication. // Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap; // it is intentionally not serialized. @@ -39,17 +165,150 @@ func (e *AuthenticationError) Unwrap() error { return e.Cause } -// PermissionError is the typed error for CategoryAuthorization. -type PermissionError struct { - Problem - MissingScopes []string `json:"missing_scopes,omitempty"` - Identity string `json:"identity,omitempty"` - ConsoleURL string `json:"console_url,omitempty"` +// Error is nil-receiver safe; see ValidationError.Error. +func (e *AuthenticationError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() } -// ConfigError is the typed error for CategoryConfig. +func NewAuthenticationError(subtype Subtype, format string, args ...any) *AuthenticationError { + return &AuthenticationError{ + Problem: Problem{ + Category: CategoryAuthentication, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *AuthenticationError) WithHint(format string, args ...any) *AuthenticationError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *AuthenticationError) WithLogID(logID string) *AuthenticationError { + e.LogID = logID + return e +} + +func (e *AuthenticationError) WithCode(code int) *AuthenticationError { + e.Code = code + return e +} + +func (e *AuthenticationError) WithRetryable() *AuthenticationError { + e.Retryable = true + return e +} + +func (e *AuthenticationError) WithUserOpenID(id string) *AuthenticationError { + e.UserOpenID = id + return e +} + +func (e *AuthenticationError) WithCause(cause error) *AuthenticationError { + e.Cause = cause + return e +} + +// ============================= PermissionError =============================== + +// PermissionError is the typed error for CategoryAuthorization. // Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap; // it is intentionally not serialized. +type PermissionError struct { + Problem + MissingScopes []string `json:"missing_scopes,omitempty"` + RequestedScopes []string `json:"requested_scopes,omitempty"` + GrantedScopes []string `json:"granted_scopes,omitempty"` + Identity string `json:"identity,omitempty"` + ConsoleURL string `json:"console_url,omitempty"` + Cause error `json:"-"` +} + +// Unwrap is nil-receiver safe; see ValidationError.Unwrap. +func (e *PermissionError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// Error is nil-receiver safe; see ValidationError.Error. +func (e *PermissionError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +func NewPermissionError(subtype Subtype, format string, args ...any) *PermissionError { + return &PermissionError{ + Problem: Problem{ + Category: CategoryAuthorization, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *PermissionError) WithHint(format string, args ...any) *PermissionError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *PermissionError) WithLogID(logID string) *PermissionError { + e.LogID = logID + return e +} + +func (e *PermissionError) WithCode(code int) *PermissionError { + e.Code = code + return e +} + +func (e *PermissionError) WithRetryable() *PermissionError { + e.Retryable = true + return e +} + +func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError { + e.MissingScopes = slices.Clone(scopes) + return e +} + +func (e *PermissionError) WithRequestedScopes(scopes ...string) *PermissionError { + e.RequestedScopes = slices.Clone(scopes) + return e +} + +func (e *PermissionError) WithGrantedScopes(scopes ...string) *PermissionError { + e.GrantedScopes = slices.Clone(scopes) + return e +} + +func (e *PermissionError) WithIdentity(identity string) *PermissionError { + e.Identity = identity + return e +} + +func (e *PermissionError) WithConsoleURL(url string) *PermissionError { + e.ConsoleURL = url + return e +} + +func (e *PermissionError) WithCause(cause error) *PermissionError { + e.Cause = cause + return e +} + +// =============================== ConfigError ================================= + +// ConfigError is the typed error for CategoryConfig. Cause preserves an +// optional wrapped sentinel for errors.Is / errors.Unwrap; it is +// intentionally not serialized. type ConfigError struct { Problem Field string `json:"field,omitempty"` @@ -64,15 +323,63 @@ func (e *ConfigError) Unwrap() error { return e.Cause } -// NetworkError is the typed error for CategoryNetwork. -// CauseKind (string) is one of: "timeout" | "tls" | "dns" | "5xx" — the -// canonical wire taxonomy (emitted as JSON key "cause"). Cause preserves an -// optional wrapped sentinel for errors.Is / errors.Unwrap; it is intentionally -// not serialized. +// Error is nil-receiver safe; see ValidationError.Error. +func (e *ConfigError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +func NewConfigError(subtype Subtype, format string, args ...any) *ConfigError { + return &ConfigError{ + Problem: Problem{ + Category: CategoryConfig, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *ConfigError) WithHint(format string, args ...any) *ConfigError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *ConfigError) WithLogID(logID string) *ConfigError { + e.LogID = logID + return e +} + +func (e *ConfigError) WithCode(code int) *ConfigError { + e.Code = code + return e +} + +func (e *ConfigError) WithRetryable() *ConfigError { + e.Retryable = true + return e +} + +func (e *ConfigError) WithField(field string) *ConfigError { + e.Field = field + return e +} + +func (e *ConfigError) WithCause(cause error) *ConfigError { + e.Cause = cause + return e +} + +// =============================== NetworkError ================================ + +// NetworkError is the typed error for CategoryNetwork. The Subtype carries +// the failure taxonomy: timeout / tls / dns / server_error, with transport +// as the fallback. Cause preserves an optional wrapped sentinel for +// errors.Is / errors.Unwrap; it is intentionally not serialized. type NetworkError struct { Problem - CauseKind string `json:"cause,omitempty"` - Cause error `json:"-"` + Cause error `json:"-"` } // Unwrap is nil-receiver safe; see ValidationError.Unwrap. @@ -83,13 +390,112 @@ func (e *NetworkError) Unwrap() error { return e.Cause } -// APIError is the typed error for CategoryAPI (catch-all for classified Lark API -// business errors). Detail preserves the raw Lark error map for diagnostics. +// Error is nil-receiver safe; see ValidationError.Error. +func (e *NetworkError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +func NewNetworkError(subtype Subtype, format string, args ...any) *NetworkError { + return &NetworkError{ + Problem: Problem{ + Category: CategoryNetwork, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *NetworkError) WithHint(format string, args ...any) *NetworkError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *NetworkError) WithLogID(logID string) *NetworkError { + e.LogID = logID + return e +} + +func (e *NetworkError) WithCode(code int) *NetworkError { + e.Code = code + return e +} + +func (e *NetworkError) WithRetryable() *NetworkError { + e.Retryable = true + return e +} + +func (e *NetworkError) WithCause(cause error) *NetworkError { + e.Cause = cause + return e +} + +// ================================ APIError =================================== + +// APIError is the typed error for CategoryAPI (catch-all for classified Lark +// API business errors). Cause preserves an optional wrapped sentinel for +// errors.Is / errors.Unwrap; it is intentionally not serialized. type APIError struct { Problem - Detail map[string]any `json:"detail,omitempty"` + Cause error `json:"-"` } +// Unwrap is nil-receiver safe; see ValidationError.Unwrap. +func (e *APIError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// Error is nil-receiver safe; see ValidationError.Error. +func (e *APIError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +func NewAPIError(subtype Subtype, format string, args ...any) *APIError { + return &APIError{ + Problem: Problem{ + Category: CategoryAPI, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *APIError) WithHint(format string, args ...any) *APIError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *APIError) WithLogID(logID string) *APIError { + e.LogID = logID + return e +} + +func (e *APIError) WithCode(code int) *APIError { + e.Code = code + return e +} + +func (e *APIError) WithRetryable() *APIError { + e.Retryable = true + return e +} + +func (e *APIError) WithCause(cause error) *APIError { + e.Cause = cause + return e +} + +// =========================== SecurityPolicyError ============================= + // SecurityPolicyError is the typed error for CategoryPolicy security-policy subtypes. // Subtype is "challenge_required" or "access_denied"; Code is 21000 or 21001. type SecurityPolicyError struct { @@ -106,14 +512,125 @@ func (e *SecurityPolicyError) Unwrap() error { return e.Cause } +// Error is nil-receiver safe; see ValidationError.Error. +func (e *SecurityPolicyError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +func NewSecurityPolicyError(subtype Subtype, format string, args ...any) *SecurityPolicyError { + return &SecurityPolicyError{ + Problem: Problem{ + Category: CategoryPolicy, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *SecurityPolicyError) WithHint(format string, args ...any) *SecurityPolicyError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *SecurityPolicyError) WithLogID(logID string) *SecurityPolicyError { + e.LogID = logID + return e +} + +func (e *SecurityPolicyError) WithCode(code int) *SecurityPolicyError { + e.Code = code + return e +} + +func (e *SecurityPolicyError) WithRetryable() *SecurityPolicyError { + e.Retryable = true + return e +} + +func (e *SecurityPolicyError) WithChallengeURL(url string) *SecurityPolicyError { + e.ChallengeURL = url + return e +} + +func (e *SecurityPolicyError) WithCause(cause error) *SecurityPolicyError { + e.Cause = cause + return e +} + +// ============================ ContentSafetyError ============================= + // ContentSafetyError is the typed error for CategoryPolicy content-safety subtypes. +// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap; +// it is intentionally not serialized. type ContentSafetyError struct { Problem Rules []string `json:"rules,omitempty"` + Cause error `json:"-"` } -// InternalError is the typed error for CategoryInternal. -// Cause is preserved for logging but not emitted on the wire. +// Unwrap is nil-receiver safe; see ValidationError.Unwrap. +func (e *ContentSafetyError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// Error is nil-receiver safe; see ValidationError.Error. +func (e *ContentSafetyError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +func NewContentSafetyError(subtype Subtype, format string, args ...any) *ContentSafetyError { + return &ContentSafetyError{ + Problem: Problem{ + Category: CategoryPolicy, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *ContentSafetyError) WithHint(format string, args ...any) *ContentSafetyError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *ContentSafetyError) WithLogID(logID string) *ContentSafetyError { + e.LogID = logID + return e +} + +func (e *ContentSafetyError) WithCode(code int) *ContentSafetyError { + e.Code = code + return e +} + +func (e *ContentSafetyError) WithRetryable() *ContentSafetyError { + e.Retryable = true + return e +} + +func (e *ContentSafetyError) WithRules(rules ...string) *ContentSafetyError { + e.Rules = slices.Clone(rules) + return e +} + +func (e *ContentSafetyError) WithCause(cause error) *ContentSafetyError { + e.Cause = cause + return e +} + +// =============================== InternalError =============================== + +// InternalError is the typed error for CategoryInternal. Cause is preserved +// for logging but not emitted on the wire. type InternalError struct { Problem Cause error `json:"-"` @@ -127,10 +644,127 @@ func (e *InternalError) Unwrap() error { return e.Cause } +// Error is nil-receiver safe; see ValidationError.Error. +func (e *InternalError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +func NewInternalError(subtype Subtype, format string, args ...any) *InternalError { + return &InternalError{ + Problem: Problem{ + Category: CategoryInternal, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *InternalError) WithHint(format string, args ...any) *InternalError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *InternalError) WithLogID(logID string) *InternalError { + e.LogID = logID + return e +} + +func (e *InternalError) WithCode(code int) *InternalError { + e.Code = code + return e +} + +func (e *InternalError) WithRetryable() *InternalError { + e.Retryable = true + return e +} + +func (e *InternalError) WithCause(cause error) *InternalError { + e.Cause = cause + return e +} + +// ========================= ConfirmationRequiredError ========================= + +// Risk classifies the impact of a confirmation-required operation. Every +// ConfirmationRequiredError MUST populate Risk; callers without a known +// risk level use RiskUnknown so the envelope is never wire-invalid. +const ( + RiskRead = "read" + RiskWrite = "write" + RiskHighRiskWrite = "high-risk-write" + RiskUnknown = "unknown" +) + // ConfirmationRequiredError is the typed error for CategoryConfirmation. -// Risk is one of: "read" | "write" | "high-risk-write". +// Risk is one of: "read" | "write" | "high-risk-write" | "unknown". +// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap; +// it is intentionally not serialized. type ConfirmationRequiredError struct { Problem Risk string `json:"risk"` Action string `json:"action"` + Cause error `json:"-"` +} + +// Unwrap is nil-receiver safe; see ValidationError.Unwrap. +func (e *ConfirmationRequiredError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// Error is nil-receiver safe; see ValidationError.Error. +func (e *ConfirmationRequiredError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +// NewConfirmationRequiredError constructs a *ConfirmationRequiredError. +// Risk + Action are wire-required (non-omitempty). Empty inputs are +// normalized at the constructor boundary so callers cannot build a +// wire-invalid envelope: risk falls back to RiskUnknown, action to +// "unknown". risk is one of: "read" | "write" | "high-risk-write". +func NewConfirmationRequiredError(risk, action, format string, args ...any) *ConfirmationRequiredError { + if risk == "" { + risk = RiskUnknown + } + if action == "" { + action = "unknown" + } + return &ConfirmationRequiredError{ + Problem: Problem{ + Category: CategoryConfirmation, + Subtype: SubtypeConfirmationRequired, + Message: formatMessage(format, args), + }, + Risk: risk, + Action: action, + } +} + +func (e *ConfirmationRequiredError) WithHint(format string, args ...any) *ConfirmationRequiredError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *ConfirmationRequiredError) WithLogID(logID string) *ConfirmationRequiredError { + e.LogID = logID + return e +} + +func (e *ConfirmationRequiredError) WithCode(code int) *ConfirmationRequiredError { + e.Code = code + return e +} + +func (e *ConfirmationRequiredError) WithCause(cause error) *ConfirmationRequiredError { + e.Cause = cause + return e } diff --git a/errs/types_test.go b/errs/types_test.go index 8d626f1fc..c59a862b1 100644 --- a/errs/types_test.go +++ b/errs/types_test.go @@ -1,20 +1,24 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package errs +package errs_test import ( "encoding/json" "errors" "strings" "testing" + + "github.com/larksuite/cli/errs" ) +// ============================== JSON shape & embed ============================== + func TestPermissionErrorJSONShape(t *testing.T) { - perm := &PermissionError{ - Problem: Problem{ - Category: CategoryAuthorization, - Subtype: SubtypeMissingScope, + perm := &errs.PermissionError{ + Problem: errs.Problem{ + Category: errs.CategoryAuthorization, + Subtype: errs.SubtypeMissingScope, Message: "x", }, MissingScopes: []string{"docx:document"}, @@ -53,35 +57,35 @@ func TestPermissionErrorJSONShape(t *testing.T) { // PermissionError embeds Problem. ProblemOf works around this by routing // via the unexported problemCarrier interface. func TestEmbedSemanticChasm(t *testing.T) { - perm := &PermissionError{ - Problem: Problem{ - Category: CategoryAuthorization, - Subtype: SubtypeMissingScope, + perm := &errs.PermissionError{ + Problem: errs.Problem{ + Category: errs.CategoryAuthorization, + Subtype: errs.SubtypeMissingScope, Message: "missing", }, } - var p *Problem + var p *errs.Problem if errors.As(perm, &p) { t.Errorf("errors.As(*PermissionError, &*Problem) unexpectedly succeeded; Go embed semantic changed") } - got, ok := ProblemOf(perm) + got, ok := errs.ProblemOf(perm) if !ok { t.Fatalf("ProblemOf(*PermissionError) returned ok=false; expected to extract embedded Problem") } if got != &perm.Problem { t.Errorf("ProblemOf returned %p, want &perm.Problem = %p", got, &perm.Problem) } - if got.Category != CategoryAuthorization { - t.Errorf("extracted Problem.Category = %q, want %q", got.Category, CategoryAuthorization) + if got.Category != errs.CategoryAuthorization { + t.Errorf("extracted Problem.Category = %q, want %q", got.Category, errs.CategoryAuthorization) } } func TestSecurityPolicyErrorUnwrap(t *testing.T) { orig := errors.New("transport stalled") - spe := &SecurityPolicyError{ - Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("challenge_required"), Message: "blocked"}, + spe := &errs.SecurityPolicyError{ + Problem: errs.Problem{Category: errs.CategoryPolicy, Subtype: errs.Subtype("challenge_required"), Message: "blocked"}, Cause: orig, } if got := errors.Unwrap(spe); got != orig { @@ -106,12 +110,12 @@ func TestTypedErrors_UnwrapNilReceiver(t *testing.T) { name string call func() error }{ - {"ValidationError", func() error { var e *ValidationError; return e.Unwrap() }}, - {"AuthenticationError", func() error { var e *AuthenticationError; return e.Unwrap() }}, - {"ConfigError", func() error { var e *ConfigError; return e.Unwrap() }}, - {"NetworkError", func() error { var e *NetworkError; return e.Unwrap() }}, - {"SecurityPolicyError", func() error { var e *SecurityPolicyError; return e.Unwrap() }}, - {"InternalError", func() error { var e *InternalError; return e.Unwrap() }}, + {"ValidationError", func() error { var e *errs.ValidationError; return e.Unwrap() }}, + {"AuthenticationError", func() error { var e *errs.AuthenticationError; return e.Unwrap() }}, + {"ConfigError", func() error { var e *errs.ConfigError; return e.Unwrap() }}, + {"NetworkError", func() error { var e *errs.NetworkError; return e.Unwrap() }}, + {"SecurityPolicyError", func() error { var e *errs.SecurityPolicyError; return e.Unwrap() }}, + {"InternalError", func() error { var e *errs.InternalError; return e.Unwrap() }}, } for _, c := range checks { t.Run(c.name, func(t *testing.T) { @@ -127,6 +131,44 @@ func TestTypedErrors_UnwrapNilReceiver(t *testing.T) { } } +// TestTypedError_NilReceiverError pins the nil-receiver guard on every typed +// error's Error(). Each typed error must define its own Error() method that +// nil-guards the outer pointer; the embedded Problem.Error()'s nil guard is +// bypassed because Go must dereference the outer pointer to reach the embedded +// field via value-embed promotion. +func TestTypedError_NilReceiverError(t *testing.T) { + // Each typed error must define its own Error() method that nil-guards + // the outer pointer; the embedded Problem.Error()'s nil guard is bypassed + // because Go must dereference the outer pointer to reach the embedded field. + cases := []struct { + name string + err error + }{ + {"ValidationError", (*errs.ValidationError)(nil)}, + {"AuthenticationError", (*errs.AuthenticationError)(nil)}, + {"PermissionError", (*errs.PermissionError)(nil)}, + {"ConfigError", (*errs.ConfigError)(nil)}, + {"NetworkError", (*errs.NetworkError)(nil)}, + {"APIError", (*errs.APIError)(nil)}, + {"InternalError", (*errs.InternalError)(nil)}, + {"SecurityPolicyError", (*errs.SecurityPolicyError)(nil)}, + {"ContentSafetyError", (*errs.ContentSafetyError)(nil)}, + {"ConfirmationRequiredError", (*errs.ConfirmationRequiredError)(nil)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("(*%s)(nil).Error() panicked: %v", tc.name, r) + } + }() + if got := tc.err.Error(); got != "" { + t.Errorf("(*%s)(nil).Error() = %q, want empty string", tc.name, got) + } + }) + } +} + // TestTypedErrors_UnwrapPropagatesCause pins the positive Unwrap path so the // nil-safety guard above does not silently drop a real Cause on non-nil // receivers. Without this, a buggy refactor could change `return e.Cause` to @@ -137,12 +179,12 @@ func TestTypedErrors_UnwrapPropagatesCause(t *testing.T) { name string err interface{ Unwrap() error } }{ - {"ValidationError", &ValidationError{Cause: cause}}, - {"AuthenticationError", &AuthenticationError{Cause: cause}}, - {"ConfigError", &ConfigError{Cause: cause}}, - {"NetworkError", &NetworkError{Cause: cause}}, - {"SecurityPolicyError", &SecurityPolicyError{Cause: cause}}, - {"InternalError", &InternalError{Cause: cause}}, + {"ValidationError", &errs.ValidationError{Cause: cause}}, + {"AuthenticationError", &errs.AuthenticationError{Cause: cause}}, + {"ConfigError", &errs.ConfigError{Cause: cause}}, + {"NetworkError", &errs.NetworkError{Cause: cause}}, + {"SecurityPolicyError", &errs.SecurityPolicyError{Cause: cause}}, + {"InternalError", &errs.InternalError{Cause: cause}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -152,3 +194,452 @@ func TestTypedErrors_UnwrapPropagatesCause(t *testing.T) { }) } } + +// =============================== Builder API =============================== + +// TestNewXxxError_LocksCategory verifies each constructor sets Category +// from its function name; caller cannot mis-specify it. +func TestNewXxxError_LocksCategory(t *testing.T) { + cases := []struct { + name string + got errs.Category + want errs.Category + }{ + {"validation", errs.NewValidationError(errs.SubtypeInvalidArgument, "x").Category, errs.CategoryValidation}, + {"authentication", errs.NewAuthenticationError(errs.SubtypeTokenMissing, "x").Category, errs.CategoryAuthentication}, + {"authorization", errs.NewPermissionError(errs.SubtypeMissingScope, "x").Category, errs.CategoryAuthorization}, + {"config", errs.NewConfigError(errs.SubtypeNotConfigured, "x").Category, errs.CategoryConfig}, + {"network", errs.NewNetworkError(errs.SubtypeNetworkTransport, "x").Category, errs.CategoryNetwork}, + {"api", errs.NewAPIError(errs.SubtypeRateLimit, "x").Category, errs.CategoryAPI}, + {"policy_security", errs.NewSecurityPolicyError(errs.SubtypeChallengeRequired, "x").Category, errs.CategoryPolicy}, + {"policy_content", errs.NewContentSafetyError(errs.SubtypeUnknown, "x").Category, errs.CategoryPolicy}, + {"internal", errs.NewInternalError(errs.SubtypeSDKError, "x").Category, errs.CategoryInternal}, + {"confirmation", errs.NewConfirmationRequiredError("write", "delete files", "x").Category, errs.CategoryConfirmation}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.want { + t.Errorf("Category = %q, want %q", tc.got, tc.want) + } + }) + } +} + +// TestNewXxxError_PrintfFormat verifies Message is formatted via fmt.Sprintf +// just like fmt.Errorf — the canonical Go convention for error messages. +func TestNewXxxError_PrintfFormat(t *testing.T) { + cause := errors.New("boom") + got := errs.NewValidationError(errs.SubtypeInvalidArgument, + "invalid --start (%s): %v", "yesterday", cause) + want := "invalid --start (yesterday): boom" + if got.Message != want { + t.Errorf("Message = %q, want %q", got.Message, want) + } +} + +// TestNewXxxError_LiteralPercentNoArgs pins the constructor's empty-args +// fast path: a literal "%" in the message must NOT be rendered as +// "%!(NOVERB)" when no args are passed. +func TestNewXxxError_LiteralPercentNoArgs(t *testing.T) { + got := errs.NewValidationError(errs.SubtypeInvalidArgument, "disk 100% full") + if got.Message != "disk 100% full" { + t.Errorf("Message = %q, want %q", got.Message, "disk 100% full") + } + hinted := errs.NewInternalError(errs.SubtypeStorage, "save failed"). + WithHint("only 5% headroom remains") + if hinted.Hint != "only 5% headroom remains" { + t.Errorf("Hint = %q, want %q", hinted.Hint, "only 5% headroom remains") + } +} + +// TestWithChain_ReturnsConcretePointer verifies WithX setters return the +// concrete *XxxError pointer, not *Problem — so chains preserve type and +// type-specific setters remain reachable to the end of the chain. +func TestWithChain_ReturnsConcretePointer(t *testing.T) { + // Chain composition: only compiles if every intermediate result has + // the concrete pointer type. Hint is on every type, Param is only on + // ValidationError — chain must keep ValidationError type to reach it. + got := errs.NewValidationError(errs.SubtypeInvalidArgument, "msg"). + WithHint("hint text"). + WithLogID("log-123"). + WithCode(42). + WithRetryable(). + WithParam("--start"). + WithCause(errors.New("boom")) + + if got.Hint != "hint text" { + t.Errorf("Hint = %q, want %q", got.Hint, "hint text") + } + if got.LogID != "log-123" { + t.Errorf("LogID = %q, want %q", got.LogID, "log-123") + } + if got.Code != 42 { + t.Errorf("Code = %d, want %d", got.Code, 42) + } + if !got.Retryable { + t.Errorf("Retryable = false, want true") + } + if got.Param != "--start" { + t.Errorf("Param = %q, want %q", got.Param, "--start") + } + if got.Cause == nil || got.Cause.Error() != "boom" { + t.Errorf("Cause = %v, want error 'boom'", got.Cause) + } +} + +// TestWithChain_MutatesReceiver verifies WithX returns the same pointer +// (not a copy) — chain edits propagate to the original construction. +func TestWithChain_MutatesReceiver(t *testing.T) { + e := errs.NewValidationError(errs.SubtypeInvalidArgument, "msg") + returned := e.WithHint("hint") + if returned != e { + t.Errorf("WithHint returned different pointer; want same as receiver") + } + if e.Hint != "hint" { + t.Errorf("Receiver Hint not mutated: got %q", e.Hint) + } +} + +// TestWithHint_PrintfFormat verifies WithHint follows fmt.Sprintf, matching +// the constructor's printf convention. +func TestWithHint_PrintfFormat(t *testing.T) { + got := errs.NewValidationError(errs.SubtypeInvalidArgument, "x"). + WithHint("expected one of: %v", []string{"7d", "1m"}) + want := "expected one of: [7d 1m]" + if got.Hint != want { + t.Errorf("Hint = %q, want %q", got.Hint, want) + } +} + +// TestPermissionError_FullChain verifies the most field-heavy typed error +// constructs cleanly via the chain. +func TestPermissionError_FullChain(t *testing.T) { + got := errs.NewPermissionError(errs.SubtypeMissingScope, + "--confirm-send requires scope: %s", "mail:user_mailbox.message:send"). + WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send"). + WithMissingScopes("mail:user_mailbox.message:send"). + WithIdentity("user"). + WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth") + + if got.Category != errs.CategoryAuthorization { + t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization) + } + if got.Subtype != errs.SubtypeMissingScope { + t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypeMissingScope) + } + if len(got.MissingScopes) != 1 || got.MissingScopes[0] != "mail:user_mailbox.message:send" { + t.Errorf("MissingScopes = %v, want [mail:user_mailbox.message:send]", got.MissingScopes) + } + if got.Identity != "user" { + t.Errorf("Identity = %q, want %q", got.Identity, "user") + } + if got.ConsoleURL == "" { + t.Error("ConsoleURL is empty") + } +} + +// TestWithMissingScopes_VariadicAndSliceExpansion verifies both forms work. +func TestWithMissingScopes_VariadicAndSliceExpansion(t *testing.T) { + t.Run("variadic", func(t *testing.T) { + got := errs.NewPermissionError(errs.SubtypeMissingScope, "x"). + WithMissingScopes("a:read", "b:write") + if len(got.MissingScopes) != 2 { + t.Errorf("got %v, want 2 elements", got.MissingScopes) + } + }) + t.Run("slice_expanded", func(t *testing.T) { + scopes := []string{"a:read", "b:write"} + got := errs.NewPermissionError(errs.SubtypeMissingScope, "x"). + WithMissingScopes(scopes...) + if len(got.MissingScopes) != 2 { + t.Errorf("got %v, want 2 elements", got.MissingScopes) + } + }) +} + +// TestNetworkError_SubtypeAndChain verifies that a network failure carries +// its canonical subtype, Retryable flag, and Unwrap chain together. +func TestNetworkError_SubtypeAndChain(t *testing.T) { + got := errs.NewNetworkError(errs.SubtypeNetworkTimeout, "download failed: %v", errors.New("timeout")). + WithCause(errors.New("context deadline exceeded")). + WithRetryable() + + if got.Subtype != errs.SubtypeNetworkTimeout { + t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypeNetworkTimeout) + } + if !got.Retryable { + t.Errorf("Retryable = false, want true") + } + if got.Cause == nil { + t.Error("Cause is nil") + } +} + +// TestNewConfirmationRequiredError_RequiresRiskAndAction verifies the +// constructor signature pins Risk + Action as positional args (non-omitempty +// wire fields per types.go). +func TestNewConfirmationRequiredError_RequiresRiskAndAction(t *testing.T) { + got := errs.NewConfirmationRequiredError("high-risk-write", "delete 42 files", + "this operation will delete %d files", 42) + + if got.Risk != "high-risk-write" { + t.Errorf("Risk = %q, want %q", got.Risk, "high-risk-write") + } + if got.Action != "delete 42 files" { + t.Errorf("Action = %q, want %q", got.Action, "delete 42 files") + } + if got.Message != "this operation will delete 42 files" { + t.Errorf("Message = %q", got.Message) + } +} + +// TestBuilder_ErrorsAsCompat verifies builder-constructed errors satisfy +// errors.As / errors.Is for both the typed wrapper and any wrapped cause. +func TestBuilder_ErrorsAsCompat(t *testing.T) { + cause := errors.New("upstream failure") + wrapped := errs.NewInternalError(errs.SubtypeSDKError, "wrap: %v", cause).WithCause(cause) + + var asInternal *errs.InternalError + if !errors.As(wrapped, &asInternal) { + t.Error("errors.As should resolve to *InternalError") + } + if !errors.Is(wrapped, cause) { + t.Error("errors.Is should resolve to original cause via Unwrap") + } +} + +// TestBuilder_WireFormat marshals a fully-built error and asserts the JSON +// matches the canonical envelope shape. This complements marshal_test.go; +// the focus here is verifying builder-set fields land in the right JSON +// keys. +func TestBuilder_WireFormat(t *testing.T) { + e := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope %s", "calendar:event:create"). + WithCode(99991679). + WithLogID("20260520-0a1b2c3d"). + WithHint("run lark-cli auth login --scope calendar:event:create"). + WithMissingScopes("calendar:event:create"). + WithIdentity("user"). + WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth") + + buf, err := json.Marshal(e) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(buf, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + wantFields := map[string]any{ + "type": "authorization", + "subtype": "missing_scope", + "code": float64(99991679), + "message": "missing scope calendar:event:create", + "hint": "run lark-cli auth login --scope calendar:event:create", + "log_id": "20260520-0a1b2c3d", + "identity": "user", + "console_url": "https://open.feishu.cn/app/cli_xxx/auth", + "missing_scopes": []any{"calendar:event:create"}, + } + for k, want := range wantFields { + gotVal, ok := got[k] + if !ok { + t.Errorf("missing wire field %q in %v", k, got) + continue + } + switch v := want.(type) { + case []any: + gotSlice, ok := gotVal.([]any) + if !ok || len(gotSlice) != len(v) { + t.Errorf("field %q = %v, want %v", k, gotVal, v) + continue + } + for i := range v { + if gotSlice[i] != v[i] { + t.Errorf("field %q[%d] = %v, want %v", k, i, gotSlice[i], v[i]) + } + } + default: + if gotVal != want { + t.Errorf("field %q = %v, want %v", k, gotVal, want) + } + } + } + + // retryable not set → must be absent (omitempty) + if _, present := got["retryable"]; present { + t.Errorf("retryable should be omitted when false, got %v", got["retryable"]) + } +} + +// TestBuilder_WithRetryable_OmittedWhenFalse verifies omitempty behaviour: +// retryable only appears on the wire when explicitly set to true. +func TestBuilder_WithRetryable_OmittedWhenFalse(t *testing.T) { + t.Run("absent_when_not_set", func(t *testing.T) { + e := errs.NewNetworkError(errs.SubtypeNetworkTransport, "x") + buf, _ := json.Marshal(e) + var got map[string]any + _ = json.Unmarshal(buf, &got) + if _, ok := got["retryable"]; ok { + t.Errorf("retryable present when unset; want omitted") + } + }) + t.Run("present_when_set", func(t *testing.T) { + e := errs.NewNetworkError(errs.SubtypeNetworkTransport, "x").WithRetryable() + buf, _ := json.Marshal(e) + var got map[string]any + _ = json.Unmarshal(buf, &got) + v, ok := got["retryable"] + if !ok || v != true { + t.Errorf("retryable = %v ok=%v, want true present", v, ok) + } + }) +} + +// TestNewSecurityPolicyError_ChallengeURL covers the Policy-specific field. +func TestNewSecurityPolicyError_ChallengeURL(t *testing.T) { + got := errs.NewSecurityPolicyError(errs.SubtypeChallengeRequired, "verify your device"). + WithCode(21000). + WithChallengeURL("https://applink.feishu.cn/T/xxxxx") + if got.ChallengeURL == "" { + t.Error("ChallengeURL not set") + } + if got.Code != 21000 { + t.Errorf("Code = %d, want 21000", got.Code) + } +} + +// TestNewContentSafetyError_Rules covers the variadic Rules setter. +func TestNewContentSafetyError_Rules(t *testing.T) { + got := errs.NewContentSafetyError(errs.SubtypeUnknown, "content blocked"). + WithRules("no_pii", "no_secrets") + if len(got.Rules) != 2 { + t.Errorf("Rules = %v, want 2 elements", got.Rules) + } +} + +// TestTypedError_UnwrapSymmetry pins that every typed error carries a Cause +// field that participates in errors.Unwrap / errors.Is. Uniformity across +// all typed errors lets callers descend below the typed-error boundary +// without first switching on the concrete type. +func TestTypedError_UnwrapSymmetry(t *testing.T) { + sentinel := errors.New("upstream cause") + cases := []struct { + name string + err error + }{ + {"APIError", errs.NewAPIError(errs.SubtypeServerError, "x").WithCause(sentinel)}, + {"PermissionError", errs.NewPermissionError(errs.SubtypeMissingScope, "x").WithCause(sentinel)}, + {"ContentSafetyError", errs.NewContentSafetyError(errs.SubtypeUnknown, "x").WithCause(sentinel)}, + {"ConfirmationRequiredError", errs.NewConfirmationRequiredError("write", "cmd", "x").WithCause(sentinel)}, + } + for _, tc := range cases { + t.Run(tc.name+"_Unwrap_returns_cause", func(t *testing.T) { + if got := errors.Unwrap(tc.err); got != sentinel { + t.Errorf("Unwrap() = %v, want %v", got, sentinel) + } + }) + t.Run(tc.name+"_errors.Is_sentinel", func(t *testing.T) { + if !errors.Is(tc.err, sentinel) { + t.Error("errors.Is(err, sentinel) = false, want true via Unwrap chain") + } + }) + } + t.Run("nil_receiver_Unwrap_safe", func(t *testing.T) { + var p *errs.APIError + _ = p.Unwrap() + var pp *errs.PermissionError + _ = pp.Unwrap() + var c *errs.ContentSafetyError + _ = c.Unwrap() + var cr *errs.ConfirmationRequiredError + _ = cr.Unwrap() + }) +} + +// TestValidationError_WithParams covers the structured-validation extension: +// WithParams appends InvalidParam items, the scalar Param setter is unaffected, +// and the wire shape nests {name, reason} under "params" (omitted when empty). +func TestValidationError_WithParams(t *testing.T) { + t.Run("appends and exposes fields", func(t *testing.T) { + e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path"). + WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"}) + if len(e.Params) != 1 { + t.Fatalf("len(Params) = %d, want 1", len(e.Params)) + } + if e.Params[0].Name != "a.md" { + t.Errorf("Params[0].Name = %q, want %q", e.Params[0].Name, "a.md") + } + if e.Params[0].Reason != "duplicate" { + t.Errorf("Params[0].Reason = %q, want %q", e.Params[0].Reason, "duplicate") + } + }) + + t.Run("appends across multiple calls and returns receiver", func(t *testing.T) { + e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x") + returned := e.WithParams(errs.InvalidParam{Name: "a.md", Reason: "dup"}) + if returned != e { + t.Errorf("WithParams returned different pointer; want same as receiver") + } + e.WithParams( + errs.InvalidParam{Name: "b.md", Reason: "dup"}, + errs.InvalidParam{Name: "c.md", Reason: "dup"}, + ) + if len(e.Params) != 3 { + t.Fatalf("len(Params) = %d after two calls, want 3", len(e.Params)) + } + }) + + t.Run("wire shape nests name and reason under params", func(t *testing.T) { + e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path"). + WithParam("--rel-path"). + WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"}) + b, err := json.Marshal(e) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + got := string(b) + for _, want := range []string{ + `"type":"validation"`, + `"param":"--rel-path"`, + `"params":[{"name":"a.md","reason":"duplicate"}]`, + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } + }) + + t.Run("empty Params omitted from wire", func(t *testing.T) { + e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x") + b, err := json.Marshal(e) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if strings.Contains(string(b), `"params"`) { + t.Errorf("empty Params should be omitted from wire; got %s", b) + } + }) +} + +func TestBuilderSetter_DefensiveCopy(t *testing.T) { + t.Run("WithMissingScopes clones input", func(t *testing.T) { + scopes := []string{"docx:document", "im:message:send"} + err := errs.NewPermissionError(errs.SubtypeMissingScope, "test"). + WithMissingScopes(scopes...) + scopes[0] = "MUTATED" + if got := err.MissingScopes[0]; got != "docx:document" { + t.Errorf("MissingScopes[0] = %q after caller mutation; want defensive copy", got) + } + }) + t.Run("WithRules clones input", func(t *testing.T) { + rules := []string{"rule-A", "rule-B"} + err := errs.NewContentSafetyError(errs.SubtypeUnknown, "test"). + WithRules(rules...) + rules[0] = "MUTATED" + if got := err.Rules[0]; got != "rule-A" { + t.Errorf("Rules[0] = %q after caller mutation; want defensive copy", got) + } + }) +} diff --git a/events/minutes/minute_generated.go b/events/minutes/minute_generated.go new file mode 100644 index 000000000..f4e4ec9da --- /dev/null +++ b/events/minutes/minute_generated.go @@ -0,0 +1,116 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/validate" +) + +const ( + minutesDetailRetryDelay = 500 * time.Millisecond + minutesDetailMaxRetries = 2 +) + +// MinutesMinuteSourceOutput is the flattened minute source payload. +type MinutesMinuteSourceOutput struct { + SourceType string `json:"source_type,omitempty" desc:"Minute source type"` + SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"` +} + +// MinutesMinuteGeneratedOutput is the flattened shape for minutes.minute.generated_v1. +type MinutesMinuteGeneratedOutput struct { + Type string `json:"type" desc:"Event type; always minutes.minute.generated_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"` + MinuteToken string `json:"minute_token,omitempty" desc:"Minute token"` + Title string `json:"title,omitempty" desc:"Minute title"` + MinuteSource *MinutesMinuteSourceOutput `json:"minute_source,omitempty" desc:"Minute source metadata"` +} + +func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event struct { + MinuteToken string `json:"minute_token"` + MinuteSource struct { + SourceType string `json:"source_type"` + SourceEntityID string `json:"source_entity_id"` + } `json:"minute_source"` + } `json:"event"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + } + + out := &MinutesMinuteGeneratedOutput{ + Type: envelope.Header.EventType, + EventID: envelope.Header.EventID, + Timestamp: envelope.Header.CreateTime, + MinuteToken: envelope.Event.MinuteToken, + } + if out.Type == "" { + out.Type = raw.EventType + } + if src := envelope.Event.MinuteSource; src.SourceType != "" || src.SourceEntityID != "" { + out.MinuteSource = &MinutesMinuteSourceOutput{ + SourceType: src.SourceType, + SourceEntityID: src.SourceEntityID, + } + } + + if rt != nil && out.MinuteToken != "" { + fillMinutesMinuteGeneratedDetails(ctx, rt, out) + } + + return json.Marshal(out) +} + +func fillMinutesMinuteGeneratedDetails(ctx context.Context, rt event.APIClient, out *MinutesMinuteGeneratedOutput) { + if rt == nil || out == nil || out.MinuteToken == "" { + return + } + + path := fmt.Sprintf(pathMinuteDetailFmt, validate.EncodePathSegment(out.MinuteToken)) + + type minuteDetailResp struct { + Data struct { + Minute struct { + Title string `json:"title"` + } `json:"minute"` + } `json:"data"` + } + + for attempt := 0; attempt <= minutesDetailMaxRetries; attempt++ { + if attempt > 0 { + time.Sleep(minutesDetailRetryDelay) + } + + raw, err := rt.CallAPI(ctx, "GET", path, nil) + if err != nil { + continue + } + + var resp minuteDetailResp + if err := json.Unmarshal(raw, &resp); err != nil { + continue + } + + if resp.Data.Minute.Title == "" { + continue + } + + out.Title = resp.Data.Minute.Title + return + } +} diff --git a/events/minutes/minute_generated_test.go b/events/minutes/minute_generated_test.go new file mode 100644 index 000000000..9a0a5b13e --- /dev/null +++ b/events/minutes/minute_generated_test.go @@ -0,0 +1,353 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "encoding/json" + "fmt" + "os" + "reflect" + "testing" + "time" + + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/validate" +) + +type stubAPIClient struct { + callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error) +} + +func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) { + if s.callFn == nil { + return nil, nil + } + return s.callFn(ctx, method, path, body) +} + +func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) { + t.Helper() + want := map[string]string{"event_type": wantEventType} + if !reflect.DeepEqual(gotBody, want) { + t.Fatalf("request body = %#v, want %#v", gotBody, want) + } +} + +func TestMain(m *testing.M) { + for _, k := range Keys() { + event.RegisterKey(k) + } + os.Exit(m.Run()) +} + +func TestMinutesKeys_ProcessedMinuteGeneratedRegistered(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + def, ok := event.Lookup(eventTypeMinuteGenerated) + if !ok { + t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated) + } + if def.Schema.Custom == nil { + t.Error("Processed key must set Schema.Custom") + } + if def.Schema.Native != nil { + t.Error("Processed key must not set Schema.Native") + } + if def.Process == nil { + t.Error("Process must not be nil for processed key") + } + if def.PreConsume == nil { + t.Error("PreConsume must not be nil for processed key") + } + if len(def.Scopes) != 1 || def.Scopes[0] != "minutes:minutes.basic:read" { + t.Errorf("Scopes = %v", def.Scopes) + } + if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" { + t.Errorf("AuthTypes = %v", def.AuthTypes) + } +} + +func TestProcessMinutesMinuteGenerated(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + var gotMethod, gotPath string + rt := &stubAPIClient{ + callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) { + gotMethod = method + gotPath = path + if body != nil { + t.Fatalf("GET detail body = %#v, want nil", body) + } + return json.RawMessage(`{ + "code": 0, + "msg": "success", + "data": { + "minute": { + "token": "", + "title": "产品周会的视频会议", + "note_id": "7616590025794260496" + } + } + }`), nil + }, + } + + out := runMinuteGenerated(t, rt, `{ + "schema": "2.0", + "header": { + "event_id": "ev_minute_001", + "event_type": "minutes.minute.generated_v1", + "create_time": "1608725989000" + }, + "event": { + "minute_token": "", + "minute_source": { + "source_type": "meeting", + "source_entity_id": "6911188411934433028" + } + } + }`) + + if gotMethod != "GET" { + t.Errorf("detail method = %q, want GET", gotMethod) + } + if gotPath != fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment("")) { + t.Errorf("detail path = %q", gotPath) + } + if out.Type != eventTypeMinuteGenerated { + t.Errorf("Type = %q", out.Type) + } + if out.EventID != "ev_minute_001" || out.Timestamp != "1608725989000" { + t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp) + } + if out.MinuteToken != "" { + t.Errorf("MinuteToken = %q", out.MinuteToken) + } + if out.Title != "产品周会的视频会议" { + t.Errorf("Title = %q", out.Title) + } + if out.MinuteSource == nil { + t.Fatal("MinuteSource should not be nil") + } + if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "6911188411934433028" { + t.Errorf("MinuteSource = %+v", out.MinuteSource) + } +} + +func TestProcessMinutesMinuteGenerated_DetailFailureFallsBackToBaseFields(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + called := 0 + rt := &stubAPIClient{ + callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) { + called++ + return nil, context.DeadlineExceeded + }, + } + + out := runMinuteGenerated(t, rt, `{ + "schema": "2.0", + "header": { + "event_id": "ev_minute_002", + "event_type": "minutes.minute.generated_v1", + "create_time": "1608725989001" + }, + "event": { + "minute_token": "", + "minute_source": { + "source_type": "meeting", + "source_entity_id": "7641156270787481117" + } + } + }`) + + wantCalls := 1 + minutesDetailMaxRetries + if called != wantCalls { + t.Fatalf("detail API called %d times, want %d", called, wantCalls) + } + if out.MinuteToken != "" { + t.Errorf("MinuteToken = %q", out.MinuteToken) + } + if out.Title != "" { + t.Errorf("Title = %q, want empty", out.Title) + } + if out.MinuteSource == nil { + t.Fatal("MinuteSource should remain from event payload") + } + if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "7641156270787481117" { + t.Errorf("MinuteSource = %+v", out.MinuteSource) + } +} + +func TestProcessMinutesMinuteGenerated_EmptyTitleRetriesAndSucceeds(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + called := 0 + rt := &stubAPIClient{ + callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) { + called++ + if called <= 1 { + return json.RawMessage(`{ + "code": 0, + "msg": "success", + "data": { + "minute": { + "title": "" + } + } + }`), nil + } + return json.RawMessage(`{ + "code": 0, + "msg": "success", + "data": { + "minute": { + "title": "delayed title" + } + } + }`), nil + }, + } + + out := runMinuteGenerated(t, rt, `{ + "schema": "2.0", + "header": { + "event_id": "ev_minute_retry", + "event_type": "minutes.minute.generated_v1", + "create_time": "1608725989000" + }, + "event": { + "minute_token": "" + } + }`) + + if called != 2 { + t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called) + } + if out.Title != "delayed title" { + t.Errorf("Title = %q, want delayed title", out.Title) + } +} + +func TestProcessMinutesMinuteGenerated_EmptyTitleExhaustsRetries(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + called := 0 + rt := &stubAPIClient{ + callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) { + called++ + return json.RawMessage(`{ + "code": 0, + "msg": "success", + "data": { + "minute": { + "title": "" + } + } + }`), nil + }, + } + + out := runMinuteGenerated(t, rt, `{ + "schema": "2.0", + "header": { + "event_id": "ev_minute_exhaust", + "event_type": "minutes.minute.generated_v1", + "create_time": "1608725989000" + }, + "event": { + "minute_token": "" + } + }`) + + wantCalls := 1 + minutesDetailMaxRetries + if called != wantCalls { + t.Fatalf("detail API called %d times, want %d", called, wantCalls) + } + if out.Title != "" { + t.Errorf("Title = %q, want empty after exhausted retries", out.Title) + } +} + +func TestMinutesMinuteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + def, ok := event.Lookup(eventTypeMinuteGenerated) + if !ok { + t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated) + } + + type call struct { + method string + path string + body any + } + var calls []call + rt := &stubAPIClient{ + callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) { + calls = append(calls, call{method: method, path: path, body: body}) + return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil + }, + } + + cleanup, err := def.PreConsume(context.Background(), rt, nil) + if err != nil { + t.Fatalf("PreConsume error: %v", err) + } + if cleanup == nil { + t.Fatal("cleanup must not be nil") + } + if len(calls) != 1 { + t.Fatalf("calls after subscribe = %d, want 1", len(calls)) + } + if calls[0].method != "POST" || calls[0].path != pathMinuteSubscribe { + t.Fatalf("subscribe call = %+v", calls[0]) + } + assertSubscriptionRequest(t, calls[0].body, eventTypeMinuteGenerated) + + cleanup() + if len(calls) != 2 { + t.Fatalf("calls after cleanup = %d, want 2", len(calls)) + } + if calls[1].method != "POST" || calls[1].path != pathMinuteUnsubscribe { + t.Fatalf("unsubscribe call = %+v", calls[1]) + } + assertSubscriptionRequest(t, calls[1].body, eventTypeMinuteGenerated) +} + +func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + raw := &event.RawEvent{ + EventType: eventTypeMinuteGenerated, + Payload: json.RawMessage(`not json`), + Timestamp: time.Now(), + } + got, err := processMinutesMinuteGenerated(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("Process should swallow parse errors, got %v", err) + } + if string(got) != "not json" { + t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + } +} + +func runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) MinutesMinuteGeneratedOutput { + t.Helper() + raw := &event.RawEvent{ + EventType: eventTypeMinuteGenerated, + Payload: json.RawMessage(payload), + Timestamp: time.Now(), + } + got, err := processMinutesMinuteGenerated(context.Background(), rt, raw, nil) + if err != nil { + t.Fatalf("Process error: %v", err) + } + var out MinutesMinuteGeneratedOutput + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("Process output is not valid MinutesMinuteGeneratedOutput JSON: %v\nraw=%s", err, string(got)) + } + return out +} diff --git a/events/minutes/preconsume.go b/events/minutes/preconsume.go new file mode 100644 index 000000000..82c329c85 --- /dev/null +++ b/events/minutes/preconsume.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "fmt" + "time" + + "github.com/larksuite/cli/internal/event" +) + +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) { + if rt == nil { + return nil, fmt.Errorf("runtime API client is required for pre-consume subscription") + } + + body := map[string]string{"event_type": eventType} + if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil { + return nil, err + } + + return func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + _, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body) + }, nil + } +} diff --git a/events/minutes/register.go b/events/minutes/register.go new file mode 100644 index 000000000..bd0297bda --- /dev/null +++ b/events/minutes/register.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package minutes registers Minutes-domain EventKeys. +package minutes + +import ( + "reflect" + + "github.com/larksuite/cli/internal/event" +) + +const ( + eventTypeMinuteGenerated = "minutes.minute.generated_v1" + + pathMinuteSubscribe = "/open-apis/minutes/v1/minutes/subscription" + pathMinuteUnsubscribe = "/open-apis/minutes/v1/minutes/unsubscription" + + pathMinuteDetailFmt = "/open-apis/minutes/v1/minutes/%s" +) + +// Keys returns all Minutes-domain EventKey definitions. +func Keys() []event.KeyDefinition { + return []event.KeyDefinition{ + { + Key: eventTypeMinuteGenerated, + DisplayName: "Minute generated", + Description: "Triggered when a minute has been generated", + EventType: eventTypeMinuteGenerated, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})}, + }, + Process: processMinutesMinuteGenerated, + PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe), + Scopes: []string{"minutes:minutes.basic:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeMinuteGenerated}, + }, + } +} diff --git a/events/register.go b/events/register.go index 7ca984a0f..e570da623 100644 --- a/events/register.go +++ b/events/register.go @@ -6,13 +6,17 @@ package events import ( "github.com/larksuite/cli/events/im" + "github.com/larksuite/cli/events/minutes" + "github.com/larksuite/cli/events/vc" "github.com/larksuite/cli/internal/event" ) -// Mail is intentionally omitted: only IM is wired up this phase. +// Mail is intentionally omitted in this phase. func init() { all := [][]event.KeyDefinition{ im.Keys(), + minutes.Keys(), + vc.Keys(), } for _, keys := range all { for _, k := range keys { diff --git a/events/vc/note_generated.go b/events/vc/note_generated.go new file mode 100644 index 000000000..8aeef4927 --- /dev/null +++ b/events/vc/note_generated.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" +) + +const ( + vcNoteArtifactTypeNote = 1 + vcNoteArtifactTypeVerbatim = 2 + + vcNoteDetailRetryDelay = 500 * time.Millisecond + vcNoteDetailMaxRetries = 2 + vcNoteDetailNotFoundCode = 121004 +) + +// VCNoteSourceOutput is the flattened note source payload. +type VCNoteSourceOutput struct { + SourceType string `json:"source_type,omitempty" desc:"Note source type"` + SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"` +} + +// VCNoteGeneratedOutput is the flattened shape for vc.note.generated_v1. +type VCNoteGeneratedOutput struct { + Type string `json:"type" desc:"Event type; always vc.note.generated_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"` + NoteID string `json:"note_id,omitempty" desc:"Note ID"` + NoteToken string `json:"note_token,omitempty" desc:"Generated note document token"` + VerbatimToken string `json:"verbatim_token,omitempty" desc:"Generated verbatim document token"` + NoteSource *VCNoteSourceOutput `json:"note_source,omitempty" desc:"Note source metadata"` +} + +func processVCNoteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event struct { + NoteID string `json:"note_id"` + } `json:"event"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + } + + out := &VCNoteGeneratedOutput{ + Type: envelope.Header.EventType, + EventID: envelope.Header.EventID, + Timestamp: envelope.Header.CreateTime, + NoteID: envelope.Event.NoteID, + } + if out.Type == "" { + out.Type = raw.EventType + } + + if rt != nil && out.NoteID != "" { + fillVCNoteGeneratedDetails(ctx, rt, out) + } + + return json.Marshal(out) +} + +func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VCNoteGeneratedOutput) { + if rt == nil || out == nil || out.NoteID == "" { + return + } + + path := fmt.Sprintf(pathNoteDetailFmt, validate.EncodePathSegment(out.NoteID)) + + type noteDetailResp struct { + Data struct { + Note struct { + Artifacts []struct { + ArtifactType int `json:"artifact_type"` + DocToken string `json:"doc_token"` + } `json:"artifacts"` + NoteSource struct { + SourceEntityID string `json:"source_entity_id"` + SourceType string `json:"source_type"` + } `json:"note_source"` + } `json:"note"` + } `json:"data"` + } + + for attempt := 0; attempt <= vcNoteDetailMaxRetries; attempt++ { + if attempt > 0 { + time.Sleep(vcNoteDetailRetryDelay) + } + + raw, err := rt.CallAPI(ctx, "GET", path, nil) + if err != nil { + if isLarkCode(err, vcNoteDetailNotFoundCode) { + continue + } + return + } + + var resp noteDetailResp + if err := json.Unmarshal(raw, &resp); err != nil { + continue + } + + var noteToken, verbatimToken string + for _, artifact := range resp.Data.Note.Artifacts { + switch artifact.ArtifactType { + case vcNoteArtifactTypeNote: + if noteToken == "" { + noteToken = artifact.DocToken + } + case vcNoteArtifactTypeVerbatim: + if verbatimToken == "" { + verbatimToken = artifact.DocToken + } + } + } + + if noteToken == "" && verbatimToken == "" { + continue + } + + if noteToken != "" { + out.NoteToken = noteToken + } + if verbatimToken != "" { + out.VerbatimToken = verbatimToken + } + if src := resp.Data.Note.NoteSource; src.SourceType != "" || src.SourceEntityID != "" { + out.NoteSource = &VCNoteSourceOutput{ + SourceType: src.SourceType, + SourceEntityID: src.SourceEntityID, + } + } + return + } +} + +func isLarkCode(err error, code int) bool { + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Detail != nil { + return exitErr.Detail.Code == code + } + return false +} diff --git a/events/vc/note_generated_test.go b/events/vc/note_generated_test.go new file mode 100644 index 000000000..c45186236 --- /dev/null +++ b/events/vc/note_generated_test.go @@ -0,0 +1,328 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/larksuite/cli/internal/event" +) + +func TestVCKeys_ProcessedNoteGeneratedRegistered(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + def, ok := event.Lookup(eventTypeNoteGenerated) + if !ok { + t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated) + } + if def.Schema.Custom == nil { + t.Error("Processed key must set Schema.Custom") + } + if def.Schema.Native != nil { + t.Error("Processed key must not set Schema.Native") + } + if def.Process == nil { + t.Error("Process must not be nil for processed key") + } + if def.PreConsume == nil { + t.Error("PreConsume must not be nil for processed key") + } + if len(def.Scopes) != 1 || def.Scopes[0] != "vc:note:read" { + t.Errorf("Scopes = %v", def.Scopes) + } + if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" { + t.Errorf("AuthTypes = %v", def.AuthTypes) + } +} + +func TestProcessVCNoteGenerated(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + var gotMethod, gotPath string + rt := &stubAPIClient{ + callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) { + gotMethod = method + gotPath = path + if body != nil { + t.Fatalf("GET detail body = %#v, want nil", body) + } + return json.RawMessage(`{ + "code": 0, + "msg": "success", + "data": { + "note": { + "artifacts": [ + {"artifact_type": 1, "doc_token": "note_doc_token"}, + {"artifact_type": 2, "doc_token": "verbatim_doc_token"} + ], + "note_source": { + "source_type": "meeting", + "source_entity_id": "6911188411934433028" + } + } + } + }`), nil + }, + } + + out := runNoteGenerated(t, rt, `{ + "schema": "2.0", + "header": { + "event_id": "ev_vc_note_001", + "event_type": "vc.note.generated_v1", + "create_time": "1608725989000" + }, + "event": { + "note_id": "6943848821689040898" + } + }`) + + if gotMethod != "GET" { + t.Errorf("detail method = %q, want GET", gotMethod) + } + if gotPath != "/open-apis/vc/v1/notes/6943848821689040898" { + t.Errorf("detail path = %q", gotPath) + } + if out.Type != eventTypeNoteGenerated { + t.Errorf("Type = %q", out.Type) + } + if out.EventID != "ev_vc_note_001" || out.Timestamp != "1608725989000" { + t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp) + } + if out.NoteID != "6943848821689040898" { + t.Errorf("NoteID = %q", out.NoteID) + } + if out.NoteToken != "note_doc_token" { + t.Errorf("NoteToken = %q", out.NoteToken) + } + if out.VerbatimToken != "verbatim_doc_token" { + t.Errorf("VerbatimToken = %q", out.VerbatimToken) + } + if out.NoteSource == nil { + t.Fatal("NoteSource should not be nil") + } + if out.NoteSource.SourceType != "meeting" || out.NoteSource.SourceEntityID != "6911188411934433028" { + t.Errorf("NoteSource = %+v", out.NoteSource) + } +} + +func TestVCNoteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + def, ok := event.Lookup(eventTypeNoteGenerated) + if !ok { + t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated) + } + + type call struct { + method string + path string + body any + } + var calls []call + rt := &stubAPIClient{ + callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) { + calls = append(calls, call{method: method, path: path, body: body}) + return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil + }, + } + + cleanup, err := def.PreConsume(context.Background(), rt, nil) + if err != nil { + t.Fatalf("PreConsume error: %v", err) + } + if cleanup == nil { + t.Fatal("cleanup must not be nil") + } + if len(calls) != 1 { + t.Fatalf("calls after subscribe = %d, want 1", len(calls)) + } + if calls[0].method != "POST" || calls[0].path != pathNoteSubscribe { + t.Fatalf("subscribe call = %+v", calls[0]) + } + assertSubscriptionRequest(t, calls[0].body, eventTypeNoteGenerated) + + cleanup() + if len(calls) != 2 { + t.Fatalf("calls after cleanup = %d, want 2", len(calls)) + } + if calls[1].method != "POST" || calls[1].path != pathNoteUnsubscribe { + t.Fatalf("unsubscribe call = %+v", calls[1]) + } + assertSubscriptionRequest(t, calls[1].body, eventTypeNoteGenerated) +} + +func TestProcessVCNoteGenerated_DetailFailureFallsBackToBaseFields(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + called := 0 + rt := &stubAPIClient{ + callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) { + called++ + return nil, context.DeadlineExceeded + }, + } + + out := runNoteGenerated(t, rt, `{ + "schema": "2.0", + "header": { + "event_id": "ev_vc_note_002", + "event_type": "vc.note.generated_v1", + "create_time": "1608725989001" + }, + "event": { + "note_id": "6943848821689040999" + } + }`) + + if called != 1 { + t.Fatalf("detail API called %d times, want 1", called) + } + if out.NoteID != "6943848821689040999" { + t.Errorf("NoteID = %q", out.NoteID) + } + if out.NoteToken != "" || out.VerbatimToken != "" { + t.Errorf("NoteToken/VerbatimToken = %q/%q, want empty", out.NoteToken, out.VerbatimToken) + } + if out.NoteSource != nil { + t.Errorf("NoteSource = %+v, want nil", out.NoteSource) + } +} + +func TestProcessVCNoteGenerated_EmptyTokensRetriesAndSucceeds(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + called := 0 + rt := &stubAPIClient{ + callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) { + called++ + if called <= 1 { + return json.RawMessage(`{ + "code": 0, + "msg": "success", + "data": { + "note": { + "artifacts": [], + "note_source": {"source_type": "meeting", "source_entity_id": "123"} + } + } + }`), nil + } + return json.RawMessage(`{ + "code": 0, + "msg": "success", + "data": { + "note": { + "artifacts": [ + {"artifact_type": 1, "doc_token": "delayed_note_token"}, + {"artifact_type": 2, "doc_token": "delayed_verbatim_token"} + ], + "note_source": {"source_type": "meeting", "source_entity_id": "123"} + } + } + }`), nil + }, + } + + out := runNoteGenerated(t, rt, `{ + "schema": "2.0", + "header": { + "event_id": "ev_vc_note_empty_retry", + "event_type": "vc.note.generated_v1", + "create_time": "1608725989000" + }, + "event": { + "note_id": "6943848821689040empty" + } + }`) + + if called != 2 { + t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called) + } + if out.NoteToken != "delayed_note_token" { + t.Errorf("NoteToken = %q, want delayed_note_token", out.NoteToken) + } + if out.VerbatimToken != "delayed_verbatim_token" { + t.Errorf("VerbatimToken = %q, want delayed_verbatim_token", out.VerbatimToken) + } +} + +func TestProcessVCNoteGenerated_EmptyTokensExhaustsRetries(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + called := 0 + rt := &stubAPIClient{ + callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) { + called++ + return json.RawMessage(`{ + "code": 0, + "msg": "success", + "data": { + "note": { + "artifacts": [], + "note_source": {"source_type": "meeting", "source_entity_id": "123"} + } + } + }`), nil + }, + } + + out := runNoteGenerated(t, rt, `{ + "schema": "2.0", + "header": { + "event_id": "ev_vc_note_empty_exhaust", + "event_type": "vc.note.generated_v1", + "create_time": "1608725989000" + }, + "event": { + "note_id": "6943848821689040emptyex" + } + }`) + + wantCalls := 1 + vcNoteDetailMaxRetries + if called != wantCalls { + t.Fatalf("detail API called %d times, want %d", called, wantCalls) + } + if out.NoteToken != "" || out.VerbatimToken != "" { + t.Errorf("NoteToken/VerbatimToken = %q/%q, want empty after exhausted retries", out.NoteToken, out.VerbatimToken) + } +} + +func TestProcessVCNoteGenerated_MalformedPayload(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + raw := &event.RawEvent{ + EventType: eventTypeNoteGenerated, + Payload: json.RawMessage(`not json`), + Timestamp: time.Now(), + } + got, err := processVCNoteGenerated(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("Process should swallow parse errors, got %v", err) + } + if string(got) != "not json" { + t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + } +} + +func runNoteGenerated(t *testing.T, rt event.APIClient, payload string) VCNoteGeneratedOutput { + t.Helper() + raw := &event.RawEvent{ + EventType: eventTypeNoteGenerated, + Payload: json.RawMessage(payload), + Timestamp: time.Now(), + } + got, err := processVCNoteGenerated(context.Background(), rt, raw, nil) + if err != nil { + t.Fatalf("Process error: %v", err) + } + var out VCNoteGeneratedOutput + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("Process output is not valid VCNoteGeneratedOutput JSON: %v\nraw=%s", err, string(got)) + } + return out +} diff --git a/events/vc/participant_meeting_ended.go b/events/vc/participant_meeting_ended.go new file mode 100644 index 000000000..4941b3b75 --- /dev/null +++ b/events/vc/participant_meeting_ended.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/larksuite/cli/internal/event" +) + +// VCParticipantMeetingEndedOutput is the flattened shape for vc.meeting.participant_meeting_ended_v1. +type VCParticipantMeetingEndedOutput struct { + Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_ended_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"` + MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"` + Topic string `json:"topic,omitempty" desc:"Meeting topic"` + MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"` + StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"` + EndTime string `json:"end_time,omitempty" desc:"Meeting end time in RFC3339, converted to the local timezone"` + CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"` +} + +func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event struct { + Meeting struct { + ID string `json:"id"` + Topic string `json:"topic"` + MeetingNo string `json:"meeting_no"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + CalendarEventID string `json:"calendar_event_id"` + } `json:"meeting"` + } `json:"event"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + } + + meeting := envelope.Event.Meeting + out := &VCParticipantMeetingEndedOutput{ + Type: envelope.Header.EventType, + EventID: envelope.Header.EventID, + Timestamp: envelope.Header.CreateTime, + MeetingID: meeting.ID, + Topic: meeting.Topic, + MeetingNo: meeting.MeetingNo, + StartTime: unixSecondsToLocalRFC3339(meeting.StartTime), + EndTime: unixSecondsToLocalRFC3339(meeting.EndTime), + CalendarEventID: meeting.CalendarEventID, + } + if out.Type == "" { + out.Type = raw.EventType + } + return json.Marshal(out) +} + +func unixSecondsToLocalRFC3339(raw string) string { + if raw == "" { + return "" + } + secs, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return "" + } + return time.Unix(secs, 0).Local().Format(time.RFC3339) +} diff --git a/events/vc/participant_meeting_ended_test.go b/events/vc/participant_meeting_ended_test.go new file mode 100644 index 000000000..0989f484c --- /dev/null +++ b/events/vc/participant_meeting_ended_test.go @@ -0,0 +1,203 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "os" + "testing" + "time" + + "github.com/larksuite/cli/internal/event" +) + +func TestMain(m *testing.M) { + for _, k := range Keys() { + event.RegisterKey(k) + } + os.Exit(m.Run()) +} + +func TestVCKeys_ProcessedMeetingEndedRegistered(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + def, ok := event.Lookup(eventTypeMeetingEnded) + if !ok { + t.Fatalf("%s should be registered via Keys()", eventTypeMeetingEnded) + } + if def.Schema.Custom == nil { + t.Error("Processed key must set Schema.Custom") + } + if def.Schema.Native != nil { + t.Error("Processed key must not set Schema.Native") + } + if def.Process == nil { + t.Error("Process must not be nil for processed key") + } + if def.PreConsume == nil { + t.Error("PreConsume must not be nil for processed key") + } + if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" { + t.Errorf("Scopes = %v", def.Scopes) + } + if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" { + t.Errorf("AuthTypes = %v", def.AuthTypes) + } +} + +func TestProcessVCParticipantMeetingEnded(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_vc_end_001", + "event_type": "vc.meeting.participant_meeting_ended_v1", + "create_time": "1608725989000", + "app_id": "cli_test" + }, + "event": { + "meeting": { + "id": "6911188411934433028", + "topic": "my meeting", + "meeting_no": "235812466", + "start_time": "1608883322", + "end_time": "1608883899", + "calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0" + } + } + }` + out := runMeetingEnded(t, payload) + + if out.Type != eventTypeMeetingEnded { + t.Errorf("Type = %q", out.Type) + } + if out.EventID != "ev_vc_end_001" { + t.Errorf("EventID = %q", out.EventID) + } + if out.Timestamp != "1608725989000" { + t.Errorf("Timestamp = %q", out.Timestamp) + } + if out.MeetingID != "6911188411934433028" { + t.Errorf("MeetingID = %q", out.MeetingID) + } + if out.Topic != "my meeting" || out.MeetingNo != "235812466" { + t.Errorf("Topic/MeetingNo = %q/%q", out.Topic, out.MeetingNo) + } + if out.CalendarEventID != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" { + t.Errorf("CalendarEventID = %q", out.CalendarEventID) + } + if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out.StartTime != want { + t.Errorf("StartTime = %q, want %q", out.StartTime, want) + } + if want := time.Unix(1608883899, 0).Local().Format(time.RFC3339); out.EndTime != want { + t.Errorf("EndTime = %q, want %q", out.EndTime, want) + } +} + +func TestProcessVCParticipantMeetingEnded_InvalidMeetingTimes(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_vc_end_002", + "event_type": "vc.meeting.participant_meeting_ended_v1", + "create_time": "1608725989001" + }, + "event": { + "meeting": { + "id": "meeting_invalid_time", + "start_time": "bad", + "end_time": "" + } + } + }` + out := runMeetingEnded(t, payload) + if out.StartTime != "" || out.EndTime != "" { + t.Errorf("StartTime/EndTime = %q/%q, want empty strings", out.StartTime, out.EndTime) + } +} + +func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + raw := &event.RawEvent{ + EventType: eventTypeMeetingEnded, + Payload: json.RawMessage(`not json`), + Timestamp: time.Now(), + } + got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("Process should swallow parse errors, got %v", err) + } + if string(got) != "not json" { + t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + } +} + +func TestVCParticipantMeetingEnded_PreConsumeSubscriptionLifecycle(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + def, ok := event.Lookup("vc.meeting.participant_meeting_ended_v1") + if !ok { + t.Fatal("vc.meeting.participant_meeting_ended_v1 should be registered via Keys()") + } + + type call struct { + method string + path string + body any + } + var calls []call + rt := &stubAPIClient{ + callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) { + calls = append(calls, call{method: method, path: path, body: body}) + return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil + }, + } + + cleanup, err := def.PreConsume(context.Background(), rt, nil) + if err != nil { + t.Fatalf("PreConsume error: %v", err) + } + if cleanup == nil { + t.Fatal("cleanup must not be nil") + } + if len(calls) != 1 { + t.Fatalf("calls after subscribe = %d, want 1", len(calls)) + } + if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe { + t.Fatalf("subscribe call = %+v", calls[0]) + } + assertSubscriptionRequest(t, calls[0].body, eventTypeMeetingEnded) + + cleanup() + if len(calls) != 2 { + t.Fatalf("calls after cleanup = %d, want 2", len(calls)) + } + if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe { + t.Fatalf("unsubscribe call = %+v", calls[1]) + } + assertSubscriptionRequest(t, calls[1].body, eventTypeMeetingEnded) +} + +func runMeetingEnded(t *testing.T, payload string) VCParticipantMeetingEndedOutput { + t.Helper() + raw := &event.RawEvent{ + EventType: eventTypeMeetingEnded, + Payload: json.RawMessage(payload), + Timestamp: time.Now(), + } + got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("Process error: %v", err) + } + var out VCParticipantMeetingEndedOutput + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("Process output is not valid VCParticipantMeetingEndedOutput JSON: %v\nraw=%s", err, string(got)) + } + return out +} diff --git a/events/vc/preconsume.go b/events/vc/preconsume.go new file mode 100644 index 000000000..9bd03d941 --- /dev/null +++ b/events/vc/preconsume.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "fmt" + "time" + + "github.com/larksuite/cli/internal/event" +) + +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) { + if rt == nil { + return nil, fmt.Errorf("runtime API client is required for pre-consume subscription") + } + + body := map[string]string{"event_type": eventType} + if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil { + return nil, err + } + + return func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + _, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body) + }, nil + } +} diff --git a/events/vc/register.go b/events/vc/register.go new file mode 100644 index 000000000..d392daf23 --- /dev/null +++ b/events/vc/register.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package vc registers VC-domain EventKeys. +package vc + +import ( + "reflect" + + "github.com/larksuite/cli/internal/event" +) + +const ( + eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1" + eventTypeNoteGenerated = "vc.note.generated_v1" + + pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription" + pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription" + pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription" + pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription" + + pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s" +) + +// Keys returns all VC-domain EventKey definitions. +func Keys() []event.KeyDefinition { + return []event.KeyDefinition{ + { + Key: eventTypeMeetingEnded, + DisplayName: "Participant meeting ended", + Description: "Triggered when a meeting the current user participates in has ended", + EventType: eventTypeMeetingEnded, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingEndedOutput{})}, + }, + Process: processVCParticipantMeetingEnded, + PreConsume: subscriptionPreConsume(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe), + Scopes: []string{"vc:meeting.meetingevent:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeMeetingEnded}, + }, + { + Key: eventTypeNoteGenerated, + DisplayName: "Note generated", + Description: "Triggered when a note has been generated", + EventType: eventTypeNoteGenerated, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCNoteGeneratedOutput{})}, + }, + Process: processVCNoteGenerated, + PreConsume: subscriptionPreConsume(eventTypeNoteGenerated, pathNoteSubscribe, pathNoteUnsubscribe), + Scopes: []string{"vc:note:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeNoteGenerated}, + }, + } +} diff --git a/events/vc/test_helpers_test.go b/events/vc/test_helpers_test.go new file mode 100644 index 000000000..4d69d8e3a --- /dev/null +++ b/events/vc/test_helpers_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "reflect" + "testing" +) + +type stubAPIClient struct { + callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error) +} + +func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) { + if s.callFn == nil { + return nil, nil + } + return s.callFn(ctx, method, path, body) +} + +func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) { + t.Helper() + want := map[string]string{"event_type": wantEventType} + if !reflect.DeepEqual(gotBody, want) { + t.Fatalf("request body = %#v, want %#v", gotBody, want) + } +} diff --git a/extension/platform/README.md b/extension/platform/README.md index d2834ddd7..9f9eb56da 100644 --- a/extension/platform/README.md +++ b/extension/platform/README.md @@ -59,7 +59,7 @@ You should see `audit` in the plugin list. | `Observer` | Before / After each command | No (fire-and-forget audit) | | `Wrap` | Around each command's RunE | Yes (return `*AbortError`) | | `On(Startup/Shutdown)` | Process lifecycle | N/A | -| `Restrict(Rule)` | Bootstrap-time, single per binary | Denies whole subtrees | +| `Restrict(Rule)` | Bootstrap-time, ≥1 per plugin | Denies whole subtrees | ### Plugin lifecycle @@ -102,10 +102,17 @@ the rejected dispatch. - A plugin calling `Restrict()` MUST declare `FailClosed`. The Builder flips it automatically; the lower-level `Plugin` interface rejects the mismatch with `restricts_mismatch`. -- Only ONE plugin per binary can call `Restrict()`. Multi-plugin - Restrict is a deliberate `plugin_conflict` error (single-rule - ecosystem assumption). YAML policy at `~/.lark-cli/policy.yml` is - shadowed by any plugin Restrict. +- A plugin may call `Restrict()` more than once; each call adds one + scoped Rule and the engine combines them with **OR** — a command is + allowed when it satisfies every axis (allow / deny / max_risk / + identities) of at least one rule. Note a rule's `deny` is scoped to + that rule only and cannot veto another rule's allow. Only ONE plugin + per binary may contribute rules, though: two DISTINCT plugins each + calling `Restrict()` is a deliberate `multiple_restrict_plugins` error + (single-owner assumption — an independent plugin must not be able to + widen another's policy). YAML policy at `~/.lark-cli/policy.yml` (which + may itself list several rules under `rules:`) is shadowed by any plugin + Restrict. - The `Wrap` factory runs **once per command dispatch**, not at install time. Long-lived state (clients, caches, metrics counters) must live on the Plugin struct or in package-level variables. @@ -115,7 +122,8 @@ the rejected dispatch. - Commands missing a `risk_level` annotation are denied by default when a Rule is active. Set `Rule.AllowUnannotated = true` (or `allow_unannotated: true` in yaml) to opt out during gradual - adoption. + adoption. With several rules this is per-rule: an unannotated command + is allowed as long as one rule that opts in also grants it. - Risk annotation typos (e.g. `"wrtie"`) are always denied with `risk_invalid` plus a "did you mean" suggestion. `AllowUnannotated` does NOT bypass this — typo is a code bug, not a missing @@ -144,8 +152,7 @@ messages are localised and may change between releases. | `duplicate_hook_name` | Same hook name registered twice within a plugin | Yes | | `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes | | `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes | -| `double_restrict` | Plugin called `r.Restrict()` more than once in one Install | Yes | -| `multiple_restrict_plugins` | Two or more plugins each contributed Restrict | Yes | +| `multiple_restrict_plugins` | Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | Yes | | `install_failed` | `Plugin.Install` returned a non-nil error | Yes | | `install_panic` | `Plugin.Install` panicked | Yes | @@ -165,6 +172,7 @@ might also be lying about being `FailOpen`). | `write_not_allowed` | Command risk is `write` / `high-risk-write` and exceeds Rule `max_risk` | | `risk_too_high` | Command risk exceeds Rule `max_risk` but is not a write (reserved for future risk levels) | | `identity_mismatch` | Command's `supportedIdentities` does not intersect Rule `identities` | +| `no_matching_rule` | Several rules are active and the command satisfied none of them (the message summarises each rule's own rejection). Single-rule policies keep their specific reason_code instead | | `aggregate_all_denied` | Aggregate stub installed on a parent group because every live child was denied | The `detail.layer` field distinguishes who rejected the call: diff --git a/extension/platform/builder.go b/extension/platform/builder.go index 1bcba749f..479fea824 100644 --- a/extension/platform/builder.go +++ b/extension/platform/builder.go @@ -37,7 +37,7 @@ type Builder struct { caps Capabilities actions []func(Registrar) - rule *Rule + rules []*Rule hookNames map[string]bool errs []error @@ -125,7 +125,8 @@ func (b *Builder) On(event LifecycleEvent, hookName string, fn LifecycleHandler) // sets Restricts=true and FailurePolicy=FailClosed (the framework // requires both to coexist; the builder enforces the pairing so the // plugin author cannot accidentally ship a policy plugin under -// FailOpen). +// FailOpen). It may be called more than once; each call adds one scoped +// Rule and the engine OR-combines them. func (b *Builder) Restrict(rule *Rule) *Builder { if rule == nil { b.errs = append(b.errs, errors.New("Restrict(nil): rule must not be nil")) @@ -133,7 +134,14 @@ func (b *Builder) Restrict(rule *Rule) *Builder { } b.caps.Restricts = true b.caps.FailurePolicy = FailClosed - b.rule = rule + // Defensive clone: capture an independent snapshot so a caller that + // reuses and mutates the same *Rule across multiple Restrict calls + // gets distinct entries (mirrors the staging registrar's clone). + cp := *rule + cp.Allow = append([]string(nil), rule.Allow...) + cp.Deny = append([]string(nil), rule.Deny...) + cp.Identities = append([]Identity(nil), rule.Identities...) + b.rules = append(b.rules, &cp) return b } @@ -143,7 +151,7 @@ func (b *Builder) Restrict(rule *Rule) *Builder { // The Restrict + FailOpen mismatch is checked here, not in the chained // setters, because the two methods may be called in either order. func (b *Builder) Build() (Plugin, error) { - if b.rule != nil && b.caps.FailurePolicy == FailOpen { + if len(b.rules) > 0 && b.caps.FailurePolicy == FailOpen { b.errs = append(b.errs, errors.New( "Restrict() requires FailClosed; do not call FailOpen() after Restrict()")) } @@ -155,7 +163,7 @@ func (b *Builder) Build() (Plugin, error) { version: b.version, caps: b.caps, actions: b.actions, - rule: b.rule, + rules: b.rules, }, nil } @@ -198,15 +206,15 @@ type builtPlugin struct { version string caps Capabilities actions []func(Registrar) - rule *Rule + rules []*Rule } func (p *builtPlugin) Name() string { return p.name } func (p *builtPlugin) Version() string { return p.version } func (p *builtPlugin) Capabilities() Capabilities { return p.caps } func (p *builtPlugin) Install(r Registrar) error { - if p.rule != nil { - r.Restrict(p.rule) + for _, rule := range p.rules { + r.Restrict(rule) } for _, action := range p.actions { action(r) diff --git a/extension/platform/builder_test.go b/extension/platform/builder_test.go index 541271a1b..7a813665f 100644 --- a/extension/platform/builder_test.go +++ b/extension/platform/builder_test.go @@ -17,7 +17,8 @@ type recorder struct { observers int wrappers int lifecycles int - rule *platform.Rule + rule *platform.Rule // last rule (existing single-rule assertions) + rules []*platform.Rule // every rule, in Restrict order } func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) { @@ -25,7 +26,39 @@ func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Ob } func (r *recorder) Wrap(string, platform.Selector, platform.Wrapper) { r.wrappers++ } func (r *recorder) On(platform.LifecycleEvent, string, platform.LifecycleHandler) { r.lifecycles++ } -func (r *recorder) Restrict(rule *platform.Rule) { r.rule = rule } +func (r *recorder) Restrict(rule *platform.Rule) { + r.rule = rule + r.rules = append(r.rules, rule) +} + +// Restrict must snapshot each rule: a caller that reuses and mutates the +// same *Rule object across two Restrict calls must still get two distinct +// rules at Install time, not two pointers to the last mutation. +func TestBuilder_restrictClonesEachRule(t *testing.T) { + shared := &platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: platform.RiskRead} + b := platform.NewPlugin("p", "0").Restrict(shared) + // Reuse and mutate the same object, then register it again. + shared.Name = "im-rw" + shared.Allow[0] = "im/**" + shared.MaxRisk = platform.RiskWrite + p, err := b.Restrict(shared).Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + r := &recorder{} + if err := p.Install(r); err != nil { + t.Fatalf("Install: %v", err) + } + if len(r.rules) != 2 { + t.Fatalf("got %d rules, want 2", len(r.rules)) + } + if r.rules[0].Name != "docs-ro" || r.rules[0].Allow[0] != "docs/**" || r.rules[0].MaxRisk != platform.RiskRead { + t.Errorf("rule[0] leaked later mutation: %+v", r.rules[0]) + } + if r.rules[1].Name != "im-rw" || r.rules[1].Allow[0] != "im/**" { + t.Errorf("rule[1] = %+v, want im-rw / im/**", r.rules[1]) + } +} func TestBuilder_basicAssembly(t *testing.T) { p, err := platform.NewPlugin("audit", "0.1.0"). diff --git a/extension/platform/registrar.go b/extension/platform/registrar.go index 8774050bf..36df85f45 100644 --- a/extension/platform/registrar.go +++ b/extension/platform/registrar.go @@ -13,9 +13,10 @@ package platform // identifier is "{plugin}.{hook}". A plugin cannot register two hooks // with the same name in the same Install call. // -// Restrict may be called at most once per plugin; multiple plugins -// contributing Restrict() is a configuration error (the resolver -// aborts startup). +// Restrict may be called multiple times per plugin; each call adds one +// scoped Rule (OR-combined by the engine). Two or more DISTINCT plugins +// contributing Restrict() is a configuration error (the resolver aborts +// startup). type Registrar interface { // Observe registers a side-effect-only command hook at the given // When stage. The selector decides which commands it fires on. @@ -29,8 +30,9 @@ type Registrar interface { // On registers a lifecycle handler for the given event. On(event LifecycleEvent, hookName string, fn LifecycleHandler) - // Restrict contributes a pruning Rule. The framework merges it - // with the yaml-sourced Rule using single-rule semantics: plugin - // rule wins, but two plugins both calling Restrict abort startup. + // Restrict contributes a pruning Rule. May be called more than once + // to declare several scoped grants (OR-combined by the engine). + // Plugin rules take precedence over the yaml source; two distinct + // plugins both calling Restrict abort startup. Restrict(r *Rule) } diff --git a/internal/auth/transport.go b/internal/auth/transport.go index b6d422899..95b2c6cd4 100644 --- a/internal/auth/transport.go +++ b/internal/auth/transport.go @@ -14,7 +14,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/errclass" - "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/transport" ) // SecurityPolicyTransport is an http.RoundTripper that intercepts all responses @@ -28,7 +28,7 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper { if t.Base != nil { return t.Base } - return util.FallbackTransport() + return transport.Fallback() } // RoundTrip implements http.RoundTripper. diff --git a/internal/auth/uat_client.go b/internal/auth/uat_client.go index a46609646..0be897892 100644 --- a/internal/auth/uat_client.go +++ b/internal/auth/uat_client.go @@ -214,7 +214,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored } var data map[string]interface{} if err := json.Unmarshal(body, &data); err != nil { - return nil, fmt.Errorf("token refresh parse error: %v", err) + return nil, fmt.Errorf("token refresh parse error: %w", err) } return data, nil } diff --git a/internal/auth/verify.go b/internal/auth/verify.go index 403ad4481..8786a7d3c 100644 --- a/internal/auth/verify.go +++ b/internal/auth/verify.go @@ -31,7 +31,7 @@ func VerifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string) Msg string `json:"msg"` } if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { - return fmt.Errorf("failed to parse response: %v", err) + return fmt.Errorf("failed to parse response: %w", err) } if resp.Code != 0 { return fmt.Errorf("[%d] %s", resp.Code, resp.Msg) diff --git a/internal/client/api_errors.go b/internal/client/api_errors.go index 613b02da5..6b9244141 100644 --- a/internal/client/api_errors.go +++ b/internal/client/api_errors.go @@ -5,91 +5,130 @@ package client import ( "bytes" + "crypto/x509" "encoding/json" "errors" - "fmt" - "io" + "net" "strings" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/output" ) +// rawAPIJSONHint guides users when an SDK or response body parse fails. The +// most common cause is a non-JSON payload (file download endpoint hit without +// `--output`, or an upstream HTML error page). const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard JSON body. If it returns a file, rerun with --output." -// WrapDoAPIError upgrades malformed JSON decode errors from the SDK into -// actionable API errors for raw `lark-cli api` calls. All other failures -// remain network errors. -// -// Already-classified errors pass through unchanged: any *output.ExitError -// (legacy envelope from output.ErrAuth / output.ErrAPI / output.ErrWithHint) -// and any typed *errs.* error (carries an embedded Problem) keeps its own -// category and exit code. This is what makes the wrap idempotent on the -// auth/credential chain — resolveAccessToken returns output.ErrAuth for -// missing tokens, and that classification must survive the SDK boundary. -// -// Deprecated: legacy *output.ExitError wire shape (api_error + rawAPIJSONHint -// on JSON-decode, network otherwise) for the wrap-from-untyped branch. -// Preserved so SDK Do() callers keep the original envelope until per-domain -// migration to typed errors. New code should route through -// APIClient.CheckResponse (typed *errs.APIError) or construct -// *errs.NetworkError / *errs.InternalError directly. +// WrapDoAPIError converts SDK-boundary failures into typed errs.* errors: +// already-typed errors pass through (idempotent), JSON-decode failures +// become InternalError{SubtypeInvalidResponse}, everything else becomes +// NetworkError with a chain-derived subtype (timeout / tls / dns / +// server_error / transport-fallback). func WrapDoAPIError(err error) error { if err == nil { return nil } - var existing *output.ExitError - if errors.As(err, &existing) { - return err - } + + // (1) Pass-through any typed errs.* error. if _, ok := errs.ProblemOf(err); ok { return err } - if isJSONDecodeError(err, false) { - return output.ErrWithHint(output.ExitAPI, "api_error", - fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint) + + // (2) JSON-decode failure at the SDK boundary → InternalError. + if isJSONDecodeError(err) { + return errs.NewInternalError(errs.SubtypeInvalidResponse, + "SDK returned an invalid JSON response: %v", err). + WithHint("%s", rawAPIJSONHint). + WithCause(err) } - return output.ErrNetwork("API call failed: %v", err) + + // (3) Otherwise classify as a network failure with a chain-derived subtype. + return errs.NewNetworkError(classifyNetworkSubtype(err), + "API call failed: %v", err). + WithCause(err) } -// WrapJSONResponseParseError upgrades empty or malformed JSON response bodies -// into API errors with hints instead of generic parse failures. -// -// Deprecated: legacy *output.ExitError wire shape (api_error + ExitAPI + -// rawAPIJSONHint). The 3-branch behaviour is preserved so existing callers -// of internal/client/response.go keep emitting the same envelope until -// per-domain migration to typed errors. +// WrapJSONResponseParseError lifts a response-layer JSON parse failure into +// *errs.InternalError{Subtype: SubtypeInvalidResponse}. Empty body, malformed +// JSON, and mid-stream EOFs all collapse to this single shape. func WrapJSONResponseParseError(err error, body []byte) error { if err == nil { return nil } + + var e *errs.InternalError if len(bytes.TrimSpace(body)) == 0 { - return output.ErrWithHint(output.ExitAPI, "api_error", - "API returned an empty JSON response body", rawAPIJSONHint) + e = errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an empty JSON response body") + } else { + e = errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err) } - if isJSONDecodeError(err, true) { - return output.ErrWithHint(output.ExitAPI, "api_error", - fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint) - } - return output.ErrNetwork("API call failed: %v", err) + return e.WithHint("%s", rawAPIJSONHint).WithCause(err) } -func isJSONDecodeError(err error, allowEOF bool) bool { +// classifyNetworkSubtype maps an error chain to one of the network subtypes, +// falling back to SubtypeNetworkTransport. Timeout is checked first because +// a net.OpError can satisfy net.Error and also wrap a DNS sub-error in +// pathological proxy configurations — we prefer the timeout signal. +func classifyNetworkSubtype(err error) errs.Subtype { + // (a) Timeout — net.Error.Timeout(), plus the SDK's typed timeout + // errors (which do not implement net.Error). + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return errs.SubtypeNetworkTimeout + } + var sdkServerTimeout *larkcore.ServerTimeoutError + if errors.As(err, &sdkServerTimeout) { + return errs.SubtypeNetworkTimeout + } + var sdkClientTimeout *larkcore.ClientTimeoutError + if errors.As(err, &sdkClientTimeout) { + return errs.SubtypeNetworkTimeout + } + + // (b) TLS — typed x509 error or message substring fallback. + var x509Err *x509.UnknownAuthorityError + if errors.As(err, &x509Err) { + return errs.SubtypeNetworkTLS + } + msg := err.Error() + if strings.Contains(msg, "x509:") || strings.Contains(msg, "tls:") { + return errs.SubtypeNetworkTLS + } + + // (c) DNS — *net.DNSError covers SDK chains coming from net.Dialer. + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return errs.SubtypeNetworkDNS + } + + // HTTP 5xx classification lives on the call sites with *http.Response + // access (DoStream, HandleResponse); the SDK never surfaces non-504 5xx + // as an error here. + return errs.SubtypeNetworkTransport +} + +// isJSONDecodeError reports whether err is a JSON decode failure at the +// SDK boundary, matching both typed json errors and their fmt.Errorf- +// wrapped substring form. io.EOF is intentionally excluded — at the SDK +// boundary an EOF is a transport failure, not a payload-shape failure. +func isJSONDecodeError(err error) bool { var syntaxErr *json.SyntaxError var unmarshalTypeErr *json.UnmarshalTypeError - if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalTypeErr) { return true } - if allowEOF && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) { - return true - } + // Substring fallback for fmt.Errorf-wrapped json decode errors that no + // longer satisfy errors.As against the typed json errors. "invalid + // character" alone is too broad (other libraries surface it for non- + // JSON failures), so it is gated on the message also containing "json". msg := err.Error() - if allowEOF && strings.Contains(msg, "unexpected EOF") { + if strings.Contains(msg, "unexpected end of JSON input") || + strings.Contains(msg, "cannot unmarshal") { return true } - return strings.Contains(msg, "unexpected end of JSON input") || - strings.Contains(msg, "invalid character") || - strings.Contains(msg, "cannot unmarshal") + lower := strings.ToLower(msg) + return strings.Contains(lower, "invalid character") && strings.Contains(lower, "json") } diff --git a/internal/client/api_errors_test.go b/internal/client/api_errors_test.go index 4a3cf5f1b..65845adc5 100644 --- a/internal/client/api_errors_test.go +++ b/internal/client/api_errors_test.go @@ -4,173 +4,312 @@ package client import ( + "crypto/x509" "encoding/json" "errors" "fmt" "io" + "net" "strings" "testing" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" ) -func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) { - err := WrapDoAPIError(&json.SyntaxError{Offset: 1}) - if err == nil { - t.Fatal("expected error") - } +// ───────────────────────────────────────────────────────────────────────────── +// WrapDoAPIError: typed error contract. +// +// Pass-through: any error carrying *errs.Problem (detected via ProblemOf). +// JSON decode failures → *errs.InternalError{Subtype: invalid_response}. +// Otherwise → *errs.NetworkError with one of: timeout / tls / dns / +// server_error / transport (fallback). +// ───────────────────────────────────────────────────────────────────────────── - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected ExitError, got %T", err) +// timeoutNetError implements net.Error with Timeout() == true. Used to exercise +// the timeout branch of the network classifier without depending on a live +// transport. +type timeoutNetError struct{} + +func (timeoutNetError) Error() string { return "i/o timeout" } +func (timeoutNetError) Timeout() bool { return true } +func (timeoutNetError) Temporary() bool { return true } + +// TestWrapDoAPIError_SyntaxError_ReturnsInternalError pins that a raw +// *json.SyntaxError from the SDK boundary surfaces as an *errs.InternalError +// with Subtype=invalid_response — replacing the legacy api_error envelope. +func TestWrapDoAPIError_SyntaxError_ReturnsInternalError(t *testing.T) { + got := WrapDoAPIError(&json.SyntaxError{Offset: 1}) + var ie *errs.InternalError + if !errors.As(got, &ie) { + t.Fatalf("expected *errs.InternalError, got %T (%v)", got, got) } - if exitErr.Code != output.ExitAPI { - t.Fatalf("expected ExitAPI, got %d", exitErr.Code) + if ie.Category != errs.CategoryInternal { + t.Errorf("Category = %v, want %v", ie.Category, errs.CategoryInternal) } - if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") { - t.Fatalf("expected JSON diagnostic message, got %#v", exitErr.Detail) + if ie.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse) } } -func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) { - err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{")) - if err == nil { - t.Fatal("expected error") +// TestWrapDoAPIError_UnmarshalTypeError_ReturnsInternalError pins the second +// json-decode error variant (type-mismatch decoding) routes through the same +// invalid_response branch — not the network fallback. +func TestWrapDoAPIError_UnmarshalTypeError_ReturnsInternalError(t *testing.T) { + got := WrapDoAPIError(&json.UnmarshalTypeError{Value: "string", Type: nil}) + var ie *errs.InternalError + if !errors.As(got, &ie) { + t.Fatalf("expected *errs.InternalError, got %T", got) } - - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected ExitError, got %T", err) - } - if exitErr.Code != output.ExitAPI { - t.Fatalf("expected ExitAPI, got %d", exitErr.Code) - } - if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") { - t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail) + if ie.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse) } } -// TestWrapJSONResponseParseError_EmptyBodyIsAPIDiagnostic pins branch 1 of -// the documented 3-branch behaviour: empty (or whitespace-only) response -// bodies surface as api_error + rawAPIJSONHint, not network. Pages returning -// only "\n" must not be reclassified as transport failures. -func TestWrapJSONResponseParseError_EmptyBodyIsAPIDiagnostic(t *testing.T) { - for _, body := range [][]byte{nil, {}, []byte(" \t\n")} { - err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, body) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("body=%q: expected ExitError, got %T", body, err) - } - if exitErr.Code != output.ExitAPI { - t.Errorf("body=%q: Code = %d, want %d", body, exitErr.Code, output.ExitAPI) - } - if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" { - t.Errorf("body=%q: Detail.Type = %v, want api_error", body, exitErr.Detail) - } - if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "empty JSON response") { - t.Errorf("body=%q: Detail.Message = %v, want empty-body diagnostic", body, exitErr.Detail) - } +// TestWrapDoAPIError_Timeout pins that an SDK transport error whose chain +// carries a net.Error with Timeout()==true classifies as +// NetworkError{Subtype: timeout}. Covers the E2E timeout scenario +// (HTTPS_PROXY pointing at a non-routable address). +func TestWrapDoAPIError_Timeout(t *testing.T) { + got := WrapDoAPIError(&net.OpError{Op: "dial", Net: "tcp", Err: timeoutNetError{}}) + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError, got %T (%v)", got, got) + } + if ne.Subtype != errs.SubtypeNetworkTimeout { + t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout) + } + if ne.Category != errs.CategoryNetwork { + t.Errorf("Category = %v, want %v", ne.Category, errs.CategoryNetwork) } } -// TestWrapJSONResponseParseError_NonJSONErrorIsNetwork pins branch 3: -// a non-JSON-decode error with a non-empty body falls back to ErrNetwork -// (the SDK delivered something but the read itself failed mid-flight). -func TestWrapJSONResponseParseError_NonJSONErrorIsNetwork(t *testing.T) { - raw := errors.New("connection reset by peer") - err := WrapJSONResponseParseError(raw, []byte(`{"code":0,"data":{}}`)) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected ExitError, got %T", err) +// TestWrapDoAPIError_TLS pins that an x509.UnknownAuthorityError classifies +// as NetworkError{Subtype: tls}. +func TestWrapDoAPIError_TLS(t *testing.T) { + got := WrapDoAPIError(&x509.UnknownAuthorityError{}) + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError, got %T", got) } - if exitErr.Code != output.ExitNetwork { - t.Errorf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork) - } - if exitErr.Detail == nil || exitErr.Detail.Type != "network" { - t.Errorf("Detail.Type = %v, want network", exitErr.Detail) + if ne.Subtype != errs.SubtypeNetworkTLS { + t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS) } } -// TestWrapDoAPIError_LegacyExitErrorPassesThrough pins the invariant that an -// already-classified *output.ExitError (e.g. output.ErrAuth from -// resolveAccessToken) survives WrapDoAPIError with its category and exit code -// intact. Without this, missing-token errors regress from exit 3/auth to -// exit 4/network at the SDK boundary. -func TestWrapDoAPIError_LegacyExitErrorPassesThrough(t *testing.T) { - cases := []struct { - name string - in error - want int - wantType string - }{ - {"auth", output.ErrAuth("no access token available for user"), output.ExitAuth, "auth"}, - {"validation", output.ErrValidation("missing flag --foo"), output.ExitValidation, "validation"}, - {"api_unknown_code", output.ErrAPI(12345, "unknown lark code", nil), output.ExitAPI, "api_error"}, +// TestWrapDoAPIError_TLS_HandshakeMessage covers the message-substring fallback +// for TLS errors that don't surface as a typed x509 error. +func TestWrapDoAPIError_TLS_HandshakeMessage(t *testing.T) { + got := WrapDoAPIError(errors.New("remote error: tls: handshake failure")) + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError, got %T", got) } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := WrapDoAPIError(tc.in) - if got != tc.in { - t.Fatalf("expected identity passthrough, got %v (orig %v)", got, tc.in) - } - var exitErr *output.ExitError - if !errors.As(got, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", got) - } - if exitErr.Code != tc.want { - t.Fatalf("Code = %d, want %d", exitErr.Code, tc.want) - } - if exitErr.Detail == nil || exitErr.Detail.Type != tc.wantType { - t.Fatalf("Detail.Type = %q, want %q (detail=%#v)", - func() string { - if exitErr.Detail == nil { - return "" - } - return exitErr.Detail.Type - }(), - tc.wantType, exitErr.Detail) - } - }) + if ne.Subtype != errs.SubtypeNetworkTLS { + t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS) } } -// TestWrapDoAPIError_TypedErrsPassesThrough pins that any *errs.* typed error -// (carries an embedded Problem) passes through unchanged. Forward-compat for -// stage-4 credential chain migration that will return *errs.AuthenticationError -// directly instead of legacy output.ErrAuth. -func TestWrapDoAPIError_TypedErrsPassesThrough(t *testing.T) { +// TestWrapDoAPIError_DNS pins that a *net.DNSError classifies as +// NetworkError{Subtype: dns}. +func TestWrapDoAPIError_DNS(t *testing.T) { + got := WrapDoAPIError(&net.DNSError{Name: "example.invalid"}) + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError, got %T", got) + } + if ne.Subtype != errs.SubtypeNetworkDNS { + t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkDNS) + } +} + +// TestWrapDoAPIError_SDKServerTimeout pins that a *larkcore.ServerTimeoutError +// (504 Gateway Timeout surfaced by the SDK as a typed error rather than an +// *http.Response) classifies as timeout — upstream took too long to respond. +func TestWrapDoAPIError_SDKServerTimeout(t *testing.T) { + got := WrapDoAPIError(&larkcore.ServerTimeoutError{}) + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError, got %T", got) + } + if ne.Subtype != errs.SubtypeNetworkTimeout { + t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout) + } +} + +// TestWrapDoAPIError_SDKClientTimeout pins that a *larkcore.ClientTimeoutError +// (client-side request timeout the SDK reports without satisfying net.Error) +// classifies as timeout. +func TestWrapDoAPIError_SDKClientTimeout(t *testing.T) { + got := WrapDoAPIError(&larkcore.ClientTimeoutError{}) + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError, got %T", got) + } + if ne.Subtype != errs.SubtypeNetworkTimeout { + t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout) + } +} + +// TestWrapDoAPIError_UnknownCause_FallsBackToTransport pins the fallback: +// when none of the specific causes match, NetworkError uses the generic +// transport subtype. +func TestWrapDoAPIError_UnknownCause_FallsBackToTransport(t *testing.T) { + got := WrapDoAPIError(errors.New("connection reset by peer")) + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError, got %T", got) + } + if ne.Subtype != errs.SubtypeNetworkTransport { + t.Errorf("Subtype = %v, want %v (fallback)", ne.Subtype, errs.SubtypeNetworkTransport) + } +} + +// TestWrapDoAPIError_PassThrough_TypedError pins that any typed *errs.* error +// (carrying an embedded Problem) passes through unchanged — same pointer +// identity, no re-classification. This is the load-bearing invariant for +// resolveAccessToken returning *errs.AuthenticationError through DoSDKRequest. +func TestWrapDoAPIError_PassThrough_TypedError(t *testing.T) { cases := []error{ - &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing}}, - &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope}}, - &errs.NetworkError{Problem: errs.Problem{Category: errs.CategoryNetwork, Subtype: errs.SubtypeNetworkTransport}}, - &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError}}, + &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing, Message: "no token"}}, + &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope, Message: "no scope"}}, + &errs.NetworkError{Problem: errs.Problem{Category: errs.CategoryNetwork, Subtype: errs.SubtypeNetworkTransport, Message: "transport"}}, + &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, Message: "sdk"}}, } for _, in := range cases { t.Run(fmt.Sprintf("%T", in), func(t *testing.T) { got := WrapDoAPIError(in) if got != in { - t.Fatalf("expected identity passthrough, got %T %v", got, got) + t.Fatalf("expected identity pass-through, got %T %v", got, got) } }) } } -// TestWrapDoAPIError_PassthroughBeforeJSONDecode pins that even if a typed/legacy -// error wraps a JSON decode error somewhere in its chain, the outer -// classification takes precedence — we never re-classify an already-typed error -// as a JSON parse error. -func TestWrapDoAPIError_PassthroughBeforeJSONDecode(t *testing.T) { - jsonErr := &json.SyntaxError{Offset: 1} - authWrappingJSON := fmt.Errorf("%w: wrapped %w", output.ErrAuth("token expired"), jsonErr) - - got := WrapDoAPIError(authWrappingJSON) - - var exitErr *output.ExitError - if !errors.As(got, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", got) - } - if exitErr.Code != output.ExitAuth { - t.Fatalf("outer auth classification should win, Code = %d want %d", exitErr.Code, output.ExitAuth) +// TestWrapDoAPIError_Nil pins that nil in stays nil out (no allocation, no +// panic). Callers rely on this when the SDK returns success. +func TestWrapDoAPIError_Nil(t *testing.T) { + if got := WrapDoAPIError(nil); got != nil { + t.Errorf("WrapDoAPIError(nil) = %v, want nil", got) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// WrapJSONResponseParseError: typed error contract. +// +// All response-layer parse failures (empty body, malformed JSON, mid-stream +// read failures that surface as parse errors) collapse to a single +// *errs.InternalError{Subtype: invalid_response}. The rawAPIJSONHint is +// preserved on Problem.Hint so users still get the "may have returned an +// empty or non-standard body, rerun with --output" guidance. +// ───────────────────────────────────────────────────────────────────────────── + +// TestWrapJSONResponseParseError_SyntaxError_ReturnsInternalError pins the +// new shape for malformed JSON bodies — replaces the legacy api_error path. +func TestWrapJSONResponseParseError_SyntaxError_ReturnsInternalError(t *testing.T) { + got := WrapJSONResponseParseError(&json.SyntaxError{Offset: 1}, []byte("{ malformed")) + var ie *errs.InternalError + if !errors.As(got, &ie) { + t.Fatalf("expected *errs.InternalError, got %T", got) + } + if ie.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse) + } + if ie.Hint != rawAPIJSONHint { + t.Errorf("Hint = %q, want rawAPIJSONHint preserved", ie.Hint) + } +} + +// TestWrapJSONResponseParseError_EmptyBody_ReturnsInternalError pins that +// empty / whitespace-only response bodies also surface as invalid_response, +// not as a network error. Endpoints returning only "\n" or "" trigger this. +func TestWrapJSONResponseParseError_EmptyBody_ReturnsInternalError(t *testing.T) { + for _, body := range [][]byte{nil, {}, []byte(" \t\n")} { + got := WrapJSONResponseParseError(io.ErrUnexpectedEOF, body) + var ie *errs.InternalError + if !errors.As(got, &ie) { + t.Fatalf("body=%q: expected *errs.InternalError, got %T", body, got) + } + if ie.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("body=%q: Subtype = %v, want invalid_response", body, ie.Subtype) + } + } +} + +// TestWrapJSONResponseParseError_UnexpectedEOF_ReturnsInternalError pins that +// io.ErrUnexpectedEOF mid-decode also surfaces as invalid_response — keeps +// the legacy non-empty-body decode-failure semantics under the new typed +// envelope. +func TestWrapJSONResponseParseError_UnexpectedEOF_ReturnsInternalError(t *testing.T) { + got := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{")) + var ie *errs.InternalError + if !errors.As(got, &ie) { + t.Fatalf("expected *errs.InternalError, got %T", got) + } + if ie.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("Subtype = %v, want invalid_response", ie.Subtype) + } +} + +// TestWrapJSONResponseParseError_Nil pins nil pass-through. +func TestWrapJSONResponseParseError_Nil(t *testing.T) { + if got := WrapJSONResponseParseError(nil, []byte("anything")); got != nil { + t.Errorf("WrapJSONResponseParseError(nil, ...) = %v, want nil", got) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Cross-cutting: existing tests already in this file (kept and adjusted below). +// ───────────────────────────────────────────────────────────────────────────── + +// TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough pins that legacy +// *output.ExitError (auth/validation/api flavours) is NOT a problemCarrier +// and is therefore not pass-through — only typed *errs.* values are. +// Legacy values fall through to the network/JSON branches based on their +// inner shape. +func TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough(t *testing.T) { + // An *output.ErrAuth has no embedded Problem and no JSON-decode chain; + // it routes to the network branch with the fallback transport subtype. + got := WrapDoAPIError(output.ErrAuth("no access token available for user")) + + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError (legacy ExitError no longer pass-through), got %T (%v)", got, got) + } + // Sanity: not silently re-classified as JSON-decode. + var ie *errs.InternalError + if errors.As(got, &ie) { + t.Fatalf("expected NetworkError, got InternalError %v", ie) + } +} + +// TestWrapDoAPIError_TypedErrorWrappingJSON_OuterWins pins that a typed +// *errs.AuthenticationError wrapping a JSON syntax error in its chain still +// passes through as the outer type — we never re-classify a typed problem +// carrier just because the chain contains a json.SyntaxError. Forward-compat +// for credential chain errors that bundle a parse failure as Cause. +func TestWrapDoAPIError_TypedErrorWrappingJSON_OuterWins(t *testing.T) { + jsonErr := &json.SyntaxError{Offset: 1} + outer := &errs.AuthenticationError{ + Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired, Message: "expired"}, + Cause: jsonErr, + } + + got := WrapDoAPIError(outer) + if got != outer { + t.Fatalf("expected outer typed error to win, got %T %v", got, got) + } +} + +// TestWrapDoAPIError_MessageContainsCause pins that the wrapped error's +// message is carried into Problem.Message so logs / debugging retain the +// underlying cause string. +func TestWrapDoAPIError_MessageContainsCause(t *testing.T) { + raw := errors.New("dial tcp 10.0.0.1:443: i/o timeout") + got := WrapDoAPIError(raw) + if !strings.Contains(got.Error(), "i/o timeout") { + t.Errorf("Error() = %q, want to contain underlying cause", got.Error()) } } diff --git a/internal/client/client.go b/internal/client/client.go index ea8ad3a2a..dc4a0e89e 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -18,8 +18,12 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" + internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/errclass" + "github.com/larksuite/cli/internal/errcompat" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" ) @@ -48,16 +52,38 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s if err != nil { var unavailableErr *credential.TokenUnavailableError if errors.As(err, &unavailableErr) { - return "", output.ErrAuth("no access token available for %s", as) + return "", newTokenMissingError(as, unavailableErr) + } + // NeedAuthorizationError from the credential chain (e.g. UAT refresh + // returned need_user_authorization) must surface as typed + // AuthenticationError. Without this, WrapDoAPIError would wrap the + // raw err as NetworkError, and cmd/root.go's outer-typed gate would + // then skip PromoteAuthError — leaving the user with exit 4 and no + // auth-login hint instead of exit 3 typed authentication. + var needAuthErr *internalauth.NeedAuthorizationError + if errors.As(err, &needAuthErr) { + return "", errcompat.PromoteAuthError(needAuthErr) } return "", err } if result.Token == "" { - return "", output.ErrAuth("no access token available for %s", as) + return "", newTokenMissingError(as, nil) } return result.Token, nil } +// newTokenMissingError builds the typed *errs.AuthenticationError that +// resolveAccessToken returns when no usable token is available for the +// requested identity. cause is the underlying credential-chain error (or nil +// for the defensive empty-token branch) and is preserved for errors.Is / +// errors.Unwrap traversal without being serialized on the wire. +func newTokenMissingError(as core.Identity, cause error) error { + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, + "no access token available for %s", as). + WithHint("run: lark-cli auth login to re-authorize"). + WithCause(cause) +} + // buildApiReq converts a RawApiRequest into SDK types and collects // request-specific options (ExtraOpts, URL-based headers). // Auth is handled separately by DoSDKRequest. @@ -93,14 +119,14 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark // and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls). // // SDK Do() failures are normalised through WrapDoAPIError so every caller -// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without each -// one remembering to wrap. In stage 1 that wire shape is still the legacy -// *output.ExitError envelope (network / api_error) — the stage-4 framework +// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without +// each one remembering to wrap. Today that wire shape is still the legacy +// *output.ExitError envelope (network / api_error); future framework- // boundary migration flips WrapDoAPIError to typed *errs.NetworkError / // *errs.InternalError per the contract in errs/ERROR_CONTRACT.md. // Errors that arrive already-classified (legacy *output.ExitError from -// resolveAccessToken's missing-credential paths, or a typed *errs.* from -// future stages) flow through unchanged. +// resolveAccessToken's missing-credential paths, or a typed *errs.*) flow +// through unchanged. func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) { var opts []larkcore.RequestOptionFunc @@ -177,7 +203,7 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core. httpReq, err := http.NewRequestWithContext(requestCtx, req.HttpMethod, requestURL, bodyReader) if err != nil { cancel() - return nil, output.ErrNetwork("stream request failed: %s", err) + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "stream request failed: %s", err).WithCause(err) } // Apply headers from opts @@ -195,7 +221,7 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core. resp, err := httpClient.Do(httpReq) if err != nil { cancel() - return nil, output.ErrNetwork("stream request failed: %s", err) + return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err) } resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel} @@ -204,15 +230,34 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core. defer resp.Body.Close() errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) msg := strings.TrimSpace(string(errBody)) - if msg != "" { - return nil, output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg) + subtype := errs.SubtypeNetworkTransport + if resp.StatusCode >= 500 { + subtype = errs.SubtypeNetworkServer } - return nil, output.ErrNetwork("HTTP %d", resp.StatusCode) + var netErr *errs.NetworkError + if msg != "" { + netErr = errs.NewNetworkError(subtype, "HTTP %d: %s", resp.StatusCode, msg) + } else { + netErr = errs.NewNetworkError(subtype, "HTTP %d", resp.StatusCode) + } + netErr = netErr.WithCode(resp.StatusCode) + if logID := streamLogID(resp.Header); logID != "" { + netErr = netErr.WithLogID(logID) + } + return nil, netErr } return resp, nil } +func streamLogID(header http.Header) string { + logID := strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyLogId)) + if logID == "" { + logID = strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyRequestId)) + } + return logID +} + type cancelOnCloseBody struct { io.ReadCloser cancel context.CancelFunc @@ -238,10 +283,10 @@ func buildStreamURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error) pathKey := strings.TrimPrefix(segment, ":") pathValue, ok := req.PathParams[pathKey] if !ok { - return "", output.ErrValidation("missing path param %q for %s", pathKey, req.ApiPath) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "missing path param %q for %s", pathKey, req.ApiPath).WithParam(pathKey) } if pathValue == "" { - return "", output.ErrValidation("empty path param %q for %s", pathKey, req.ApiPath) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "empty path param %q for %s", pathKey, req.ApiPath).WithParam(pathKey) } pathSegs = append(pathSegs, url.PathEscape(pathValue)) } @@ -267,7 +312,7 @@ func buildStreamBody(body interface{}) (io.Reader, string, error) { default: payload, err := json.Marshal(typed) if err != nil { - return nil, "", output.Errorf(output.ExitInternal, "api_error", "failed to encode request body: %s", err) + return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "failed to encode request body: %s", err).WithCause(err) } return bytes.NewReader(payload), "application/json", nil } @@ -288,11 +333,9 @@ func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore // JSON parse failures are wrapped via WrapJSONResponseParseError so callers // (notably the pagination loop and --page-all paths in cmd/api / cmd/service) // see an *output.ExitError envelope (api_error for malformed JSON, network -// for everything else) instead of a bare fmt.Errorf. Without this, an empty +// for everything else) instead of a bare fmt.Errorf — otherwise an empty // or malformed page body would surface to the root handler as a plain-text -// "Error: ..." line, bypassing the JSON stderr envelope contract. Stage-4 -// framework-boundary migration will flip this wrapper to typed -// *errs.InternalError / *errs.NetworkError. +// "Error: ..." line and bypass the JSON stderr envelope contract. func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) { resp, err := c.DoAPI(ctx, request) if err != nil { @@ -446,23 +489,23 @@ func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onIt return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil } -// CheckResponse inspects a Lark API response for business-level errors (non-zero code). -// -// Deprecated: legacy *output.ExitError wire shape via output.ErrAPI / -// ClassifyLarkError (type "api_error" / "permission" / etc). Preserved so -// existing callers keep emitting the same envelope until per-domain -// migration to typed errors. The identity parameter is reserved for the -// stage-2 typed path; stage-1 ignores it. +// CheckResponse inspects a Lark API response for business-level errors (non-zero code) +// and routes the result through errclass.BuildAPIError so the wire envelope carries +// the canonical Category/Subtype + identity-aware extension fields (MissingScopes, +// ConsoleURL, etc.) for known Lark codes; unknown codes still surface as +// *errs.APIError{Subtype: unknown}. func (c *APIClient) CheckResponse(result interface{}, identity core.Identity) error { resultMap, ok := result.(map[string]interface{}) if !ok || resultMap == nil { return nil } - code, _ := util.ToFloat64(resultMap["code"]) - if code == 0 { + if code, _ := util.ToFloat64(resultMap["code"]); code == 0 { return nil } - larkCode := int(code) - msg, _ := resultMap["msg"].(string) - return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"]) + cc := errclass.ClassifyContext{Identity: string(identity)} + if c != nil && c.Config != nil { + cc.Brand = string(c.Config.Brand) + cc.AppID = c.Config.AppID + } + return errclass.BuildAPIError(resultMap, cc) } diff --git a/internal/client/client_test.go b/internal/client/client_test.go index a1330c4a9..8cf38d95b 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "io" + "net" "net/http" "net/http/httptest" "strings" @@ -18,6 +19,8 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" + internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/output" @@ -428,6 +431,39 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) { } } +// TestDoStream_TransportFailureSplitsSubtype pins that a streaming-request +// transport failure routes through classifyNetworkSubtype rather than emitting +// a hardcoded SubtypeNetworkTransport for every cause. Concretely: a DNS +// failure must surface as SubtypeNetworkDNS so downstream agents can react +// (retry / give up / show recovery hint) without parsing the message text. +// Pre-fix, DoStream collapsed every httpClient.Do failure to NetworkTransport, +// erasing the timeout / TLS / DNS distinctions the SDK path already preserved. +func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) { + rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return nil, &net.DNSError{Err: "no such host", Name: "nowhere.invalid"} + }) + ac := &APIClient{ + HTTP: &http.Client{Transport: rt}, + Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil), + Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}, + } + + _, err := ac.DoStream(context.Background(), &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/drive/v1/files/file_token/download", + }, core.AsBot) + if err == nil { + t.Fatal("expected DNS error from DoStream transport, got nil") + } + var netErr *errs.NetworkError + if !errors.As(err, &netErr) { + t.Fatalf("expected *errs.NetworkError, got %T (%v)", err, err) + } + if netErr.Subtype != errs.SubtypeNetworkDNS { + t.Errorf("Subtype = %q, want %q (DNS failures must not be classified as generic transport)", netErr.Subtype, errs.SubtypeNetworkDNS) + } +} + // failingTokenResolver always returns TokenUnavailableError, exercising the // auth/credential failure path through resolveAccessToken. type failingTokenResolver struct{} @@ -436,17 +472,93 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T return nil, &credential.TokenUnavailableError{Source: "test", Type: spec.Type} } -// TestDoSDKRequest_AuthFailurePreservesAuthCategory pins the end-to-end -// invariant codex caught the day this PR landed: when resolveAccessToken -// produces output.ErrAuth ("no access token available for "), -// DoSDKRequest must surface it with the original auth classification — -// not silently downgrade it to a network error via the SDK-failure wrap. +// TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError pins that +// the missing-token path of resolveAccessToken returns the typed +// *errs.AuthenticationError{Subtype: TokenMissing} rather than the legacy +// *output.ExitError envelope. +func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) { + ac := &APIClient{ + HTTP: &http.Client{}, + Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil), + Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}, + } + + _, err := ac.resolveAccessToken(context.Background(), core.AsUser) + if err == nil { + t.Fatal("expected error when no token available, got nil") + } + + var authErr *errs.AuthenticationError + if !errors.As(err, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T (%v)", err, err) + } + if authErr.Category != errs.CategoryAuthentication { + t.Errorf("Category = %v, want %v", authErr.Category, errs.CategoryAuthentication) + } + if authErr.Subtype != errs.SubtypeTokenMissing { + t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing) + } +} + +// needAuthTokenResolver returns *internalauth.NeedAuthorizationError to +// exercise the P1 regression path: a credential chain that signals +// "user must re-authorize" must surface as typed AuthenticationError, not +// fall through to the generic err return which WrapDoAPIError would then +// wrap as NetworkError (the outer-typed dispatcher gate would then skip +// PromoteAuthError and the user would see exit 4 with no auth-login hint). +type needAuthTokenResolver struct { + userOpenID string +} + +func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) { + return nil, &internalauth.NeedAuthorizationError{UserOpenId: f.userOpenID} +} + +// TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication +// is the codex P1 regression test: without this branch, the credential +// chain's NeedAuthorizationError would propagate raw and WrapDoAPIError +// would mis-classify it as NetworkError. +func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) { + ac := &APIClient{ + HTTP: &http.Client{}, + Credential: credential.NewCredentialProvider(nil, nil, &needAuthTokenResolver{userOpenID: "ou_test_user"}, nil), + Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}, + } + + _, err := ac.resolveAccessToken(context.Background(), core.AsUser) + if err == nil { + t.Fatal("expected error when credential chain signals need_user_authorization, got nil") + } + + var authErr *errs.AuthenticationError + if !errors.As(err, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T (%v)", err, err) + } + if authErr.Subtype != errs.SubtypeTokenMissing { + t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing) + } + if !strings.Contains(authErr.Message, "need_user_authorization") { + t.Errorf("Message must contain the marker 'need_user_authorization' (invariant), got %q", authErr.Message) + } + // Underlying NeedAuthorizationError preserved in Cause chain so + // existing errors.As(&NeedAuthorizationError{}) consumers still match. + var needErr *internalauth.NeedAuthorizationError + if !errors.As(err, &needErr) { + t.Errorf("NeedAuthorizationError not preserved in Cause chain") + } +} + +// TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError pins the +// end-to-end invariant codex caught the day this PR landed: when +// resolveAccessToken fails because no token is cached, DoSDKRequest must +// surface that as a typed *errs.AuthenticationError — not silently downgrade +// it to a network error via the SDK-failure wrap. // // Regression scenario: shortcut path // (shortcuts/common/runner.go DoAPI → DoSDKRequest) calling against a user // identity with no cached token. Pre-fix this surfaced as exit 4/type=network // and routed agents into "check your connection" instead of "log in". -func TestDoSDKRequest_AuthFailurePreservesAuthCategory(t *testing.T) { +func TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError(t *testing.T) { ac := &APIClient{ HTTP: &http.Client{}, Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil), @@ -461,22 +573,20 @@ func TestDoSDKRequest_AuthFailurePreservesAuthCategory(t *testing.T) { if err == nil { t.Fatal("expected auth error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", err) + var authErr *errs.AuthenticationError + if !errors.As(err, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T (%v) — WrapDoAPIError must pass typed *errs.* through unchanged", err, err) } - if exitErr.Code != output.ExitAuth { - t.Fatalf("Code = %d, want %d (auth) — confirms ErrAuth was downgraded to network at SDK wrap", exitErr.Code, output.ExitAuth) - } - if exitErr.Detail == nil || exitErr.Detail.Type != "auth" { - t.Fatalf("Detail.Type = %v, want auth", exitErr.Detail) + if authErr.Subtype != errs.SubtypeTokenMissing { + t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing) } } // TestDoSDKRequest_TransportFailureWrapsAsNetwork pins that genuinely untyped -// SDK transport errors get the network classification via WrapDoAPIError. +// SDK transport errors get the typed network classification via WrapDoAPIError. // io.ErrUnexpectedEOF from a RoundTripper surfaces through net/http as a -// *url.Error, which the wrap classifier recognises as a transport error. +// *url.Error, which the wrap classifier reaches as the transport-error +// fallback (no specific subtype matches — falls back to transport). func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) { rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) { return nil, io.ErrUnexpectedEOF @@ -491,25 +601,29 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) { if err == nil { t.Fatal("expected error from broken transport, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", err) + var netErr *errs.NetworkError + if !errors.As(err, &netErr) { + t.Fatalf("expected *errs.NetworkError, got %T (%v)", err, err) } - if exitErr.Code != output.ExitNetwork { - t.Fatalf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork) + if netErr.Category != errs.CategoryNetwork { + t.Errorf("Category = %v, want %v", netErr.Category, errs.CategoryNetwork) } - if exitErr.Detail == nil || exitErr.Detail.Type != "network" { - t.Fatalf("Detail.Type = %v, want network", exitErr.Detail) + if netErr.Subtype != errs.SubtypeNetworkTransport { + t.Errorf("Subtype = %v, want %v", netErr.Subtype, errs.SubtypeNetworkTransport) + } + // io.ErrUnexpectedEOF round-tripping through net/http does not satisfy + // any of the specific cause checks; subtype falls back to transport. + if output.ExitCodeOf(err) != output.ExitNetwork { + t.Errorf("ExitCodeOf = %d, want %d (network)", output.ExitCodeOf(err), output.ExitNetwork) } } -// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the legacy-envelope contract for -// malformed JSON response bodies: WrapJSONResponseParseError emits api_error -// (exit 1) with the rawAPIJSONHint, so the pagination / cmd/api / cmd/service -// callers always see a JSON stderr envelope instead of a bare "Error: ..." -// line. Stage-4 framework-boundary migration will flip this wrapper to typed -// *errs.InternalError; until then this test pins the legacy shape so we do -// not regress envelope coverage. +// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the typed-envelope contract for +// malformed JSON response bodies: WrapJSONResponseParseError emits +// *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint +// preserved on Problem.Hint. Pagination / cmd/api / cmd/service callers see +// the typed JSON stderr envelope (exit 5/internal) — wire `type` is +// "internal", not the legacy "api_error". func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) { rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) { return &http.Response{ @@ -529,17 +643,20 @@ func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) { if err == nil { t.Fatal("expected JSON parse error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", err) + var intErr *errs.InternalError + if !errors.As(err, &intErr) { + t.Fatalf("expected *errs.InternalError, got %T (%v)", err, err) } - if exitErr.Code != output.ExitAPI { - t.Fatalf("Code = %d, want %d (api)", exitErr.Code, output.ExitAPI) + if intErr.Category != errs.CategoryInternal { + t.Errorf("Category = %v, want %v", intErr.Category, errs.CategoryInternal) } - if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" { - t.Fatalf("Detail.Type = %v, want api_error", exitErr.Detail) + if intErr.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("Subtype = %v, want %v", intErr.Subtype, errs.SubtypeInvalidResponse) } - if exitErr.Detail.Hint != rawAPIJSONHint { - t.Errorf("Detail.Hint = %q, want rawAPIJSONHint", exitErr.Detail.Hint) + if intErr.Hint != rawAPIJSONHint { + t.Errorf("Hint = %q, want rawAPIJSONHint preserved", intErr.Hint) + } + if output.ExitCodeOf(err) != output.ExitInternal { + t.Errorf("ExitCodeOf = %d, want %d (internal)", output.ExitCodeOf(err), output.ExitInternal) } } diff --git a/internal/client/dostream_test.go b/internal/client/dostream_test.go new file mode 100644 index 000000000..0a33868a4 --- /dev/null +++ b/internal/client/dostream_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package client_test + +import ( + "context" + "errors" + "net/http" + "testing" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + config := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu} + factory, _, _, reg := cmdutil.TestFactory(t, config) + reg.Register(&httpmock.Stub{ + Method: http.MethodGet, + URL: "/open-apis/drive/v1/medias/file_token/download", + Status: http.StatusForbidden, + RawBody: []byte("forbidden"), + Headers: http.Header{ + larkcore.HttpHeaderKeyLogId: []string{"202605270003"}, + }, + }) + + client, err := factory.NewAPIClientWithConfig(config) + if err != nil { + t.Fatalf("NewAPIClientWithConfig() error = %v", err) + } + + _, err = client.DoStream(context.Background(), &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/drive/v1/medias/file_token/download", + }, core.AsBot) + var netErr *errs.NetworkError + if !errors.As(err, &netErr) { + t.Fatalf("expected *errs.NetworkError, got %T %v", err, err) + } + if netErr.LogID != "202605270003" { + t.Fatalf("LogID = %q, want %q", netErr.LogID, "202605270003") + } +} diff --git a/internal/client/response.go b/internal/client/response.go index 67ff98a0d..fbd88f7c3 100644 --- a/internal/client/response.go +++ b/internal/client/response.go @@ -14,6 +14,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" @@ -52,12 +53,10 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { } check := opts.CheckError if check == nil { - // Stage 1: default check routes through legacy CheckResponse - // (output.ErrAPI / ClassifyLarkError). Stage-2+ migration will - // switch this to errclass.BuildAPIError so PermissionError carries - // MissingScopes / ConsoleURL — at that point a zero-value - // *APIClient still works because BuildAPIError short-circuits on - // empty AppID, gracefully degrading identity-aware fields. + // Default check routes through BuildAPIError, producing typed + // *errs.PermissionError / AuthenticationError / etc. A zero-value + // *APIClient is safe here because BuildAPIError gracefully degrades + // identity-aware fields (ConsoleURL etc.) when AppID is empty. check = func(r interface{}, id core.Identity) error { return (&APIClient{}).CheckResponse(r, id) } @@ -65,9 +64,20 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { // Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly // instead of falling through to the binary-save path. + // 5xx → typed NetworkError (server/transport tier); 4xx → typed APIError (client error). if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" { body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(resp.RawBody)), 500) - return output.Errorf(httpExitCode(resp.StatusCode), "http_error", "HTTP %d: %s", resp.StatusCode, body) + if resp.StatusCode >= 500 { + return errs.NewNetworkError(errs.SubtypeNetworkServer, + "HTTP %d: %s", resp.StatusCode, body). + WithCode(resp.StatusCode) + } + subtype := errs.SubtypeUnknown + if resp.StatusCode == 404 { + subtype = errs.SubtypeNotFound + } + return errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body). + WithCode(resp.StatusCode) } // JSON responses: always check for business errors before saving. @@ -102,7 +112,9 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { // Non-JSON (binary) responses. if opts.JqExpr != "" { - return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct) + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--jq requires a JSON response (got Content-Type: %s)", ct). + WithParam("--jq") } if opts.OutputPath != "" { return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out) @@ -111,7 +123,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { // No --output: auto-save with derived filename. meta, err := SaveResponse(opts.FileIO, resp, ResolveFilename(resp)) if err != nil { - return output.Errorf(output.ExitInternal, "file_error", "%s", err) + return classifySaveErr(err) } fmt.Fprintf(opts.ErrOut, "binary response detected (Content-Type: %s), saved to file\n", ct) output.PrintJson(opts.Out, meta) @@ -121,12 +133,23 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { func saveAndPrint(fio fileio.FileIO, resp *larkcore.ApiResp, path string, w io.Writer) error { meta, err := SaveResponse(fio, resp, path) if err != nil { - return output.Errorf(output.ExitInternal, "file_error", "%s", err) + return classifySaveErr(err) } output.PrintJson(w, meta) return nil } +// classifySaveErr routes a SaveResponse error to the right typed shape. +// Path-validation failures are caller-induced (an unsafe --output path), +// so they surface as ValidationError on --output. Mkdir / write failures +// are local I/O issues classified as InternalError with SubtypeFileIO. +func classifySaveErr(err error) error { + if errors.Is(err, fileio.ErrPathValidation) { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--output") + } + return errs.NewInternalError(errs.SubtypeFileIO, "save response: %v", err).WithCause(err) +} + // ── JSON helpers ── // IsJSONContentType reports whether the Content-Type header indicates a JSON response. @@ -160,13 +183,13 @@ func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string) var we *fileio.WriteError switch { case errors.Is(err, fileio.ErrPathValidation): - return nil, fmt.Errorf("unsafe output path: %s", err) + return nil, fmt.Errorf("unsafe output path: %w", err) case errors.As(err, &me): - return nil, fmt.Errorf("create directory: %s", err) + return nil, fmt.Errorf("create directory: %w", err) case errors.As(err, &we): - return nil, fmt.Errorf("cannot write file: %s", err) + return nil, fmt.Errorf("cannot write file: %w", err) default: - return nil, fmt.Errorf("cannot write file: %s", err) + return nil, fmt.Errorf("cannot write file: %w", err) } } @@ -225,12 +248,3 @@ func mimeToExt(ct string) string { return ".bin" } } - -// httpExitCode maps HTTP status ranges to CLI exit codes: -// 5xx → ExitNetwork (server error), 4xx → ExitAPI (client error). -func httpExitCode(status int) int { - if status >= 500 { - return output.ExitNetwork - } - return output.ExitAPI -} diff --git a/internal/client/response_test.go b/internal/client/response_test.go index 4030d1ce2..0902e5556 100644 --- a/internal/client/response_test.go +++ b/internal/client/response_test.go @@ -15,6 +15,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/vfs/localfileio" ) @@ -294,9 +295,12 @@ func TestHandleResponse_NonJSONError_404(t *testing.T) { if !strings.Contains(got, "HTTP 404") || !strings.Contains(got, "404 page not found") { t.Errorf("expected 'HTTP 404: 404 page not found', got: %s", got) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI { - t.Errorf("expected ExitAPI (%d) for 4xx, got code: %d", output.ExitAPI, exitErr.Code) + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Errorf("expected *errs.APIError, got %T", err) + } + if output.ExitCodeOf(err) != output.ExitAPI { + t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err)) } } @@ -312,9 +316,12 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) { if !strings.Contains(got, "HTTP 502") || !strings.Contains(got, "Bad Gateway") { t.Errorf("expected 'HTTP 502' and 'Bad Gateway' in error, got: %s", got) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Code != output.ExitNetwork { - t.Errorf("expected ExitNetwork (%d) for 5xx, got code: %d", output.ExitNetwork, exitErr.Code) + var netErr *errs.NetworkError + if !errors.As(err, &netErr) { + t.Errorf("expected *errs.NetworkError, got %T", err) + } + if output.ExitCodeOf(err) != output.ExitNetwork { + t.Errorf("expected ExitNetwork (%d) for 5xx, got %d", output.ExitNetwork, output.ExitCodeOf(err)) } } diff --git a/internal/cmdpolicy/active.go b/internal/cmdpolicy/active.go index 488d641c1..7a766e29d 100644 --- a/internal/cmdpolicy/active.go +++ b/internal/cmdpolicy/active.go @@ -15,8 +15,12 @@ import ( // it hide?". // // Set once at bootstrap time; consumed read-only thereafter. +// +// Rules is the full set the winning source contributed (one rule for the +// common single-rule case, several when a plugin or yaml declares scoped +// grants). nil/empty means "no rule applied". type ActivePolicy struct { - Rule *platform.Rule + Rules []*platform.Rule Source ResolveSource DeniedPaths int // number of commands the engine marked as denied (post-aggregation) } @@ -56,20 +60,26 @@ func GetActive() *ActivePolicy { return cloneActivePolicy(activePolicy) } -// cloneActivePolicy deep-copies the top-level struct plus the embedded -// Rule's slice fields. Other fields (Source, DeniedPaths) are value -// types so the struct copy already disjoints them. +// cloneActivePolicy deep-copies the top-level struct, the Rules slice, and +// each Rule's own slice fields. Other fields (Source, DeniedPaths) are +// value types so the struct copy already disjoints them. func cloneActivePolicy(in *ActivePolicy) *ActivePolicy { if in == nil { return nil } cp := *in - if in.Rule != nil { - rule := *in.Rule - rule.Allow = append([]string(nil), in.Rule.Allow...) - rule.Deny = append([]string(nil), in.Rule.Deny...) - rule.Identities = append([]platform.Identity(nil), in.Rule.Identities...) - cp.Rule = &rule + if in.Rules != nil { + cp.Rules = make([]*platform.Rule, len(in.Rules)) + for i, r := range in.Rules { + if r == nil { + continue + } + rule := *r + rule.Allow = append([]string(nil), r.Allow...) + rule.Deny = append([]string(nil), r.Deny...) + rule.Identities = append([]platform.Identity(nil), r.Identities...) + cp.Rules[i] = &rule + } } return &cp } diff --git a/internal/cmdpolicy/engine.go b/internal/cmdpolicy/engine.go index c2e7e0162..3b0e2c1eb 100644 --- a/internal/cmdpolicy/engine.go +++ b/internal/cmdpolicy/engine.go @@ -17,6 +17,7 @@ package cmdpolicy import ( "fmt" + "strings" "github.com/bmatcuk/doublestar/v4" "github.com/spf13/cobra" @@ -36,16 +37,45 @@ type Decision struct { Reason string // human-readable } -// Engine evaluates a Rule against the command tree. It is stateless except -// for the Rule snapshot it was constructed with. +// Engine evaluates a set of Rules against the command tree with OR +// semantics: a command is allowed when it satisfies every axis of AT +// LEAST ONE rule. It is stateless except for the Rule snapshot it was +// constructed with. type Engine struct { - rule *platform.Rule + rules []*platform.Rule } -// New returns an Engine bound to a Rule. A nil Rule means "no user-layer -// restriction" -- EvaluateOne always returns Allowed=true. +// New returns an Engine bound to a single Rule. A nil Rule means "no +// user-layer restriction" -- EvaluateOne always returns Allowed=true. +// It is the ergonomic single-rule constructor, kept so existing callers +// (and the single-rule decision path) stay byte-for-byte unchanged. func New(rule *platform.Rule) *Engine { - return &Engine{rule: rule} + if rule == nil { + return &Engine{} + } + return &Engine{rules: []*platform.Rule{rule}} +} + +// NewSet returns an Engine bound to a set of Rules evaluated with OR +// semantics. An empty/nil slice means "no user-layer restriction". nil +// entries are dropped so callers may pass a slice with gaps without a +// separate filter step. +// +// With exactly one rule the behaviour is identical to New(rule): the +// rejection Decision is returned verbatim. With multiple rules a command +// rejected by all of them gets the aggregate reason_code +// "no_matching_rule" (see mergeDenials). +func NewSet(rules []*platform.Rule) *Engine { + cleaned := make([]*platform.Rule, 0, len(rules)) + for _, r := range rules { + if r != nil { + cleaned = append(cleaned, r) + } + } + if len(cleaned) == 0 { + return &Engine{} + } + return &Engine{rules: cleaned} } // EvaluateAll walks the command tree and evaluates every **runnable** @@ -81,27 +111,29 @@ func (e *Engine) EvaluateAll(root *cobra.Command) map[string]Decision { } // EvaluateOne returns the user-layer decision for a single command. Always -// Allowed=true when the engine has no Rule. +// Allowed=true when the engine has no Rule. With multiple rules the +// decision is the OR over per-rule evaluations: the command is allowed as +// soon as one rule grants it; if every rule rejects it, the rejections are +// merged (see mergeDenials). func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision { - if e.rule == nil { + if len(e.rules) == 0 { return Decision{Allowed: true} } - r := e.rule path := CanonicalPath(cmd) if IsDiagnosticPath(path) { return Decision{Allowed: true} } - // A registered Rule expresses intent over the closed risk taxonomy - // (read / write / high-risk-write). Two ways a command can fall - // outside that taxonomy: + // risk_invalid is a property of the COMMAND's own annotation (the + // annotation exists but is a typo / not in the closed taxonomy + // read / write / high-risk-write). It is independent of any Rule and + // is always fail-closed regardless of AllowUnannotated -- a typo is a + // code bug, not a migration phase. So it is checked once up front, + // before the per-rule OR loop, and short-circuits to deny. // - // - "absent" (no risk_level annotation) — fail-closed by default, - // but Rule.AllowUnannotated=true opts out for gradual adoption. - // - "invalid" (annotation exists but is a typo / not in the - // closed enum) — always fail-closed regardless of - // AllowUnannotated. Typo is a code bug, not a migration phase. + // The "absent" case (no risk_level annotation at all) is per-rule: + // each rule's AllowUnannotated decides, so it lives inside evalRule. cmdRiskStr, hasRisk := cmdmeta.Risk(cmd) cmdRisk := platform.Risk(cmdRiskStr) var ( @@ -117,7 +149,31 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision { Reason: fmt.Sprintf("invalid risk %q; did you mean %q?", cmdRiskStr, suggestRisk(cmdRiskStr)), } } - } else if !r.AllowUnannotated { + } + + // OR across rules: the first rule that fully grants the command wins. + denials := make([]Decision, 0, len(e.rules)) + for _, r := range e.rules { + d := evalRule(r, path, cmd, hasRisk, cmdRisk, cmdRank, cmdRankOk) + if d.Allowed { + return Decision{Allowed: true} + } + denials = append(denials, d) + } + return mergeDenials(e.rules, denials) +} + +// evalRule applies one Rule's four-axis AND filter to a command whose +// risk annotation has already been parsed by EvaluateOne (risk_invalid is +// handled there). cmdRankOk is false only when the command is unannotated +// (hasRisk=false); a present-but-invalid risk never reaches here. Returns +// Allowed=true only when the command clears every axis of this rule. +func evalRule(r *platform.Rule, path string, cmd *cobra.Command, hasRisk bool, cmdRisk platform.Risk, cmdRank int, cmdRankOk bool) Decision { + // Unannotated gate: fail-closed unless THIS rule opts out. A command + // with no risk_level annotation can still be granted by a rule that + // sets AllowUnannotated=true (gradual-adoption opt-in); other rules in + // the set reject it here and the OR moves on. + if !hasRisk && !r.AllowUnannotated { return Decision{ Allowed: false, ReasonCode: "risk_not_annotated", @@ -125,7 +181,9 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision { } } - // Axis 1: Deny has priority. + // Axis 1: Deny has priority. Note OR semantics scope a rule's Deny to + // that rule only -- it cannot veto another rule's Allow. A command to + // block everywhere must be denied (or simply not allowed) by every rule. if matched, ok := firstMatch(r.Deny, path); ok { return Decision{ Allowed: false, @@ -171,6 +229,34 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision { return Decision{Allowed: true} } +// mergeDenials collapses the per-rule rejections into a single Decision +// for a command that no rule granted. denials is parallel to rules (same +// order, one entry per rule, all Allowed=false). +// +// With exactly one rule the original rejection is returned verbatim, so +// single-rule envelopes are byte-for-byte identical to the pre-multi-rule +// behaviour (reason_code / reason unchanged). With multiple rules the +// rejection is the aggregate reason_code "no_matching_rule"; its Reason +// enumerates each rule's own rejection for debugging. +func mergeDenials(rules []*platform.Rule, denials []Decision) Decision { + if len(denials) == 1 { + return denials[0] + } + parts := make([]string, len(denials)) + for i, d := range denials { + name := rules[i].Name + if name == "" { + name = fmt.Sprintf("#%d", i) + } + parts[i] = fmt.Sprintf("%s: %s", name, d.ReasonCode) + } + return Decision{ + Allowed: false, + ReasonCode: "no_matching_rule", + Reason: fmt.Sprintf("no rule grants this command (%s)", strings.Join(parts, "; ")), + } +} + // BuildDeniedByPath converts engine Decisions to a deniedByPath map keyed // by canonical path. It performs the parent-group aggregation defined in // the tech doc: a non-runnable parent whose every runnable descendant is diff --git a/internal/cmdpolicy/engine_test.go b/internal/cmdpolicy/engine_test.go index e2e9ca559..992b599b4 100644 --- a/internal/cmdpolicy/engine_test.go +++ b/internal/cmdpolicy/engine_test.go @@ -398,6 +398,93 @@ func TestEvaluate_unknownIdentitiesIsAllow(t *testing.T) { } } +// --- Multi-rule (OR) semantics --- + +// Two scoped rules (docs read-only, im writable) are OR-combined: a +// command is allowed when it satisfies ANY rule. This is the headline +// multi-rule use case -- different command groups need different risk +// ceilings within one policy. +func TestEvaluate_multiRuleOR(t *testing.T) { + root := buildTree() + e := cmdpolicy.NewSet([]*platform.Rule{ + {Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"}, + {Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"}, + }) + got := e.EvaluateAll(root) + + // docs/+fetch (read) clears docs-ro. + if !got["docs/+fetch"].Allowed { + t.Errorf("docs/+fetch should be allowed by docs-ro") + } + // im/+send (write) clears im-rw even though docs-ro rejects it. + if !got["im/+send"].Allowed { + t.Errorf("im/+send (write) should be allowed by im-rw") + } + // docs/+update (write) exceeds docs-ro's read ceiling AND is outside + // im-rw's allow list -> rejected by both -> no_matching_rule. + if got["docs/+update"].Allowed { + t.Fatalf("docs/+update should be denied: read-only in docs, not allowed in im") + } + if rc := got["docs/+update"].ReasonCode; rc != "no_matching_rule" { + t.Errorf("docs/+update ReasonCode = %q, want no_matching_rule", rc) + } +} + +// Identity can differ per rule: docs limited to user, im open to bot. +// This is the second half of the requirement -- some commands restrict +// identity, others allow the bot identity. +func TestEvaluate_multiRulePerRuleIdentity(t *testing.T) { + root := buildTree() + e := cmdpolicy.NewSet([]*platform.Rule{ + {Name: "docs-user", Allow: []string{"docs/**"}, MaxRisk: "write", Identities: []platform.Identity{"user"}}, + {Name: "im-bot", Allow: []string{"im/**"}, MaxRisk: "write", Identities: []platform.Identity{"bot"}}, + }) + got := e.EvaluateAll(root) + + // docs/+update identities=[user] -> docs-user grants. + if !got["docs/+update"].Allowed { + t.Errorf("docs/+update (user) should be allowed by docs-user") + } + // im/+send identities=[bot] -> im-bot grants. + if !got["im/+send"].Allowed { + t.Errorf("im/+send (bot) should be allowed by im-bot") + } + // docs/+delete-doc is high-risk-write -> exceeds both ceilings -> denied. + if got["docs/+delete-doc"].Allowed { + t.Errorf("docs/+delete-doc (high-risk-write) should be denied by both rules") + } +} + +// NewSet with a single rule must behave exactly like New: the per-rule +// rejection (not the aggregate no_matching_rule) is preserved so the +// single-rule envelope is unchanged. +func TestEvaluate_newSetSingleRuleKeepsReason(t *testing.T) { + root := buildTree() + e := cmdpolicy.NewSet([]*platform.Rule{ + {Allow: []string{"docs/**"}}, + }) + got := e.EvaluateAll(root) + if got["im/+send"].Allowed { + t.Fatalf("im/+send should be denied by docs-only rule") + } + if rc := got["im/+send"].ReasonCode; rc != "domain_not_allowed" { + t.Errorf("single-rule reason must be preserved verbatim, got %q want domain_not_allowed", rc) + } +} + +// NewSet drops nil entries; an all-nil/empty set means "no restriction". +func TestNewSet_emptyAndNilMeansNoRestriction(t *testing.T) { + root := buildTree() + for _, rules := range [][]*platform.Rule{nil, {}, {nil}} { + got := cmdpolicy.NewSet(rules).EvaluateAll(root) + for path, d := range got { + if !d.Allowed { + t.Fatalf("empty/nil rule set must allow all, got deny for %s", path) + } + } + } +} + // Apply must install denyStubs only on Layer="policy" entries. A // "strict_mode" denial in the same map must be left for // applyStrictModeDenials in cmd/. diff --git a/internal/cmdpolicy/resolver.go b/internal/cmdpolicy/resolver.go index d70335f58..3afd6fcf3 100644 --- a/internal/cmdpolicy/resolver.go +++ b/internal/cmdpolicy/resolver.go @@ -33,44 +33,69 @@ type PluginRule struct { type Sources struct { PluginRules []PluginRule - YAMLRule *platform.Rule + YAMLRules []*platform.Rule YAMLPath string } -var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one is permitted") +var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one plugin may own the policy") -// Resolve picks by precedence: plugin > yaml > none. Pure function; load -// yaml via LoadYAMLPolicy first. Winner is validated. -func Resolve(s Sources) (*platform.Rule, ResolveSource, error) { - if len(s.PluginRules) > 1 { - names := make([]string, len(s.PluginRules)) - for i, pr := range s.PluginRules { - names[i] = pr.PluginName - } - return nil, ResolveSource{}, fmt.Errorf("%w: %v", ErrMultipleRestricts, names) +// Resolve picks by precedence: plugin > yaml > none, returning the full +// rule set the winning source contributes. Pure function; load yaml via +// LoadYAMLPolicy first. Every returned rule is validated. +// +// Multi-rule semantics (single owner): one plugin may contribute several +// rules (each a scoped grant, OR-combined by the engine), but two or more +// DISTINCT plugins contributing rules is still a configuration error -- +// the resolver aborts so independent plugins cannot silently widen each +// other's policy. yaml may likewise carry several rules under "rules:". +func Resolve(s Sources) ([]*platform.Rule, ResolveSource, error) { + owners := distinctOwners(s.PluginRules) + if len(owners) > 1 { + return nil, ResolveSource{}, fmt.Errorf("%w: %v", ErrMultipleRestricts, owners) } - if len(s.PluginRules) == 1 { - pr := s.PluginRules[0] - if err := ValidateRule(pr.Rule); err != nil { - return nil, ResolveSource{}, fmt.Errorf("plugin %q rule invalid: %w", pr.PluginName, err) + if len(s.PluginRules) > 0 { + rules := make([]*platform.Rule, 0, len(s.PluginRules)) + for _, pr := range s.PluginRules { + if err := ValidateRule(pr.Rule); err != nil { + return nil, ResolveSource{}, fmt.Errorf("plugin %q rule invalid: %w", pr.PluginName, err) + } + rules = append(rules, pr.Rule) } - return pr.Rule, ResolveSource{Kind: SourcePlugin, Name: pr.PluginName}, nil + return rules, ResolveSource{Kind: SourcePlugin, Name: owners[0]}, nil } - if s.YAMLRule != nil { - if err := ValidateRule(s.YAMLRule); err != nil { - return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", s.YAMLPath, err) + if len(s.YAMLRules) > 0 { + for _, r := range s.YAMLRules { + if err := ValidateRule(r); err != nil { + return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", s.YAMLPath, err) + } } - return s.YAMLRule, ResolveSource{Kind: SourceYAML, Name: s.YAMLPath}, nil + return s.YAMLRules, ResolveSource{Kind: SourceYAML, Name: s.YAMLPath}, nil } return nil, ResolveSource{Kind: SourceNone}, nil } +// distinctOwners returns the unique plugin names contributing a rule, in +// first-seen order. A single plugin contributing N rules collapses to one +// owner; that is the case the single-owner check below permits. +func distinctOwners(prs []PluginRule) []string { + seen := map[string]bool{} + owners := make([]string, 0, len(prs)) + for _, pr := range prs { + if !seen[pr.PluginName] { + seen[pr.PluginName] = true + owners = append(owners, pr.PluginName) + } + } + return owners +} + // LoadYAMLPolicy returns (nil, nil) when path is empty or file is absent, -// so callers can pass the result straight into Sources.YAMLRule. -func LoadYAMLPolicy(path string) (*platform.Rule, error) { +// so callers can pass the result straight into Sources.YAMLRules. A +// present file yields one or more rules (see yaml.Parse). +func LoadYAMLPolicy(path string) ([]*platform.Rule, error) { if path == "" { return nil, nil } @@ -84,9 +109,9 @@ func LoadYAMLPolicy(path string) (*platform.Rule, error) { if err != nil { return nil, fmt.Errorf("read policy yaml %q: %w", path, err) } - rule, err := pyaml.Parse(data) + rules, err := pyaml.Parse(data) if err != nil { return nil, fmt.Errorf("policy yaml %q: %w", path, err) } - return rule, nil + return rules, nil } diff --git a/internal/cmdpolicy/resolver_test.go b/internal/cmdpolicy/resolver_test.go index 1631cb6c7..e687574db 100644 --- a/internal/cmdpolicy/resolver_test.go +++ b/internal/cmdpolicy/resolver_test.go @@ -21,23 +21,45 @@ func TestResolve_singlePluginWins(t *testing.T) { if err != nil { t.Fatalf("Resolve err: %v", err) } - if got != rule || src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" { + if len(got) != 1 || got[0] != rule || src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" { t.Fatalf("Resolve = (%v, %+v)", got, src) } } +// A single plugin may contribute several rules (each a scoped grant). They +// are all returned, in registration order, under one plugin source. +func TestResolve_singlePluginMultipleRules(t *testing.T) { + r1 := &platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"} + r2 := &platform.Rule{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"} + got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{ + PluginRules: []cmdpolicy.PluginRule{ + {PluginName: "secaudit", Rule: r1}, + {PluginName: "secaudit", Rule: r2}, + }, + }) + if err != nil { + t.Fatalf("Resolve err: %v", err) + } + if len(got) != 2 || got[0] != r1 || got[1] != r2 { + t.Fatalf("expected both rules in order, got %v", got) + } + if src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" { + t.Fatalf("source = %+v, want plugin:secaudit", src) + } +} + func TestResolve_pluginShadowsYaml(t *testing.T) { pluginRule := &platform.Rule{Name: "from-plugin"} yamlRule := &platform.Rule{Name: "from-yaml"} got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{ PluginRules: []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: pluginRule}}, - YAMLRule: yamlRule, + YAMLRules: []*platform.Rule{yamlRule}, YAMLPath: "/some/policy.yml", }) if err != nil { t.Fatalf("Resolve err: %v", err) } - if got.Name != "from-plugin" || src.Kind != cmdpolicy.SourcePlugin { + if len(got) != 1 || got[0].Name != "from-plugin" || src.Kind != cmdpolicy.SourcePlugin { t.Fatalf("plugin should shadow yaml, got %+v / %+v", got, src) } } @@ -45,13 +67,13 @@ func TestResolve_pluginShadowsYaml(t *testing.T) { func TestResolve_yamlWhenNoPlugin(t *testing.T) { yamlRule := &platform.Rule{Name: "from-yaml", MaxRisk: "read"} got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{ - YAMLRule: yamlRule, - YAMLPath: "/some/policy.yml", + YAMLRules: []*platform.Rule{yamlRule}, + YAMLPath: "/some/policy.yml", }) if err != nil { t.Fatalf("Resolve err: %v", err) } - if got.Name != "from-yaml" || src.Kind != cmdpolicy.SourceYAML { + if len(got) != 1 || got[0].Name != "from-yaml" || src.Kind != cmdpolicy.SourceYAML { t.Fatalf("yaml should win when no plugin, got %+v / %+v", got, src) } if src.Name != "/some/policy.yml" { @@ -59,19 +81,36 @@ func TestResolve_yamlWhenNoPlugin(t *testing.T) { } } +// yaml may also carry several rules under "rules:"; all are returned. +func TestResolve_yamlMultipleRules(t *testing.T) { + r1 := &platform.Rule{Name: "a", MaxRisk: "read"} + r2 := &platform.Rule{Name: "b", MaxRisk: "write"} + got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{ + YAMLRules: []*platform.Rule{r1, r2}, + YAMLPath: "/some/policy.yml", + }) + if err != nil { + t.Fatalf("Resolve err: %v", err) + } + if len(got) != 2 || src.Kind != cmdpolicy.SourceYAML { + t.Fatalf("expected both yaml rules, got %v / %+v", got, src) + } +} + func TestResolve_emptyEverythingIsNone(t *testing.T) { got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{}) if err != nil { t.Fatalf("Resolve err: %v", err) } - if got != nil || src.Kind != cmdpolicy.SourceNone { - t.Fatalf("expected (nil, SourceNone), got (%v, %+v)", got, src) + if len(got) != 0 || src.Kind != cmdpolicy.SourceNone { + t.Fatalf("expected (empty, SourceNone), got (%v, %+v)", got, src) } } -// Two plugins both contributing a Rule must produce the typed error so -// the bootstrap pipeline aborts (hard-constraint #7). -func TestResolve_multipleRestrictIsError(t *testing.T) { +// Two DISTINCT plugins both contributing a Rule must produce the typed +// error so the bootstrap pipeline aborts (single-owner invariant): one +// plugin cannot silently widen another plugin's policy. +func TestResolve_multipleRestrictPluginsIsError(t *testing.T) { _, _, err := cmdpolicy.Resolve(cmdpolicy.Sources{ PluginRules: []cmdpolicy.PluginRule{ {PluginName: "a", Rule: &platform.Rule{Name: "a"}}, @@ -84,26 +123,26 @@ func TestResolve_multipleRestrictIsError(t *testing.T) { } // LoadYAMLPolicy: missing file returns (nil, nil) silently so callers -// can pass the result straight into Sources.YAMLRule without special- +// can pass the result straight into Sources.YAMLRules without special- // casing not-exist. func TestLoadYAMLPolicy_missingIsSilent(t *testing.T) { missing := filepath.Join(t.TempDir(), "absent-policy.yml") - rule, err := cmdpolicy.LoadYAMLPolicy(missing) + rules, err := cmdpolicy.LoadYAMLPolicy(missing) if err != nil { t.Fatalf("missing yaml should not error, got %v", err) } - if rule != nil { - t.Fatalf("missing yaml should return nil rule, got %+v", rule) + if rules != nil { + t.Fatalf("missing yaml should return nil rules, got %+v", rules) } } func TestLoadYAMLPolicy_emptyPathIsNoop(t *testing.T) { - rule, err := cmdpolicy.LoadYAMLPolicy("") + rules, err := cmdpolicy.LoadYAMLPolicy("") if err != nil { t.Fatalf("empty path should not error, got %v", err) } - if rule != nil { - t.Fatalf("empty path should return nil rule, got %+v", rule) + if rules != nil { + t.Fatalf("empty path should return nil rules, got %+v", rules) } } @@ -113,11 +152,11 @@ func TestLoadYAMLPolicy_parsesValid(t *testing.T) { if err := os.WriteFile(yamlPath, []byte("name: from-yaml\nmax_risk: read\n"), 0o644); err != nil { t.Fatalf("write yaml: %v", err) } - rule, err := cmdpolicy.LoadYAMLPolicy(yamlPath) + rules, err := cmdpolicy.LoadYAMLPolicy(yamlPath) if err != nil { t.Fatalf("LoadYAMLPolicy err: %v", err) } - if rule == nil || rule.Name != "from-yaml" { - t.Fatalf("expected rule with name=from-yaml, got %+v", rule) + if len(rules) != 1 || rules[0].Name != "from-yaml" { + t.Fatalf("expected one rule with name=from-yaml, got %+v", rules) } } diff --git a/internal/cmdpolicy/yaml/schema.go b/internal/cmdpolicy/yaml/schema.go index 718d2a8bd..65432c085 100644 --- a/internal/cmdpolicy/yaml/schema.go +++ b/internal/cmdpolicy/yaml/schema.go @@ -1,10 +1,10 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -// Package yaml parses a Rule from yaml bytes. It is kept separate from the -// public extension/platform package so that platform stays free of yaml -// library dependencies -- plugins constructing a Rule in Go code never -// import yaml, only the file loader does. +// Package yaml parses one or more Rules from yaml bytes. It is kept +// separate from the public extension/platform package so that platform +// stays free of yaml library dependencies -- plugins constructing a Rule +// in Go code never import yaml, only the file loader does. // // This package does **structural** parsing only (yaml syntax + unknown-field // rejection). Semantic validation (valid MaxRisk enum, valid identity @@ -23,9 +23,9 @@ import ( "github.com/larksuite/cli/extension/platform" ) -// schema is the internal yaml-tagged shape. Mirrors platform.Rule but lives -// here so the public Rule has no yaml tag baggage. -type schema struct { +// ruleSchema is the internal yaml-tagged shape of one rule. Mirrors +// platform.Rule but lives here so the public Rule has no yaml tag baggage. +type ruleSchema struct { Name string `yaml:"name"` Description string `yaml:"description,omitempty"` Allow []string `yaml:"allow,omitempty"` @@ -35,35 +35,45 @@ type schema struct { AllowUnannotated bool `yaml:"allow_unannotated,omitempty"` } -// Parse decodes yaml bytes into a *platform.Rule. Unknown fields are -// rejected so an old binary cannot silently ignore new schema additions -// (forward-compat safeguard). +// fileSchema is the top-level document shape. Two mutually-exclusive +// layouts are accepted: // -// Semantic validation (MaxRisk taxonomy, identity values, glob syntax) is -// the caller's responsibility -- run the result through -// internal/cmdpolicy.ValidateRule before handing it to the engine. -func Parse(data []byte) (*platform.Rule, error) { - var s schema - dec := gopkgyaml.NewDecoder(bytesReader(data)) - dec.KnownFields(true) - if err := dec.Decode(&s); err != nil { - return nil, fmt.Errorf("parse policy yaml: %w", err) - } +// - a single rule written with flat top-level fields (the historical +// layout; the inlined ruleSchema), or +// - a "rules:" list of rule objects (multi-rule layout). +// +// Mixing the two (flat fields AND a rules: list in the same file) is a +// configuration error -- Parse rejects it rather than guessing intent. +// +// Rules is a pointer so Parse can tell "rules: key absent" (nil) apart +// from "rules: present but empty" (non-nil, len 0). The latter is a +// foot-gun -- a config generator that renders an empty list would +// otherwise yield a single all-zero Rule that lets every annotated +// command through -- so Parse rejects it outright. +type fileSchema struct { + ruleSchema `yaml:",inline"` + Rules *[]ruleSchema `yaml:"rules,omitempty"` +} - // Reject multi-document input: yaml.v3 only decodes one document - // per call, so a stray "---" followed by another document would - // silently drop the trailing rule. - var extra schema - if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { - if err == nil { - return nil, fmt.Errorf("parse policy yaml: multiple YAML documents are not allowed") +// isZero reports whether every field is its zero value. Used to detect +// the flat-fields-plus-rules: mixing error. +func (s ruleSchema) isZero() bool { + return s.Name == "" && s.Description == "" && + len(s.Allow) == 0 && len(s.Deny) == 0 && + s.MaxRisk == "" && len(s.Identities) == 0 && !s.AllowUnannotated +} + +func (s ruleSchema) toRule() *platform.Rule { + // Leave Identities nil when absent (omitempty-style), matching how the + // Allow/Deny slices arrive nil from yaml. A zero-length but non-nil + // slice is behaviourally identical to the engine but trips + // reflect.DeepEqual in tests and reads as "explicitly empty". + var idents []platform.Identity + if len(s.Identities) > 0 { + idents = make([]platform.Identity, len(s.Identities)) + for i, id := range s.Identities { + idents[i] = platform.Identity(id) } - return nil, fmt.Errorf("parse policy yaml: %w", err) - } - - idents := make([]platform.Identity, len(s.Identities)) - for i, id := range s.Identities { - idents[i] = platform.Identity(id) } return &platform.Rule{ Name: s.Name, @@ -73,5 +83,53 @@ func Parse(data []byte) (*platform.Rule, error) { MaxRisk: platform.Risk(s.MaxRisk), Identities: idents, AllowUnannotated: s.AllowUnannotated, - }, nil + } +} + +// Parse decodes yaml bytes into one or more *platform.Rule. Unknown fields +// are rejected so an old binary cannot silently ignore new schema additions +// (forward-compat safeguard). +// +// The result always has at least one element: a flat-fields document +// yields a single rule (possibly an all-zero "no restriction" rule), and a +// "rules:" list yields one rule per entry. +// +// Semantic validation (MaxRisk taxonomy, identity values, glob syntax) is +// the caller's responsibility -- run each result through +// internal/cmdpolicy.ValidateRule before handing it to the engine. +func Parse(data []byte) ([]*platform.Rule, error) { + var s fileSchema + dec := gopkgyaml.NewDecoder(bytesReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&s); err != nil { + return nil, fmt.Errorf("parse policy yaml: %w", err) + } + + // Reject multi-document input: yaml.v3 only decodes one document + // per call, so a stray "---" followed by another document would + // silently drop the trailing rule. + var extra fileSchema + if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return nil, fmt.Errorf("parse policy yaml: multiple YAML documents are not allowed") + } + return nil, fmt.Errorf("parse policy yaml: %w", err) + } + + if s.Rules != nil { + if len(*s.Rules) == 0 { + return nil, fmt.Errorf("parse policy yaml: 'rules:' is present but empty; remove the key, or list at least one rule") + } + if !s.ruleSchema.isZero() { + return nil, fmt.Errorf("parse policy yaml: top-level rule fields cannot be combined with a 'rules:' list; move every rule under 'rules:'") + } + out := make([]*platform.Rule, 0, len(*s.Rules)) + for _, rs := range *s.Rules { + out = append(out, rs.toRule()) + } + return out, nil + } + + // Backward-compatible single top-level rule (flat fields). + return []*platform.Rule{s.ruleSchema.toRule()}, nil } diff --git a/internal/cmdpolicy/yaml/schema_test.go b/internal/cmdpolicy/yaml/schema_test.go index 912c8b2a5..4b77e99f8 100644 --- a/internal/cmdpolicy/yaml/schema_test.go +++ b/internal/cmdpolicy/yaml/schema_test.go @@ -24,7 +24,7 @@ max_risk: read identities: - user `) - rule, err := pyaml.Parse(data) + rules, err := pyaml.Parse(data) if err != nil { t.Fatalf("Parse failed: %v", err) } @@ -36,8 +36,59 @@ identities: MaxRisk: "read", Identities: []platform.Identity{"user"}, } - if !reflect.DeepEqual(rule, want) { - t.Fatalf("rule = %+v, want %+v", rule, want) + // A flat top-level rule yields exactly one element (backward compat). + if !reflect.DeepEqual(rules, []*platform.Rule{want}) { + t.Fatalf("rules = %+v, want single %+v", rules, want) + } +} + +// A "rules:" list yields one platform.Rule per entry, in order. This is +// the multi-rule layout: each rule is a scoped grant the engine +// OR-combines. +func TestParse_rulesList(t *testing.T) { + data := []byte(` +rules: + - name: docs-ro + allow: [docs/**] + max_risk: read + - name: im-rw + allow: [im/**] + max_risk: write + identities: [user, bot] +`) + rules, err := pyaml.Parse(data) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + want := []*platform.Rule{ + {Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"}, + {Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write", Identities: []platform.Identity{"user", "bot"}}, + } + if !reflect.DeepEqual(rules, want) { + t.Fatalf("rules = %+v, want %+v", rules, want) + } +} + +// A "rules:" key that is present but empty is a foot-gun: an empty list +// would otherwise fall through to a single all-zero Rule that allows +// every annotated command ("looks like a policy, enforces almost +// nothing"). Parse must reject it outright instead. +func TestParse_rejectsEmptyRulesList(t *testing.T) { + if _, err := pyaml.Parse([]byte("rules: []\n")); err == nil { + t.Fatalf("Parse should reject a present-but-empty 'rules:' list") + } +} + +// Mixing top-level flat rule fields with a rules: list is ambiguous and +// must be rejected rather than silently picking one. +func TestParse_rejectsFlatPlusRulesMix(t *testing.T) { + data := []byte(` +name: top-level +rules: + - name: nested +`) + if _, err := pyaml.Parse(data); err == nil { + t.Fatalf("Parse should reject mixing top-level fields with a rules: list") } } @@ -52,15 +103,15 @@ name: agent-readonly max_risk: read allow_unannotated: true `) - rule, err := pyaml.Parse(data) + rules, err := pyaml.Parse(data) if err != nil { t.Fatalf("Parse failed: %v", err) } - if !rule.AllowUnannotated { + if !rules[0].AllowUnannotated { t.Fatalf("AllowUnannotated = false, want true (yaml field must propagate)") } - if rule.MaxRisk != "read" || rule.Name != "agent-readonly" { - t.Errorf("other fields lost: %+v", rule) + if rules[0].MaxRisk != "read" || rules[0].Name != "agent-readonly" { + t.Errorf("other fields lost: %+v", rules[0]) } } @@ -71,11 +122,11 @@ func TestParse_allowUnannotatedDefaultsFalse(t *testing.T) { name: x max_risk: read `) - rule, err := pyaml.Parse(data) + rules, err := pyaml.Parse(data) if err != nil { t.Fatalf("Parse failed: %v", err) } - if rule.AllowUnannotated { + if rules[0].AllowUnannotated { t.Fatalf("AllowUnannotated must default to false when key is absent") } } @@ -96,12 +147,12 @@ mystery_field: oh no // structural yaml; an invalid max_risk passes through (validation happens // downstream). func TestParse_doesNotValidateSemantics(t *testing.T) { - rule, err := pyaml.Parse([]byte("max_risk: nuclear\n")) + rules, err := pyaml.Parse([]byte("max_risk: nuclear\n")) if err != nil { t.Fatalf("structural parse should succeed, got %v", err) } - if rule.MaxRisk != "nuclear" { - t.Fatalf("MaxRisk = %q, want passed through as-is", rule.MaxRisk) + if rules[0].MaxRisk != "nuclear" { + t.Fatalf("MaxRisk = %q, want passed through as-is", rules[0].MaxRisk) } } diff --git a/internal/cmdutil/confirm.go b/internal/cmdutil/confirm.go index 45031521b..b2c9cf575 100644 --- a/internal/cmdutil/confirm.go +++ b/internal/cmdutil/confirm.go @@ -34,7 +34,7 @@ func RequireConfirmation(action string) error { Message: fmt.Sprintf("%s requires confirmation", action), Hint: "add --yes to confirm", Risk: &output.RiskDetail{ - Level: "high-risk-write", + Level: RiskHighRiskWrite, Action: action, }, }, diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index 5eff1931f..827d7e58d 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -5,7 +5,6 @@ package cmdutil import ( "context" - "fmt" "io" "net/http" "strings" @@ -13,13 +12,13 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/keychain" - "github.com/larksuite/cli/internal/output" ) // Factory holds shared dependencies injected into every command. @@ -129,11 +128,18 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error { } list := strings.Join(supported, ", ") if f.IdentityAutoDetected { - return output.ErrValidation( - "resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s\nhint: use --as %s", - as, list, supported[0]) + base := errs.NewValidationError(errs.SubtypeInvalidArgument, + "resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s", + as, list). + WithParam("--as") + if len(supported) > 0 { + return base.WithHint("use --as %s", supported[0]) + } + return base } - return fmt.Errorf("--as %s is not supported, this command only supports: %s", as, list) + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--as %s is not supported, this command only supports: %s", as, list). + WithParam("--as") } // ResolveStrictMode returns the effective strict mode by reading @@ -161,9 +167,9 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode { func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error { mode := f.ResolveStrictMode(ctx) if mode.IsActive() && !mode.AllowsIdentity(as) { - return output.ErrWithHint(output.ExitValidation, "command_denied", - fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()), - "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()). + WithHint("if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") } return nil } @@ -202,9 +208,9 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient }, nil } -// RequireBuiltinCredentialProvider returns a structured error (exit 2, code -// "external_provider") when an extension provider is actively managing credentials. -// Intended for use as PersistentPreRunE on the auth and config parent commands. +// RequireBuiltinCredentialProvider returns a typed validation error when an +// extension provider is actively managing credentials. Intended for use as +// PersistentPreRunE on the auth and config parent commands. // // Returns nil when: // - f.Credential is nil (test environments without credential setup) @@ -220,10 +226,7 @@ func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command if provName == "" { return nil } - return output.ErrWithHint( - output.ExitValidation, - "external_provider", - fmt.Sprintf("%q is not supported: credentials are provided externally and do not support interactive management", command), - "If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.", - ) + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "%q is not supported: credentials are provided externally and do not support interactive management", command). + WithHint("If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.") } diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index f18a816b3..514aaf93f 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -23,7 +23,7 @@ import ( "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/registry" _ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider - "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/transport" _ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider ) @@ -102,15 +102,15 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error { func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) { return sync.OnceValues(func() (*http.Client, error) { - util.WarnIfProxied(f.IOStreams.ErrOut) + transport.WarnIfProxied(f.IOStreams.ErrOut) - var transport http.RoundTripper = util.SharedTransport() - transport = &RetryTransport{Base: transport} - transport = &SecurityHeaderTransport{Base: transport} - transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor - transport = wrapWithExtension(transport) + var rt http.RoundTripper = transport.Shared() + rt = &RetryTransport{Base: rt} + rt = &SecurityHeaderTransport{Base: rt} + rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor + rt = wrapWithExtension(rt) client := &http.Client{ - Transport: transport, + Transport: rt, Timeout: 30 * time.Second, CheckRedirect: safeRedirectPolicy, } @@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) { lark.WithLogLevel(larkcore.LogLevelError), lark.WithHeaders(BaseSecurityHeaders()), } - util.WarnIfProxied(f.IOStreams.ErrOut) + transport.WarnIfProxied(f.IOStreams.ErrOut) opts = append(opts, lark.WithHttpClient(&http.Client{ Transport: buildSDKTransport(), CheckRedirect: safeRedirectPolicy, @@ -141,7 +141,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) { } func buildSDKTransport() http.RoundTripper { - var sdkTransport http.RoundTripper = util.SharedTransport() + var sdkTransport http.RoundTripper = transport.Shared() sdkTransport = &RetryTransport{Base: sdkTransport} sdkTransport = &UserAgentTransport{Base: sdkTransport} sdkTransport = &BuildHeaderTransport{Base: sdkTransport} diff --git a/internal/cmdutil/factory_test.go b/internal/cmdutil/factory_test.go index dbf343f75..97eed8097 100644 --- a/internal/cmdutil/factory_test.go +++ b/internal/cmdutil/factory_test.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" @@ -179,14 +180,15 @@ func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) { f.IdentityAutoDetected = true err := f.CheckIdentity(core.AsUser, []string{"bot"}) - if err == nil { - t.Fatal("expected error") + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) } - if !strings.Contains(err.Error(), "resolved identity") { - t.Errorf("expected 'resolved identity' in error, got: %v", err) + if !strings.Contains(ve.Message, "resolved identity") { + t.Errorf("expected 'resolved identity' in message, got: %v", ve.Message) } - if !strings.Contains(err.Error(), "hint: use --as bot") { - t.Errorf("expected hint in error, got: %v", err) + if !strings.Contains(ve.Hint, "use --as bot") { + t.Errorf("expected hint to suggest --as bot, got: %v", ve.Hint) } } @@ -422,20 +424,17 @@ func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) { t.Fatal("expected error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("error type = %T, want *output.ExitError", err) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error type = %T, want *errs.ValidationError", err) } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Errorf("exit code = %d, want %d", got, output.ExitValidation) } - if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" { - t.Errorf("error type field = %v, want %q", exitErr.Detail, "external_provider") - } - if exitErr.Detail.Message == "" { + if ve.Message == "" { t.Error("expected non-empty message") } - if exitErr.Detail.Hint == "" { + if ve.Hint == "" { t.Error("expected non-empty hint") } } diff --git a/internal/cmdutil/lang.go b/internal/cmdutil/lang.go new file mode 100644 index 000000000..4a6e514e6 --- /dev/null +++ b/internal/cmdutil/lang.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "strings" + + "github.com/larksuite/cli/internal/i18n" + "github.com/larksuite/cli/internal/output" +) + +// ParseLangFlag validates and canonicalizes a --lang value, shared by config +// and profile so every entry point honors one contract. Empty is unset (no-op); +// a non-empty value must resolve via i18n.Parse or it errors. +func ParseLangFlag(raw string) (i18n.Lang, error) { + if raw == "" { + return "", nil + } + lang, ok := i18n.Parse(raw) + if !ok { + return "", output.ErrValidation( + "invalid --lang %q; valid values: %s", + raw, strings.Join(i18n.Codes(), ", ")) + } + return lang, nil +} diff --git a/internal/cmdutil/risk.go b/internal/cmdutil/risk.go index 112e0ae5f..22fb092c3 100644 --- a/internal/cmdutil/risk.go +++ b/internal/cmdutil/risk.go @@ -7,11 +7,20 @@ import "github.com/spf13/cobra" const riskLevelAnnotationKey = "risk_level" +// Risk level constants — the three-tier convention used across the CLI. +// Use these in place of string literals so the typo radius is one place, +// not every call site. +const ( + RiskRead = "read" + RiskWrite = "write" + RiskHighRiskWrite = "high-risk-write" +) + // SetRisk stores a command's static risk level on cobra annotations so the // help renderer (cmd/root.go) can surface a Risk: line without importing -// shortcuts/common. Levels follow the three-tier convention: "read" | "write" -// | "high-risk-write". Framework-level confirmation gating only acts on -// "high-risk-write". +// shortcuts/common. Levels follow the three-tier convention: RiskRead | +// RiskWrite | RiskHighRiskWrite. Framework-level confirmation gating only +// acts on RiskHighRiskWrite. func SetRisk(cmd *cobra.Command, level string) { if level == "" { return diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go index f93713f4a..9b6fa83ea 100644 --- a/internal/cmdutil/secheader.go +++ b/internal/cmdutil/secheader.go @@ -6,15 +6,18 @@ package cmdutil import ( "context" "net/http" + "os" "reflect" "runtime/debug" "strings" "sync" + "unicode" "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/extension/fileio" exttransport "github.com/larksuite/cli/extension/transport" "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/envvars" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) @@ -24,6 +27,7 @@ const ( HeaderBuild = "X-Cli-Build" HeaderShortcut = "X-Cli-Shortcut" HeaderExecutionId = "X-Cli-Execution-Id" + HeaderAgentTrace = "X-Agent-Trace" SourceValue = "lark-cli" @@ -36,6 +40,8 @@ const ( BuildKindUnknown = "unknown" officialModulePath = "github.com/larksuite/cli" + + agentTraceMaxLen = 1024 ) // UserAgentValue returns the User-Agent value: "lark-cli/{version}". @@ -43,6 +49,25 @@ func UserAgentValue() string { return SourceValue + "/" + build.Version } +// AgentTraceValue returns a header-safe value from the +// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims +// surrounding whitespace, rejects values containing any Unicode +// control character or exceeding agentTraceMaxLen, and returns "" +// for any invalid or empty value. Callers can use the result +// directly in HTTP headers without further sanitisation. +func AgentTraceValue() string { + v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace)) + if v == "" || len(v) > agentTraceMaxLen { + return "" + } + for _, r := range v { + if unicode.IsControl(r) { + return "" + } + } + return v +} + // BaseSecurityHeaders returns headers that every request must carry. func BaseSecurityHeaders() http.Header { h := make(http.Header) @@ -50,6 +75,9 @@ func BaseSecurityHeaders() http.Header { h.Set(HeaderVersion, build.Version) h.Set(HeaderBuild, DetectBuildKind()) h.Set(HeaderUserAgent, UserAgentValue()) + if v := AgentTraceValue(); v != "" { + h.Set(HeaderAgentTrace, v) + } return h } diff --git a/internal/cmdutil/secheader_test.go b/internal/cmdutil/secheader_test.go index 54c61f1d4..6b116f458 100644 --- a/internal/cmdutil/secheader_test.go +++ b/internal/cmdutil/secheader_test.go @@ -6,10 +6,12 @@ package cmdutil import ( "context" "net/http" + "strings" "testing" "github.com/larksuite/cli/extension/credential" envcred "github.com/larksuite/cli/extension/credential/env" + "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/internal/vfs/localfileio" ) @@ -260,3 +262,134 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) { } } } + +// --------------------------------------------------------------------------- +// AgentTraceValue / HeaderAgentTrace +// --------------------------------------------------------------------------- + +func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, "") + if got := AgentTraceValue(); got != "" { + t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got) + } +} + +func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, "trace-abc-123") + if got := AgentTraceValue(); got != "trace-abc-123" { + t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123") + } +} + +func TestAgentTraceValue_TrimsWhitespace(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, " trace-trim ") + if got := AgentTraceValue(); got != "trace-trim" { + t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim") + } +} + +func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, " ") + if got := AgentTraceValue(); got != "" { + t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got) + } +} + +func TestAgentTraceValue_RejectsCRLF(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack") + if got := AgentTraceValue(); got != "" { + t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got) + } +} + +func TestAgentTraceValue_RejectsLF(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack") + if got := AgentTraceValue(); got != "" { + t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got) + } +} + +func TestAgentTraceValue_RejectsTab(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, "val\tinjected") + if got := AgentTraceValue(); got != "" { + t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got) + } +} + +func TestAgentTraceValue_RejectsControlChar(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, "val\x01injected") + if got := AgentTraceValue(); got != "" { + t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got) + } +} + +func TestAgentTraceValue_RejectsDEL(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, "val\x7finjected") + if got := AgentTraceValue(); got != "" { + t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got) + } +} + +func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) { + longVal := strings.Repeat("a", agentTraceMaxLen+1) + t.Setenv(envvars.CliAgentTrace, longVal) + if got := AgentTraceValue(); got != "" { + t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen) + } +} + +func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) { + val := strings.Repeat("a", agentTraceMaxLen) + t.Setenv(envvars.CliAgentTrace, val) + if got := AgentTraceValue(); got != val { + t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen) + } +} + +func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, "") + h := BaseSecurityHeaders() + if v := h.Get(HeaderAgentTrace); v != "" { + t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentTrace, v) + } +} + +func TestBaseSecurityHeaders_IncludesAgentTraceHeaderWhenEnvSet(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, "trace-xyz-789") + h := BaseSecurityHeaders() + if v := h.Get(HeaderAgentTrace); v != "trace-xyz-789" { + t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentTrace, v, "trace-xyz-789") + } +} + +func TestBaseSecurityHeaders_AgentTraceTrimmedWhitespace(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, " trace-trim ") + h := BaseSecurityHeaders() + if v := h.Get(HeaderAgentTrace); v != "trace-trim" { + t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q (whitespace trimmed)", HeaderAgentTrace, v, "trace-trim") + } +} + +func TestBaseSecurityHeaders_AgentTraceOnlyWhitespace_Skipped(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, " ") + h := BaseSecurityHeaders() + if v := h.Get(HeaderAgentTrace); v != "" { + t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for whitespace-only value", HeaderAgentTrace, v) + } +} + +func TestBaseSecurityHeaders_AgentTraceRejectsCRLFInjection(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack") + h := BaseSecurityHeaders() + if v := h.Get(HeaderAgentTrace); v != "" { + t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for CR/LF value", HeaderAgentTrace, v) + } +} + +func TestBaseSecurityHeaders_AgentTraceRejectsLFInjection(t *testing.T) { + t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack") + h := BaseSecurityHeaders() + if v := h.Get(HeaderAgentTrace); v != "" { + t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for LF value", HeaderAgentTrace, v) + } +} diff --git a/internal/cmdutil/transport.go b/internal/cmdutil/transport.go index ccb661194..351eeb853 100644 --- a/internal/cmdutil/transport.go +++ b/internal/cmdutil/transport.go @@ -9,7 +9,7 @@ import ( "time" exttransport "github.com/larksuite/cli/extension/transport" - "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/transport" ) // RetryTransport is an http.RoundTripper that retries on 5xx responses @@ -24,7 +24,7 @@ func (t *RetryTransport) base() http.RoundTripper { if t.Base != nil { return t.Base } - return util.FallbackTransport() + return transport.Fallback() } func (t *RetryTransport) delay() time.Duration { @@ -69,7 +69,7 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error if t.Base != nil { return t.Base.RoundTrip(req) } - return util.FallbackTransport().RoundTrip(req) + return transport.Fallback().RoundTrip(req) } // BuildHeaderTransport is an http.RoundTripper that force-writes the @@ -87,7 +87,7 @@ func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, err if t.Base != nil { return t.Base.RoundTrip(req) } - return util.FallbackTransport().RoundTrip(req) + return transport.Fallback().RoundTrip(req) } // SecurityHeaderTransport is an http.RoundTripper that injects CLI security @@ -100,7 +100,7 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper { if t.Base != nil { return t.Base } - return util.FallbackTransport() + return transport.Fallback() } // RoundTrip implements http.RoundTripper. diff --git a/internal/cmdutil/transport_test.go b/internal/cmdutil/transport_test.go index f76b0c326..9cd061021 100644 --- a/internal/cmdutil/transport_test.go +++ b/internal/cmdutil/transport_test.go @@ -332,7 +332,7 @@ func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) { // TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil, // the transport still sets X-Cli-Build and routes the request through -// util.FallbackTransport rather than panicking. This covers the fallback +// transport.Fallback rather than panicking. This covers the fallback // branch in RoundTrip that is otherwise unreachable with a non-nil Base. func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) { var receivedBuild string diff --git a/internal/core/config.go b/internal/core/config.go index 9c566ce50..040b59b38 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -11,6 +11,7 @@ import ( "strings" "unicode/utf8" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" @@ -41,7 +42,7 @@ type AppConfig struct { AppId string `json:"appId"` AppSecret SecretInput `json:"appSecret"` Brand LarkBrand `json:"brand"` - Lang string `json:"lang,omitempty"` + Lang i18n.Lang `json:"lang,omitempty"` DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto StrictMode *StrictMode `json:"strictMode,omitempty"` Users []AppUser `json:"users"` @@ -159,6 +160,7 @@ type CliConfig struct { DefaultAs Identity // AsUser | AsBot | AsAuto | "" (from config file) UserOpenId string UserName string + Lang i18n.Lang SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider } @@ -264,6 +266,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro AppSecret: secret, Brand: app.Brand, DefaultAs: app.DefaultAs, + Lang: app.Lang, } if len(app.Users) > 0 { cfg.UserOpenId = app.Users[0].UserOpenId diff --git a/internal/credential/default_provider.go b/internal/credential/default_provider.go index 45ec3ef00..9482b2845 100644 --- a/internal/credential/default_provider.go +++ b/internal/credential/default_provider.go @@ -4,21 +4,50 @@ package credential import ( - "bytes" "context" - "encoding/json" "fmt" "io" "net/http" "sync" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/keychain" extcred "github.com/larksuite/cli/extension/credential" ) +// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the +// canonical typed error. The TAT mint endpoint reports invalid credentials +// with two distinct codes: +// +// - 10003: bad app_id format or non-existent app_id ("invalid param") +// - 10014: invalid app_secret ("app secret invalid") +// +// Both surface as CategoryConfig/InvalidClient from the user's perspective — +// the configured credentials cannot mint a tenant access token. 10014 is +// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543). +// 10003 is NOT globally mapped because in other Lark endpoints it carries +// unrelated semantics (e.g. task API uses 10003 for permission denied), so +// the override stays local to this TAT call site instead of leaking into the +// shared codemeta table. +func classifyTATResponseCode(code int, msg, brand, appID string) error { + if code == 10003 { + return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg). + WithCode(code). + WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient)) + } + return errclass.BuildAPIError(map[string]any{ + "code": code, + "msg": msg, + }, errclass.ClassifyContext{ + Brand: brand, + AppID: appID, + }) +} + // DefaultAccountProvider resolves account from config.json via keychain. type DefaultAccountProvider struct { keychain func() keychain.KeychainAccess @@ -135,42 +164,9 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, if err != nil { return nil, err } - ep := core.ResolveEndpoints(acct.Brand) - url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal" - - body, err := json.Marshal(map[string]string{ - "app_id": acct.AppID, - "app_secret": acct.AppSecret, - }) - if err != nil { - return nil, fmt.Errorf("failed to marshal TAT request: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret) if err != nil { return nil, err } - req.Header.Set("Content-Type", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode) - } - - var result struct { - Code int `json:"code"` - Msg string `json:"msg"` - TenantAccessToken string `json:"tenant_access_token"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to parse TAT response: %w", err) - } - if result.Code != 0 { - return nil, fmt.Errorf("TAT API error: [%d] %s", result.Code, result.Msg) - } - return &TokenResult{Token: result.TenantAccessToken}, nil + return &TokenResult{Token: token}, nil } diff --git a/internal/credential/default_provider_test.go b/internal/credential/default_provider_test.go index 5aa258499..46e21f066 100644 --- a/internal/credential/default_provider_test.go +++ b/internal/credential/default_provider_test.go @@ -4,7 +4,10 @@ package credential import ( + "errors" "testing" + + "github.com/larksuite/cli/errs" ) func TestDefaultTokenProvider_Dispatches(t *testing.T) { @@ -15,3 +18,68 @@ func TestDefaultTokenProvider_Dispatches(t *testing.T) { func TestDefaultAccountProvider_Implements(t *testing.T) { var _ DefaultAccountResolver = &DefaultAccountProvider{} } + +// TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT +// endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient. +// Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint, +// which from the user's perspective is the same actionable failure as 10014 +// ("app secret invalid") — both mean the configured credentials cannot mint a +// tenant access token. The global codemeta intentionally does not map 10003 +// because in other Lark endpoints 10003 carries unrelated semantics (e.g. task +// API uses it for permission denied), so the override is local to this site. +func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) { + err := classifyTATResponseCode(10003, "invalid param", "feishu", "cli_app_x") + if err == nil { + t.Fatal("expected non-nil error for code=10003") + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err) + } + if cfgErr.Category != errs.CategoryConfig { + t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig) + } + if cfgErr.Subtype != errs.SubtypeInvalidClient { + t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) + } + if cfgErr.Code != 10003 { + t.Errorf("Code = %d, want 10003", cfgErr.Code) + } + if cfgErr.Hint == "" { + t.Error("Hint must be non-empty so the user gets a recovery action") + } +} + +// TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still +// goes through the global BuildAPIError path (codemeta entry) so the override +// for 10003 does not regress the existing mapping. +func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) { + err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x") + if err == nil { + t.Fatal("expected non-nil error for code=10014") + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err) + } + if cfgErr.Subtype != errs.SubtypeInvalidClient { + t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) + } + if cfgErr.Code != 10014 { + t.Errorf("Code = %d, want 10014", cfgErr.Code) + } +} + +// TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside +// the credential set fall through to the generic BuildAPIError fallback +// (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional. +func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) { + err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x") + if err == nil { + t.Fatal("expected non-nil error for unmapped code") + } + var cfgErr *errs.ConfigError + if errors.As(err, &cfgErr) { + t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err) + } +} diff --git a/internal/credential/integration_test.go b/internal/credential/integration_test.go index 7daef1d6a..de173d194 100644 --- a/internal/credential/integration_test.go +++ b/internal/credential/integration_test.go @@ -12,6 +12,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" ) @@ -115,3 +116,45 @@ func TestFullChain_ConfigStrictMode(t *testing.T) { t.Errorf("expected SupportsBot (%d), got %d", extcred.SupportsBot, acct.SupportedIdentities) } } + +// TestFullChain_LangSurvivesProductionPath exercises the exact data flow the +// production Factory uses (factory_default.go Phase 3): disk → multi config → +// DefaultAccountProvider.ResolveAccount → Account → ToCliConfig. If Lang gets +// dropped at the credential boundary (as it would when Account lacks the field), +// shortcuts/common/runner.go RuntimeContext.Lang() returns "" and downstream +// consumers (mail signature, etc.) silently fall back to defaults — defeating +// the whole point of persisting --lang. +func TestFullChain_LangSurvivesProductionPath(t *testing.T) { + t.Setenv(envvars.CliAppID, "") + t.Setenv(envvars.CliAppSecret, "") + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + multi := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: "cfg_app", + AppSecret: core.PlainSecret("cfg_secret"), + Brand: core.BrandFeishu, + Lang: i18n.LangJaJP, + }}, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig: %v", err) + } + + defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "") + acct, err := defaultAcct.ResolveAccount(context.Background()) + if err != nil { + t.Fatalf("ResolveAccount: %v", err) + } + if acct.Lang != i18n.LangJaJP { + t.Errorf("Account.Lang = %q, want %q (DefaultAccountProvider must propagate Lang from config)", acct.Lang, i18n.LangJaJP) + } + + cfg := acct.ToCliConfig() + if cfg == nil { + t.Fatal("ToCliConfig() = nil") + } + if cfg.Lang != i18n.LangJaJP { + t.Errorf("CliConfig.Lang = %q, want %q (this is the value RuntimeContext.Lang() reads in production)", cfg.Lang, i18n.LangJaJP) + } +} diff --git a/internal/credential/tat_fetch.go b/internal/credential/tat_fetch.go new file mode 100644 index 000000000..eecaed20e --- /dev/null +++ b/internal/credential/tat_fetch.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package credential + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/larksuite/cli/internal/core" +) + +// FetchTAT performs a single HTTP POST to mint a tenant access token with the +// given credentials. It does not read configuration or keychain, so callers +// that already hold plaintext credentials (e.g. the post-`config init` probe) +// can validate them without a second keychain round-trip. +// +// A non-zero TAT response code means the server inspected the payload and +// rejected the credentials; FetchTAT returns the canonical typed error from +// classifyTATResponseCode — the SAME classification doResolveTAT (and thus +// every token-resolving command) produces, so callers see one consistent +// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.). +// Transport, HTTP-status and JSON-parse failures are returned raw (untyped), +// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic +// credential rejection apart from upstream/transport noise. +// +// The caller owns the context timeout. +func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) { + ep := core.ResolveEndpoints(brand) + url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal" + + body, err := json.Marshal(map[string]string{ + "app_id": appID, + "app_secret": appSecret, + }) + if err != nil { + return "", fmt.Errorf("failed to marshal TAT request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode) + } + + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + TenantAccessToken string `json:"tenant_access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to parse TAT response: %w", err) + } + if result.Code != 0 { + return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID) + } + return result.TenantAccessToken, nil +} diff --git a/internal/credential/tat_fetch_test.go b/internal/credential/tat_fetch_test.go new file mode 100644 index 000000000..686d98cc9 --- /dev/null +++ b/internal/credential/tat_fetch_test.go @@ -0,0 +1,237 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package credential + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" +) + +// stubRoundTripper lets us assert request shape and return canned responses. +type stubRoundTripper struct { + gotReq *http.Request + gotBody string + respCode int + respBody string + err error +} + +func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + s.gotReq = req + if req.Body != nil { + b, _ := io.ReadAll(req.Body) + s.gotBody = string(b) + } + if s.err != nil { + return nil, s.err + } + return &http.Response{ + StatusCode: s.respCode, + Body: io.NopCloser(strings.NewReader(s.respBody)), + Header: make(http.Header), + }, nil +} + +func TestFetchTAT_Success(t *testing.T) { + rt := &stubRoundTripper{ + respCode: 200, + respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`, + } + hc := &http.Client{Transport: rt} + + token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "t-abc" { + t.Errorf("token = %q, want t-abc", token) + } + if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" { + t.Errorf("url = %s", rt.gotReq.URL.String()) + } + if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) { + t.Errorf("request body missing credentials: %s", rt.gotBody) + } +} + +// 10003 (bad / non-existent app_id, "invalid param") is classified locally by +// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same +// typed error doResolveTAT (and thus every token-resolving command) returns. +func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) { + rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`} + hc := &http.Client{Transport: rt} + + token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err == nil { + t.Fatal("expected error for code 10003") + } + if token != "" { + t.Errorf("token = %q, want empty", token) + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("error not *errs.ConfigError: %T %v", err, err) + } + if cfgErr.Category != errs.CategoryConfig { + t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig) + } + if cfgErr.Subtype != errs.SubtypeInvalidClient { + t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) + } + if cfgErr.Code != 10003 { + t.Errorf("Code = %d, want 10003", cfgErr.Code) + } +} + +// 10014 ("app secret invalid") — the most common real-world rejection (real +// app_id + wrong secret) — is globally mapped in codemeta to +// CategoryConfig / SubtypeInvalidClient via BuildAPIError. +func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) { + rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`} + hc := &http.Client{Transport: rt} + + _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("error not *errs.ConfigError: %T %v", err, err) + } + if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 { + t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code) + } +} + +// Any non-zero body code is a deterministic server-side rejection, so it +// always yields a typed error (errs.IsTyped). An unrecognized code falls back +// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe +// caller still surfaces it rather than silently swallowing. +func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) { + rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`} + hc := &http.Client{Transport: rt} + + _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err == nil { + t.Fatal("expected error for code 99999") + } + if !errs.IsTyped(err) { + t.Fatalf("expected a typed errs.* error, got %T %v", err, err) + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Errorf("unknown code should fall back to *errs.APIError, got %T", err) + } +} + +// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it +// must stay UNTYPED so a probe caller treats it as upstream noise and stays +// silent. +func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) { + for _, code := range []int{401, 403, 500, 503} { + rt := &stubRoundTripper{respCode: code, respBody: `whatever`} + hc := &http.Client{Transport: rt} + _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err == nil { + t.Fatalf("HTTP %d: expected error", code) + } + if errs.IsTyped(err) { + t.Errorf("HTTP %d: must be UNTYPED (ambiguous), got typed %T %v", code, err, err) + } + } +} + +func TestFetchTAT_TransportError_Untyped(t *testing.T) { + sentinel := errors.New("network down") + rt := &stubRoundTripper{err: sentinel} + hc := &http.Client{Transport: rt} + + _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err == nil { + t.Fatal("expected error") + } + if errs.IsTyped(err) { + t.Errorf("transport error must be UNTYPED, got typed %T", err) + } + if !errors.Is(err, sentinel) { + t.Errorf("error chain missing sentinel: %v", err) + } +} + +func TestFetchTAT_ParseError_Untyped(t *testing.T) { + rt := &stubRoundTripper{respCode: 200, respBody: `not json`} + hc := &http.Client{Transport: rt} + + _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err == nil { + t.Fatal("expected parse error") + } + if errs.IsTyped(err) { + t.Errorf("parse error must be UNTYPED, got typed %T", err) + } +} + +func TestFetchTAT_BrandRouting(t *testing.T) { + tests := []struct { + brand core.LarkBrand + wantURL string + }{ + {core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"}, + {core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"}, + } + for _, tc := range tests { + t.Run(string(tc.brand), func(t *testing.T) { + rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`} + hc := &http.Client{Transport: rt} + if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil { + t.Fatal(err) + } + if got := rt.gotReq.URL.String(); got != tc.wantURL { + t.Errorf("url = %s, want %s", got, tc.wantURL) + } + }) + } +} + +func TestFetchTAT_ContextCanceled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer srv.Close() + + rt := &urlRewriteRT{base: srv.URL} + hc := &http.Client{Transport: rt} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // pre-canceled + + _, err := FetchTAT(ctx, hc, core.BrandFeishu, "a", "b") + if err == nil { + t.Fatal("expected error for canceled context") + } + if errs.IsTyped(err) { + t.Errorf("canceled context must be UNTYPED, got typed %T", err) + } + if !errors.Is(err, context.Canceled) { + t.Errorf("error chain missing context.Canceled: %v", err) + } +} + +// urlRewriteRT forwards requests to a fixed base URL (test server). +type urlRewriteRT struct{ base string } + +func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) { + newURL := r.base + req.URL.Path + req2, err := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body) + if err != nil { + return nil, err + } + req2.Header = req.Header + return http.DefaultTransport.RoundTrip(req2) +} diff --git a/internal/credential/types.go b/internal/credential/types.go index 54620621d..98af4d475 100644 --- a/internal/credential/types.go +++ b/internal/credential/types.go @@ -10,6 +10,7 @@ import ( extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" ) // Account is the credential-layer view of the active runtime account. @@ -23,6 +24,7 @@ type Account struct { DefaultAs core.Identity UserOpenId string UserName string + Lang i18n.Lang SupportedIdentities uint8 } @@ -65,6 +67,7 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account { DefaultAs: cfg.DefaultAs, UserOpenId: cfg.UserOpenId, UserName: cfg.UserName, + Lang: cfg.Lang, SupportedIdentities: cfg.SupportedIdentities, } } @@ -82,6 +85,7 @@ func (a *Account) ToCliConfig() *core.CliConfig { DefaultAs: a.DefaultAs, UserOpenId: a.UserOpenId, UserName: a.UserName, + Lang: a.Lang, SupportedIdentities: a.SupportedIdentities, } } diff --git a/internal/credential/types_test.go b/internal/credential/types_test.go index b4feec7df..929d74fdb 100644 --- a/internal/credential/types_test.go +++ b/internal/credential/types_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" ) func TestTokenTypeString(t *testing.T) { @@ -53,6 +54,7 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) { DefaultAs: "user", UserOpenId: "ou_123", UserName: "alice", + Lang: i18n.LangJaJP, SupportedIdentities: 3, } @@ -63,6 +65,9 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) { if acct.AppID != cfg.AppID || acct.ProfileName != cfg.ProfileName || acct.UserName != cfg.UserName { t.Fatalf("AccountFromCliConfig() = %#v, want copied fields from %#v", acct, cfg) } + if acct.Lang != cfg.Lang { + t.Fatalf("AccountFromCliConfig().Lang = %q, want %q", acct.Lang, cfg.Lang) + } roundtrip := acct.ToCliConfig() if roundtrip == nil { @@ -71,6 +76,9 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) { if roundtrip.AppID != cfg.AppID || roundtrip.ProfileName != cfg.ProfileName || roundtrip.UserName != cfg.UserName { t.Fatalf("ToCliConfig() = %#v, want copied fields from %#v", roundtrip, cfg) } + if roundtrip.Lang != cfg.Lang { + t.Fatalf("ToCliConfig().Lang = %q, want %q (production Factory path reads Lang via this conversion)", roundtrip.Lang, cfg.Lang) + } roundtrip.AppID = "mutated-cli" acct.AppID = "mutated-account" diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 41560ec9d..7b4a23464 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -18,4 +18,10 @@ const ( // Content safety scanning mode CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE" + + CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE" + + CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" + CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS" + CliCAPath = "LARKSUITE_CLI_CA_PATH" ) diff --git a/internal/errclass/classify.go b/internal/errclass/classify.go index a40e92006..a0176b3ec 100644 --- a/internal/errclass/classify.go +++ b/internal/errclass/classify.go @@ -20,6 +20,7 @@ type ClassifyContext struct { Brand string // "feishu" | "lark" — drives console_url host AppID string // placed in console_url Identity string // "user" / "bot" / "" — caller converts core.Identity at the boundary + LarkCmd string // e.g. "drive +delete" — used as Action fallback on CategoryConfirmation arm } // BuildAPIError consumes a parsed Lark API response and returns a typed error. @@ -35,7 +36,7 @@ type ClassifyContext struct { // Network → *errs.NetworkError // Internal → *errs.InternalError // Confirmation → *errs.ConfirmationRequiredError -// default (CategoryAPI) → *errs.APIError (Detail preserves raw response) +// default (CategoryAPI) → *errs.APIError (catch-all for classified Lark business errors) // // Unknown Lark codes (LookupCodeMeta returns false) fall back to // CategoryAPI + SubtypeUnknown. @@ -80,6 +81,17 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error { LogID: logID, Retryable: meta.Retryable, } + // Upstream-provided diagnostic URL (resp.error.troubleshooter). Lifted + // universally before the category switch so every classified typed + // error surfaces it when present. The remaining contents of resp["error"] + // (permission_violations.subject, data.challenge_url, data.hint) are + // either lifted into category-specific typed extension fields below or + // intentionally dropped as redundant with the typed envelope. + if errBlock, ok := resp["error"].(map[string]any); ok { + if ts, _ := errBlock["troubleshooter"].(string); ts != "" { + base.Troubleshooter = ts + } + } switch meta.Category { case errs.CategoryAuthorization: @@ -87,7 +99,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error { case errs.CategoryAuthentication: return &errs.AuthenticationError{Problem: base} case errs.CategoryConfig: - return &errs.ConfigError{Problem: base} + return buildConfigError(base) case errs.CategoryPolicy: return buildSecurityPolicyError(base, resp) case errs.CategoryValidation: @@ -97,9 +109,40 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error { case errs.CategoryInternal: return &errs.InternalError{Problem: base} case errs.CategoryConfirmation: - return &errs.ConfirmationRequiredError{Problem: base} + // Risk + Action are non-omitempty wire fields. Derive from + // CodeMeta when available; otherwise emit RiskUnknown + + // ctx.LarkCmd placeholder so the envelope is never wire-invalid. + risk := meta.Risk + if risk == "" { + risk = errs.RiskUnknown + } + action := meta.Action + if action == "" { + action = cc.LarkCmd + } + if action == "" { + action = "unknown" + } + return &errs.ConfirmationRequiredError{ + Problem: base, + Risk: risk, + Action: action, + } + case errs.CategoryAPI: + base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default + return &errs.APIError{Problem: base} default: - return &errs.APIError{Problem: base, Detail: resp} + // Fail closed: an unrecognized Category routes to InternalError + // instead of emitting an empty Problem on the wire. + return &errs.InternalError{ + Problem: errs.Problem{ + Category: errs.CategoryInternal, + Subtype: errs.SubtypeSDKError, + Code: base.Code, + Message: fmt.Sprintf("unrecognized Category %q for code %d", base.Category, base.Code), + LogID: base.LogID, + }, + } } } @@ -149,7 +192,7 @@ func buildSecurityPolicyError(p errs.Problem, resp map[string]any) *errs.Securit // isHTTPSURL is the local-to-errclass duplicate of internal/auth/transport.go's // isValidChallengeURL. Kept local to avoid coupling errclass to internal/auth; -// the two will collapse when the auth transport adopts BuildAPIError in stage 4. +// the two collapse once the auth transport adopts BuildAPIError directly. func isHTTPSURL(rawURL string) bool { if rawURL == "" { return false @@ -167,47 +210,158 @@ func stringFromAny(v any) string { return s } +// buildConfigError enriches a typed ConfigError with the canonical +// per-subtype recovery hint before returning it, so the wire envelope +// emitted via BuildAPIError always carries a hint for known config subtypes. +func buildConfigError(p errs.Problem) *errs.ConfigError { + p.Hint = ConfigHint(p.Subtype) + return &errs.ConfigError{Problem: p} +} + +// ConfigHint returns the canonical per-subtype recovery hint for a typed +// ConfigError emitted via BuildAPIError. +func ConfigHint(subtype errs.Subtype) string { + switch subtype { + case errs.SubtypeInvalidClient: + return "run `lark-cli config init` to set valid app_id and app_secret" + case errs.SubtypeNotConfigured: + return "run `lark-cli config init` to set up app_id and app_secret" + case errs.SubtypeInvalidConfig: + return "check the config file for syntax errors; rerun `lark-cli config init` to reset" + } + return "" +} + +// APIHint returns the canonical per-subtype recovery hint for a typed APIError +// emitted via BuildAPIError, for API subtypes whose recovery is context-free. +// Context-specific guidance (e.g. a command's flags, an API's own quota) is +// layered on by the caller after BuildAPIError returns and overrides this. +func APIHint(subtype errs.Subtype) string { + switch subtype { + case errs.SubtypeConflict: + return "retry later and avoid concurrent duplicate requests on the same resource" + case errs.SubtypeCrossTenant: + return "operate on source and target within the same tenant and region/unit" + case errs.SubtypeCrossBrand: + return "operate on source and target within the same brand environment" + } + return "" +} + func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError { missing := extractMissingScopes(resp) identity := cc.Identity if identity == "" { identity = "user" } - p.Hint = PermissionHint(missing, identity, p.Subtype) - return &errs.PermissionError{ + consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing) + p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message) + p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL) + permErr := &errs.PermissionError{ Problem: p, MissingScopes: missing, Identity: identity, - ConsoleURL: ConsoleURL(cc.Brand, cc.AppID, missing), } + // ConsoleURL is the developer-console deep-link an app developer follows to + // apply for a missing scope. That action only resolves SubtypeAppScopeNotApplied, + // which is bot-perspective. The other authorization subtypes route to a + // different actor: SubtypeMissingScope / SubtypeTokenScopeInsufficient / + // SubtypeUserUnauthorized recover via `lark-cli auth login`; SubtypeAppUnavailable + // / SubtypeAppDisabled require tenant admin. Carrying ConsoleURL on those + // envelopes is dead weight and risks pointing an end user at a console they + // cannot modify; the URL is still computed so the hint composer can use it + // where appropriate. + if p.Subtype == errs.SubtypeAppScopeNotApplied { + permErr.ConsoleURL = consoleURL + } + return permErr } -// PermissionHint returns an actionable next-step string for a permission -// error. User identity with a missing user-scope is recovered by re-running -// `auth login --scope ...`; bot identity or app-level scope errors are -// recovered by enabling scopes in the open-platform console. The subtype -// argument distinguishes app-level failures (e.g. SubtypeAppScopeNotApplied) -// where re-authentication will not help regardless of the caller identity. +// CanonicalPermissionMessage returns the CLI-side canonical wording for a +// typed PermissionError, preserving the Lark official-API phrasing +// ("access denied" / "unauthorized" / "token has no permission") and +// enhancing it with CLI context (app ID, missing scope list). Subtypes +// outside the known set fall through to fallback so the upstream message +// is preserved. +func CanonicalPermissionMessage(subtype errs.Subtype, appID string, missing []string, fallback string) string { + switch subtype { + case errs.SubtypeAppScopeNotApplied: + if len(missing) > 0 { + scopes := strings.Join(missing, ", ") + if appID != "" { + return fmt.Sprintf("access denied: app %s has not applied for the required scope(s): %s", appID, scopes) + } + return fmt.Sprintf("access denied: app has not applied for the required scope(s): %s", scopes) + } + if appID != "" { + return fmt.Sprintf("access denied: app %s has not applied for the required scope(s)", appID) + } + return "access denied: app has not applied for the required scope(s)" + case errs.SubtypeMissingScope: + if len(missing) > 0 { + return fmt.Sprintf("unauthorized: user authorization does not cover the required scope(s): %s", strings.Join(missing, ", ")) + } + return "unauthorized: user authorization does not cover the required scope" + case errs.SubtypeTokenScopeInsufficient: + return "token has no permission for this operation; required scope is missing" + case errs.SubtypeUserUnauthorized: + return "access denied for this operation; possible causes: missing scope, missing user authorization, or restricted by tenant policy" + case errs.SubtypeAppUnavailable: + if appID != "" { + return fmt.Sprintf("unauthorized app: app %s is not properly installed in this tenant", appID) + } + return "unauthorized app: app is not properly installed in this tenant" + case errs.SubtypeAppDisabled: + if appID != "" { + return fmt.Sprintf("app %s is not in use in this tenant (currently disabled)", appID) + } + return "app is not in use in this tenant (currently disabled)" + case errs.SubtypePermissionDenied: + return "user lacks permission for the requested resource" + } + return fallback +} + +// PermissionHint returns the canonical per-subtype recovery hint for a typed +// PermissionError. The hint distinguishes authorization subtypes routing +// to different recovery paths: developer console for app_scope_not_applied, +// user re-login for missing_scope / token_scope_insufficient / user_unauthorized, +// and tenant admin for app_unavailable / app_disabled. The subtype +// argument is the primary discriminator; identity is retained for the +// generic permission_denied fallback so callers that do not yet route on +// subtype still get a sensible hint. // // Exported so direct construction sites (cmd/service/service.go's // checkServiceScopes) can produce hints that match the dispatcher path // byte-for-byte instead of hand-rolling divergent strings. -func PermissionHint(missing []string, identity string, subtype errs.Subtype) string { - // app_scope_not_enabled means the scope has not been granted at the - // app (developer console) level — re-authenticating cannot fix it, - // so route every caller identity to the console hint. - useConsole := identity == "bot" || subtype == errs.SubtypeAppScopeNotApplied - if len(missing) == 0 { - if useConsole { - return "check the app's scope grant in the Lark open platform console" +func PermissionHint(missing []string, identity string, subtype errs.Subtype, consoleURL string) string { + switch subtype { + case errs.SubtypeAppScopeNotApplied: + if consoleURL != "" { + return fmt.Sprintf("the app developer must apply for the required scope(s) at the developer console: %s", consoleURL) } - return "ensure the calling identity has been granted the required scopes" + return "the app developer must apply for the required scope(s) at the developer console" + case errs.SubtypeMissingScope: + if len(missing) > 0 { + return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authorize the user with the updated scope set", strings.Join(missing, " ")) + } + return "run `lark-cli auth login` to re-authorize the user with the updated scope set" + case errs.SubtypeTokenScopeInsufficient: + return "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued" + case errs.SubtypeUserUnauthorized: + return "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy" + case errs.SubtypeAppUnavailable: + return "ask the tenant admin to check the app's install status in the Lark admin console" + case errs.SubtypeAppDisabled: + return "ask the tenant admin to re-enable the app in the Lark admin console" + case errs.SubtypePermissionDenied: + who := "this user" + if identity == "bot" { + who = "this bot" + } + return fmt.Sprintf("check the resource owner has granted access to %s", who) } - scopes := strings.Join(missing, " ") - if useConsole { - return fmt.Sprintf("the app is missing required scope(s): %s. Open the app's open platform console and add them.", scopes) - } - return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authenticate with the missing scope(s)", scopes) + return "check the calling identity has the required scope" } // extractMissingScopes walks resp["error"]["permission_violations"][].subject. diff --git a/internal/errclass/classify_internal_test.go b/internal/errclass/classify_internal_test.go new file mode 100644 index 000000000..83714d03f --- /dev/null +++ b/internal/errclass/classify_internal_test.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errclass + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" +) + +// TestBuildAPIError_CategoryConfirmationFillsRiskAction pins fail-closed +// behaviour: a code mapped to CategoryConfirmation MUST yield a +// ConfirmationRequiredError whose Risk + Action are non-empty even when the +// CodeMeta itself carries no Risk/Action hints. Risk falls back to +// RiskUnknown; Action falls back to ctx.LarkCmd. +func TestBuildAPIError_CategoryConfirmationFillsRiskAction(t *testing.T) { + const stubCode = 99999991 + codeMeta[stubCode] = CodeMeta{ + Category: errs.CategoryConfirmation, + Subtype: errs.SubtypeConfirmationRequired, + } + t.Cleanup(func() { delete(codeMeta, stubCode) }) + + resp := map[string]any{"code": stubCode, "msg": "confirmation required"} + ctx := ClassifyContext{ + Brand: "feishu", + AppID: "cli_test", + Identity: "user", + LarkCmd: "drive +delete", + } + err := BuildAPIError(resp, ctx) + var confirmErr *errs.ConfirmationRequiredError + if !errors.As(err, &confirmErr) { + t.Fatalf("expected *ConfirmationRequiredError, got %T: %v", err, err) + } + if confirmErr.Risk == "" { + t.Error("Risk empty; arm must fail-closed with RiskUnknown") + } + if confirmErr.Risk != errs.RiskUnknown { + t.Errorf("Risk = %q, want %q (CodeMeta carried no Risk hint)", + confirmErr.Risk, errs.RiskUnknown) + } + if confirmErr.Action == "" { + t.Error("Action empty; arm must fail-closed with command name from ClassifyContext") + } + if confirmErr.Action != "drive +delete" { + t.Errorf("Action = %q, want %q (ctx.LarkCmd fallback)", + confirmErr.Action, "drive +delete") + } +} + +// TestBuildAPIError_CategoryConfirmationPrefersCodeMetaHints pins that when +// CodeMeta carries explicit Risk + Action, the dispatcher uses them rather +// than falling back to RiskUnknown / ctx.LarkCmd. +func TestBuildAPIError_CategoryConfirmationPrefersCodeMetaHints(t *testing.T) { + const stubCode = 99999992 + codeMeta[stubCode] = CodeMeta{ + Category: errs.CategoryConfirmation, + Subtype: errs.SubtypeConfirmationRequired, + Risk: errs.RiskHighRiskWrite, + Action: "wiki:delete-space", + } + t.Cleanup(func() { delete(codeMeta, stubCode) }) + + resp := map[string]any{"code": stubCode, "msg": "confirmation required"} + ctx := ClassifyContext{LarkCmd: "drive +delete"} + err := BuildAPIError(resp, ctx) + var confirmErr *errs.ConfirmationRequiredError + if !errors.As(err, &confirmErr) { + t.Fatalf("expected *ConfirmationRequiredError, got %T: %v", err, err) + } + if confirmErr.Risk != errs.RiskHighRiskWrite { + t.Errorf("Risk = %q, want %q (CodeMeta hint should win)", + confirmErr.Risk, errs.RiskHighRiskWrite) + } + if confirmErr.Action != "wiki:delete-space" { + t.Errorf("Action = %q, want %q (CodeMeta hint should win)", + confirmErr.Action, "wiki:delete-space") + } +} + +// TestBuildAPIError_UnknownCategoryRoutesToInternalError pins fail-closed +// behaviour: an unrecognized Category routes to InternalError instead of +// emitting an empty Problem on the wire. +func TestBuildAPIError_UnknownCategoryRoutesToInternalError(t *testing.T) { + const stubCode = 99999993 + codeMeta[stubCode] = CodeMeta{ + Category: errs.Category("totally_unknown_category"), + Subtype: errs.SubtypeUnknown, + } + t.Cleanup(func() { delete(codeMeta, stubCode) }) + + resp := map[string]any{"code": stubCode, "msg": "weird"} + err := BuildAPIError(resp, ClassifyContext{}) + var ie *errs.InternalError + if !errors.As(err, &ie) { + t.Fatalf("expected *InternalError, got %T: %v", err, err) + } + if ie.Category != errs.CategoryInternal { + t.Errorf("Category = %q, want %q", ie.Category, errs.CategoryInternal) + } + if ie.Subtype != errs.SubtypeSDKError { + t.Errorf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeSDKError) + } + if ie.Code != stubCode { + t.Errorf("Code = %d, want %d (raw Lark code should propagate)", ie.Code, stubCode) + } +} + +// TestBuildAPIError_ConfigInvalidClient_HasHint pins that when a +// CategoryConfig response (Lark code 10014 — "app secret invalid") flows +// through BuildAPIError, the resulting *ConfigError MUST carry the canonical +// recovery hint pointing the user at `lark-cli config init`. +func TestBuildAPIError_ConfigInvalidClient_HasHint(t *testing.T) { + const code = 10014 + resp := map[string]any{"code": code, "msg": "app secret invalid"} + ctx := ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "bot"} + + err := BuildAPIError(resp, ctx) + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *ConfigError, got %T: %v", err, err) + } + if cfgErr.Subtype != errs.SubtypeInvalidClient { + t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) + } + if cfgErr.Hint == "" { + t.Errorf("Hint is empty; canonical hint required for invalid_client") + } + if !strings.Contains(cfgErr.Hint, "lark-cli config init") { + t.Errorf("Hint should reference `lark-cli config init`; got %q", cfgErr.Hint) + } +} diff --git a/internal/errclass/classify_test.go b/internal/errclass/classify_test.go index 11a01ae86..08cfc0c30 100644 --- a/internal/errclass/classify_test.go +++ b/internal/errclass/classify_test.go @@ -29,6 +29,22 @@ func missingScopeResp(scope string) map[string]any { } } +// appScopeNotAppliedResp builds the Lark response shape for code 99991672 +// ("the app has not applied for the required scope(s)"). Used by tests that +// exercise the bot-perspective ConsoleURL attachment path, which the +// dispatcher restricts to SubtypeAppScopeNotApplied only. +func appScopeNotAppliedResp(scope string) map[string]any { + return map[string]any{ + "code": 99991672, + "msg": "app scope not applied", + "error": map[string]any{ + "permission_violations": []any{ + map[string]any{"subject": scope}, + }, + }, + } +} + func TestBuildAPIError_NilAndZeroCode(t *testing.T) { if got := errclass.BuildAPIError(nil, errclass.ClassifyContext{}); got != nil { t.Errorf("nil resp should return nil error, got %v", got) @@ -95,8 +111,8 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) { {"99991676 token_no_permission", 99991676, errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, 3, "PermissionError"}, {"99991679 missing_scope", 99991679, errs.CategoryAuthorization, errs.SubtypeMissingScope, 3, "PermissionError"}, {"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"}, - {"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.Subtype("task_permission_denied"), 3, "PermissionError"}, - {"1470400 task_invalid_params", 1470400, errs.CategoryValidation, errs.Subtype("task_invalid_params"), 2, "ValidationError"}, + {"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"}, + {"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"}, {"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"}, {"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"}, {"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"}, @@ -129,29 +145,92 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) { } } -// TestBuildAPIError_ValidationRoutesToValidationError pins that code 1470400 -// (taskCodeMeta → CategoryValidation) produces *errs.ValidationError, not -// the default *errs.APIError. The dispatcher must read codeMeta.Category and -// route accordingly so the embedded Problem.Category matches the wire type. -func TestBuildAPIError_ValidationRoutesToValidationError(t *testing.T) { +// TestBuildAPIError_TaskInvalidParamsRoutesToAPIError pins that code 1470400 +// (Lark API-side parameter rejection) routes to *errs.APIError + CategoryAPI +// + SubtypeInvalidParameters. CategoryValidation is reserved for CLI-side +// (caller-side) flag/arg validation, never reachable from API responses; +// classify_test pins the API-side classification here so a regression that +// re-introduces the misclassification fails fast. +func TestBuildAPIError_TaskInvalidParamsRoutesToAPIError(t *testing.T) { resp := map[string]any{"code": 1470400, "msg": "bad params"} err := errclass.BuildAPIError(resp, errclass.ClassifyContext{}) if err == nil { t.Fatal("expected error for code 1470400") } - var ve *errs.ValidationError - if !errors.As(err, &ve) { - t.Fatalf("expected *errs.ValidationError, got %T", err) - } - if _, isAPI := err.(*errs.APIError); isAPI { - t.Fatalf("unexpected *errs.APIError fallthrough (F2 regression): %T", err) + var ae *errs.APIError + if !errors.As(err, &ae) { + t.Fatalf("expected *errs.APIError, got %T", err) } p, ok := errs.ProblemOf(err) if !ok { t.Fatal("ProblemOf returned !ok") } - if p.Category != errs.CategoryValidation { - t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation) + if p.Category != errs.CategoryAPI { + t.Errorf("Category = %q, want %q", p.Category, errs.CategoryAPI) + } + if p.Subtype != errs.SubtypeInvalidParameters { + t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidParameters) + } +} + +// TestBuildAPIError_TroubleshooterLiftedOnAPIArm pins that BuildAPIError lifts +// resp.error.troubleshooter into Problem.Troubleshooter when the response +// routes to the catch-all CategoryAPI arm. troubleshooter is the only +// resp.error field with genuinely non-redundant content vs typed envelope +// fields; the rest (permission_violations.subject, log_id, challenge_url) is +// already lifted by category-specific paths. +func TestBuildAPIError_TroubleshooterLiftedOnAPIArm(t *testing.T) { + resp := map[string]any{ + "code": 1470400, + "msg": "bad params", + "error": map[string]any{ + "troubleshooter": "https://open.feishu.cn/document/troubleshoot/x", + }, + } + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{}) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatal("ProblemOf returned !ok") + } + if p.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/x" { + t.Errorf("Troubleshooter = %q, want passthrough", p.Troubleshooter) + } +} + +// TestBuildAPIError_TroubleshooterLiftedOnPermissionArm pins that +// troubleshooter surfaces on classified non-API arms too — BuildAPIError lifts +// it before the category switch so PermissionError / ConfigError / etc. inherit +// the same wire vocab. +func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) { + resp := map[string]any{ + "code": 99991679, + "msg": "missing scope", + "error": map[string]any{ + "troubleshooter": "https://open.feishu.cn/document/troubleshoot/scope", + "permission_violations": []any{map[string]any{"subject": "docx:document"}}, + }, + } + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Identity: "user"}) + var pe *errs.PermissionError + if !errors.As(err, &pe) { + t.Fatalf("expected *errs.PermissionError, got %T", err) + } + if pe.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/scope" { + t.Errorf("Troubleshooter = %q, want lifted on PermissionError", pe.Troubleshooter) + } +} + +// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty +// when the upstream response omits it — wire envelope must omit the field. +func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) { + resp := map[string]any{"code": 1470400, "msg": "bad params"} + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{}) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatal("ProblemOf returned !ok") + } + if p.Troubleshooter != "" { + t.Errorf("Troubleshooter = %q, want empty when resp omits it", p.Troubleshooter) } } @@ -182,8 +261,6 @@ func TestPermissionErrorEnvelopeShape(t *testing.T) { `"code": 99991679`, `"missing_scopes":`, `"docx:document"`, - `"console_url":`, - `open.feishu.cn/app/cli_a123/auth`, `"identity": "user"`, `"log_id": "lg-1"`, } { @@ -196,6 +273,12 @@ func TestPermissionErrorEnvelopeShape(t *testing.T) { `"component"`, `"doc_url"`, `"retryable":`, // Retryable defaults false, omitempty → key absent + // console_url is gated to SubtypeAppScopeNotApplied (bot-perspective + // dev-action recovery). For user-perspective missing_scope the only + // actionable recovery is `lark-cli auth login --scope ...` (already + // in Hint), so the URL is dropped from the wire to avoid pointing an + // end user at a console they cannot modify. + `"console_url":`, } { if strings.Contains(out, mustNot) { t.Errorf("envelope must not contain %q\nfull: %s", mustNot, out) @@ -228,8 +311,8 @@ func TestRetryableEnvelope_TrueOnly(t *testing.T) { } func TestConsoleURL_FeishuBrand(t *testing.T) { - resp := missingScopeResp("docx:document") - err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}) + resp := appScopeNotAppliedResp("docx:document") + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"}) pe, ok := err.(*errs.PermissionError) if !ok { t.Fatalf("expected *errs.PermissionError, got %T", err) @@ -240,8 +323,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) { } func TestConsoleURL_LarkBrand(t *testing.T) { - resp := missingScopeResp("docx:document") - err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "user"}) + resp := appScopeNotAppliedResp("docx:document") + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "bot"}) pe, ok := err.(*errs.PermissionError) if !ok { t.Fatalf("expected *errs.PermissionError, got %T", err) @@ -252,14 +335,36 @@ func TestConsoleURL_LarkBrand(t *testing.T) { } func TestConsoleURL_EmptyAppID(t *testing.T) { - resp := missingScopeResp("docx:document") - err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "user"}) + resp := appScopeNotAppliedResp("docx:document") + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "bot"}) pe := err.(*errs.PermissionError) if pe.ConsoleURL != "" { t.Errorf("ConsoleURL with empty AppID should be empty; got %q", pe.ConsoleURL) } } +// TestConsoleURL_AttachedOnlyForAppScopeNotApplied pins the gating rule: +// the developer-console deep-link only rides on the wire for +// SubtypeAppScopeNotApplied (where the recovery is "developer applies the +// scope"). User-perspective subtypes such as SubtypeMissingScope recover via +// `lark-cli auth login --scope ...`, so the URL is dead weight on those +// envelopes and is intentionally omitted to avoid pointing an end user at a +// console they cannot modify. +func TestConsoleURL_AttachedOnlyForAppScopeNotApplied(t *testing.T) { + cc := errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"} + + bot := errclass.BuildAPIError(appScopeNotAppliedResp("docx:document"), cc).(*errs.PermissionError) + if bot.ConsoleURL == "" { + t.Errorf("SubtypeAppScopeNotApplied envelope must carry ConsoleURL; got empty") + } + + user := errclass.BuildAPIError(missingScopeResp("docx:document"), + errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}).(*errs.PermissionError) + if user.ConsoleURL != "" { + t.Errorf("SubtypeMissingScope envelope must NOT carry ConsoleURL; got %q", user.ConsoleURL) + } +} + // TestConsoleURL_EscapesDangerousChars pins that ConsoleURL escapes appID and // scope values so a hostile value cannot break out of the URL framing // (e.g. by smuggling extra `&` parameters or a `#` fragment). @@ -335,9 +440,10 @@ func TestPermissionError_DefaultIdentity(t *testing.T) { func TestPermissionError_NoViolations(t *testing.T) { // permission error without a permission_violations array → MissingScopes nil, - // ConsoleURL falls back to the no-scope form. - resp := map[string]any{"code": 99991679, "msg": "x"} - err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}) + // ConsoleURL falls back to the no-scope form. Exercises the bot-perspective + // SubtypeAppScopeNotApplied envelope since that is where ConsoleURL rides. + resp := map[string]any{"code": 99991672, "msg": "x"} + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"}) pe := err.(*errs.PermissionError) if pe.MissingScopes != nil { t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes) @@ -367,20 +473,24 @@ func TestExtractMissingScopes_Dedup(t *testing.T) { } } -// TestServiceShortcutEnvelopeConverge guards that the wire envelope is -// identical whether produced via the dispatcher (BuildAPIError — the normal -// service / shortcut path) or constructed directly at the call site (the -// cmd/service permission path). +// TestServiceShortcutEnvelopeConverge guards that the wire envelope produced +// by the dispatcher (BuildAPIError — the normal service / shortcut path) +// converges with the envelope produced by the direct-construction path used +// in cmd/service/service.go's checkServiceScopes pre-flight check. // -// cmd/service/service.go's checkServiceScopes builds PermissionError using the -// exported PermissionHint and ConsoleURL helpers — the same helpers -// BuildAPIError uses. The hand-constructed branch below intentionally mirrors -// service.go line-by-line so a future drift on either side (e.g. a new -// extension field on PermissionError that only BuildAPIError populates) fails -// loudly here. The remaining limitation is that this test invokes the helpers -// directly rather than driving checkServiceScopes (which requires a credential -// + factory mock). TODO: lift this into cmd/service_test.go once a lightweight -// mock harness lands. +// Both paths now share the same canonical helpers in internal/errclass for +// Message (CanonicalPermissionMessage), Hint (PermissionHint), and +// ConsoleURL (ConsoleURL); MissingScopes and Identity are filled identically. +// A future drift on either side (e.g. a new extension field on +// PermissionError that only BuildAPIError populates, or service.go inlining +// its own message string again) fails this test loudly. +// +// One upstream-derived field is a documented exception: `code` (the Lark +// API numeric code). The pre-flight check runs against a locally cached +// scope list and has no upstream response to extract it from. The +// comparison below strips that key from both envelopes so the assertion +// isolates the contract fields that MUST converge: Subtype, Category, +// Message, Hint, Identity, MissingScopes, ConsoleURL. func TestServiceShortcutEnvelopeConverge(t *testing.T) { const ( brand = "feishu" @@ -392,27 +502,21 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) { // Path A: dispatcher — BuildAPIError parsing a Lark API response. resp := missingScopeResp(missing[0]) dispatcherErr := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: brand, AppID: appID, Identity: identity}) - dispatcherPE, ok := dispatcherErr.(*errs.PermissionError) - if !ok { + if _, ok := dispatcherErr.(*errs.PermissionError); !ok { t.Fatalf("BuildAPIError did not return *PermissionError, got %T", dispatcherErr) } - // Path B: direct construction — exactly mirrors cmd/service/service.go's - // checkServiceScopes (same helpers, same field-fill order). Code - // and Message are copied from Path A so the byte-comparison below isolates - // the contract under test (Hint + Identity + ConsoleURL convergence). - directErr := &errs.PermissionError{ - Problem: errs.Problem{ - Category: errs.CategoryAuthorization, - Subtype: errs.SubtypeMissingScope, - Code: dispatcherPE.Code, - Message: dispatcherPE.Message, - Hint: errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope), - }, - MissingScopes: missing, - Identity: identity, - ConsoleURL: errclass.ConsoleURL(brand, appID, missing), - } + // Path B: direct construction — exercises the same helpers that + // cmd/service/service.go's newPreflightMissingScopeError uses. Keep this + // in lock-step with that helper; if either drifts the byte-comparison + // fails. ConsoleURL is intentionally NOT set on either path for + // SubtypeMissingScope — see the gating rationale in buildPermissionError. + consoleURL := errclass.ConsoleURL(brand, appID, missing) + directErr := errs.NewPermissionError(errs.SubtypeMissingScope, + "%s", errclass.CanonicalPermissionMessage(errs.SubtypeMissingScope, appID, missing, "")). + WithHint("%s", errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope, consoleURL)). + WithMissingScopes(missing...). + WithIdentity(identity) var bufA, bufB bytes.Buffer if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok { @@ -422,11 +526,34 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) { t.Fatal("direct path failed to emit typed envelope") } - if bufA.String() != bufB.String() { - t.Errorf("dispatcher vs direct-construction envelopes diverge:\nDispatcher: %s\nDirect: %s", bufA.String(), bufB.String()) + // Strip `code` from both envelopes — see test doc above. + stripA := stripUpstreamFields(t, bufA.Bytes()) + stripB := stripUpstreamFields(t, bufB.Bytes()) + if stripA != stripB { + t.Errorf("dispatcher vs direct-construction envelopes diverge (upstream fields stripped):\nDispatcher: %s\nDirect: %s", stripA, stripB) } } +// stripUpstreamFields parses an envelope JSON and re-marshals it with the +// upstream-derived "code" key removed from the inner "error" block. Used by +// the convergence test to isolate contract fields shared between the +// dispatcher and pre-flight paths. +func stripUpstreamFields(t *testing.T, raw []byte) string { + t.Helper() + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatalf("envelope not valid JSON: %v\nraw: %s", err, raw) + } + if errBlock, ok := obj["error"].(map[string]any); ok { + delete(errBlock, "code") + } + out, err := json.Marshal(obj) + if err != nil { + t.Fatalf("re-marshal failed: %v", err) + } + return string(out) +} + func TestDirectPermissionPath_TypedExitCode(t *testing.T) { // Mirrors what the cmd/service direct-construction path produces. pe := &errs.PermissionError{ @@ -492,44 +619,48 @@ func TestBuildAPIError_LogIDTopLevel(t *testing.T) { } } -func TestBuildPermissionHint_UserWithScopes(t *testing.T) { - got := errclass.PermissionHint([]string{"docx:document", "im:message"}, "user", errs.SubtypeMissingScope) - if !strings.Contains(got, "lark-cli auth login") { - t.Errorf("user hint should suggest `lark-cli auth login`; got %q", got) - } - if !strings.Contains(got, "docx:document") || !strings.Contains(got, "im:message") { - t.Errorf("user hint should include missing scopes; got %q", got) - } -} - -func TestBuildPermissionHint_BotWithScopes(t *testing.T) { - got := errclass.PermissionHint([]string{"docx:document"}, "bot", errs.SubtypeMissingScope) - if !strings.Contains(got, "open platform console") { - t.Errorf("bot hint should mention the open-platform console; got %q", got) - } - if strings.Contains(got, "auth login") { - t.Errorf("bot hint must not suggest re-running `auth login`; got %q", got) +func TestBuildPermissionHint_MissingScopeRoutesToAuthLogin(t *testing.T) { + // missing_scope means the user authorized the app but did not grant + // this scope — recoverable by re-running `auth login`. Both user and + // bot identities route the same way because the recovery action is + // user-initiated either way. + for _, identity := range []string{"user", "bot", ""} { + got := errclass.PermissionHint([]string{"docx:document", "im:message"}, identity, errs.SubtypeMissingScope, "") + if !strings.Contains(got, "lark-cli auth login") { + t.Errorf("identity=%q: hint should suggest `lark-cli auth login`; got %q", identity, got) + } + if !strings.Contains(got, "docx:document") || !strings.Contains(got, "im:message") { + t.Errorf("identity=%q: hint should include missing scopes; got %q", identity, got) + } } } func TestBuildPermissionHint_NoScopes(t *testing.T) { - if got := errclass.PermissionHint(nil, "user", errs.SubtypeMissingScope); !strings.Contains(got, "required scopes") { - t.Errorf("user no-scope hint missing fallback wording; got %q", got) + // missing_scope with empty list — still suggests auth login even + // without the explicit --scope argument. + if got := errclass.PermissionHint(nil, "user", errs.SubtypeMissingScope, ""); !strings.Contains(got, "lark-cli auth login") { + t.Errorf("missing_scope no-scope hint should still suggest auth login; got %q", got) } - if got := errclass.PermissionHint(nil, "bot", errs.SubtypeMissingScope); !strings.Contains(got, "open platform console") { - t.Errorf("bot no-scope hint should still point at the console; got %q", got) + // app_scope_not_applied without console URL — still points at the + // developer console (URL is optional context, not a routing axis). + if got := errclass.PermissionHint(nil, "user", errs.SubtypeAppScopeNotApplied, ""); !strings.Contains(got, "developer console") { + t.Errorf("app_scope_not_applied no-URL hint should still point at developer console; got %q", got) } } func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) { - // 99991672 / app_scope_not_enabled means the scope has not been granted + // 99991672 / app_scope_not_applied means the scope has not been granted // at the app level — re-authenticating cannot fix it. The hint must // point to the developer console regardless of caller identity, or // agents will loop on `auth login` forever. + consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact" for _, identity := range []string{"user", "bot", ""} { - got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied) - if !strings.Contains(got, "open platform console") { - t.Errorf("identity=%q: hint should point to console; got %q", identity, got) + got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL) + if !strings.Contains(got, "developer console") { + t.Errorf("identity=%q: hint should point to developer console; got %q", identity, got) + } + if !strings.Contains(got, consoleURL) { + t.Errorf("identity=%q: hint should embed the console URL; got %q", identity, got) } if strings.Contains(got, "auth login") { t.Errorf("identity=%q: hint must not suggest `auth login`; got %q", identity, got) @@ -537,6 +668,123 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) { } } +// TestBuildPermissionError_CanonicalMessage pins the per-subtype canonical +// wording so the wire envelope's Message preserves Lark's official phrasing +// ("access denied" / "unauthorized" / "token has no permission") and enhances +// it with CLI context (app ID, scope list). Regressions here are user-visible. +func TestBuildPermissionError_CanonicalMessage(t *testing.T) { + const appID = "cli_xyz" + cases := []struct { + name string + code int + wantSubtype errs.Subtype + // substrings the canonical message MUST contain + wantSubstrs []string + }{ + { + name: "99991672 app_scope_not_applied", + code: 99991672, + wantSubtype: errs.SubtypeAppScopeNotApplied, + wantSubstrs: []string{"access denied", "app " + appID, "contact:contact"}, + }, + { + name: "99991679 missing_scope", + code: 99991679, + wantSubtype: errs.SubtypeMissingScope, + wantSubstrs: []string{"unauthorized", "user authorization", "contact:contact"}, + }, + { + name: "99991676 token_scope_insufficient", + code: 99991676, + wantSubtype: errs.SubtypeTokenScopeInsufficient, + wantSubstrs: []string{"token has no permission"}, + }, + { + name: "230027 user_unauthorized", + code: 230027, + wantSubtype: errs.SubtypeUserUnauthorized, + wantSubstrs: []string{"access denied for this operation"}, + }, + { + name: "99991673 app_unavailable", + code: 99991673, + wantSubtype: errs.SubtypeAppUnavailable, + wantSubstrs: []string{"unauthorized app", "app " + appID, "not properly installed"}, + }, + { + name: "99991662 app_disabled", + code: 99991662, + wantSubtype: errs.SubtypeAppDisabled, + wantSubstrs: []string{"app " + appID, "not in use", "currently disabled"}, + }, + { + name: "1470403 permission_denied", + code: 1470403, + wantSubtype: errs.SubtypePermissionDenied, + wantSubstrs: []string{"user lacks permission"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := map[string]any{ + "code": tc.code, + "msg": "upstream raw text — must be replaced", + "error": map[string]any{"permission_violations": []any{map[string]any{"subject": "contact:contact"}}}, + } + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: appID, Identity: "user"}) + pe, ok := err.(*errs.PermissionError) + if !ok { + t.Fatalf("expected *PermissionError, got %T", err) + } + if pe.Subtype != tc.wantSubtype { + t.Errorf("Subtype = %q, want %q", pe.Subtype, tc.wantSubtype) + } + for _, sub := range tc.wantSubstrs { + if !strings.Contains(pe.Message, sub) { + t.Errorf("Message %q missing substring %q", pe.Message, sub) + } + } + if pe.Message == "upstream raw text — must be replaced" { + t.Errorf("Message must be rewritten to canonical text, got upstream verbatim: %q", pe.Message) + } + }) + } +} + +// TestCanonicalPermissionMessage_FallbackOnUnknownSubtype pins that an unknown +// subtype (not in the per-subtype switch) preserves the upstream fallback +// instead of producing an empty Message. +func TestCanonicalPermissionMessage_FallbackOnUnknownSubtype(t *testing.T) { + got := errclass.CanonicalPermissionMessage(errs.SubtypeUnknown, "cli_x", nil, "upstream verbatim") + if got != "upstream verbatim" { + t.Errorf("unknown subtype should preserve fallback; got %q", got) + } +} + +// TestCanonicalPermissionMessage_EmptyAppIDStillReadable pins the no-app-id +// fallback wording so an early-init bootstrap path that produces a +// PermissionError without ClassifyContext.AppID still emits useful text. +func TestCanonicalPermissionMessage_EmptyAppIDStillReadable(t *testing.T) { + cases := []struct { + sub errs.Subtype + substr string + appIDIn string + }{ + {errs.SubtypeAppScopeNotApplied, "app has not applied", ""}, + {errs.SubtypeAppUnavailable, "app is not properly installed", ""}, + {errs.SubtypeAppDisabled, "app is not in use", ""}, + } + for _, tc := range cases { + got := errclass.CanonicalPermissionMessage(tc.sub, tc.appIDIn, nil, "") + if !strings.Contains(got, tc.substr) { + t.Errorf("subtype=%s no-app-id message missing %q: got %q", tc.sub, tc.substr, got) + } + if strings.Contains(got, " app ") || strings.Contains(got, "app : ") { + t.Errorf("subtype=%s no-app-id message has double space placeholder: %q", tc.sub, got) + } + } +} + func TestBuildAPIError_AppMissingScope_UserIdentityHintRoutesToConsole(t *testing.T) { // Regression: code 99991672 with user identity previously emitted // `lark-cli auth login --scope ...` which sends agents into a re-auth @@ -554,8 +802,8 @@ func TestBuildAPIError_AppMissingScope_UserIdentityHintRoutesToConsole(t *testin if p.Subtype != errs.SubtypeAppScopeNotApplied { t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeAppScopeNotApplied) } - if !strings.Contains(p.Hint, "open platform console") { - t.Errorf("Hint should route to console; got %q", p.Hint) + if !strings.Contains(p.Hint, "developer console") { + t.Errorf("Hint should route to developer console; got %q", p.Hint) } if strings.Contains(p.Hint, "auth login") { t.Errorf("Hint must not suggest `auth login` for app-level scope errors; got %q", p.Hint) diff --git a/internal/errclass/codemeta.go b/internal/errclass/codemeta.go index 572fc4dc8..7c41c875a 100644 --- a/internal/errclass/codemeta.go +++ b/internal/errclass/codemeta.go @@ -12,10 +12,16 @@ import ( // CodeMeta is the classification metadata attached to a Lark numeric code. // It does NOT carry Message or Hint — those are derived at the dispatcher // (see BuildAPIError). +// +// Risk + Action are populated only for codes that route to CategoryConfirmation; +// the dispatcher falls back to RiskUnknown + ctx.LarkCmd when either is empty +// so the envelope is never wire-invalid. type CodeMeta struct { Category errs.Category Subtype errs.Subtype Retryable bool + Risk string // CategoryConfirmation arm only; empty otherwise + Action string // CategoryConfirmation arm only; empty otherwise } // codeMeta is the central registry. Top-level entries (auth/authorization/api/ @@ -27,42 +33,43 @@ type CodeMeta struct { // so sub-tables registering via init() can always assume codeMeta is non-nil. var codeMeta = map[int]CodeMeta{ // CategoryAuthentication - 99991661: {errs.CategoryAuthentication, errs.SubtypeTokenMissing, false}, // Authorization header missing - 99991671: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // token format error (must start with t- / u-) - 99991668: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // UAT invalid/expired (server does not distinguish) - 99991663: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // access_token invalid - 99991677: {errs.CategoryAuthentication, errs.SubtypeTokenExpired, false}, // UAT expired - 20026: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false}, // refresh_token v1 legacy format - 20037: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false}, // refresh_token expired - 20064: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false}, // refresh_token revoked - 20073: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false}, // refresh_token already used - 20050: {errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true}, // refresh endpoint transient error + 99991661: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing}, // Authorization header missing + 99991671: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // token format error (must start with t- / u-) + 99991668: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // UAT invalid/expired (server does not distinguish) + 99991663: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // access_token invalid + 99991677: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired}, // UAT expired + 20026: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // refresh_token v1 legacy format + 20037: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenExpired}, // refresh_token expired + 20064: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenRevoked}, // refresh_token revoked + 20073: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenReused}, // refresh_token already used + 20050: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshServerError, Retryable: true}, // refresh endpoint transient error // CategoryAuthorization - 99991672: {errs.CategoryAuthorization, errs.SubtypeAppScopeNotApplied, false}, - 99991676: {errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, false}, - 99991679: {errs.CategoryAuthorization, errs.SubtypeMissingScope, false}, // user authorized app but did not grant this scope - 230027: {errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false}, // user never authorized the app - 99991673: {errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false}, // app status unavailable - 99991662: {errs.CategoryAuthorization, errs.SubtypeAppNotInstalled, false}, // app not enabled / not installed in tenant + 99991672: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppScopeNotApplied}, + 99991676: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeTokenScopeInsufficient}, + 99991679: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope}, // user authorized app but did not grant this scope + 230027: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user never authorized the app + 99991673: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app status unavailable + 99991662: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppDisabled}, // app currently disabled in tenant // CategoryAPI - 99991400: {errs.CategoryAPI, errs.SubtypeRateLimit, true}, - 1061045: {errs.CategoryAPI, errs.SubtypeConflict, true}, - 131009: {errs.CategoryAPI, errs.SubtypeConflict, true}, // wiki write-path lock contention; retryable with backoff - 1064510: {errs.CategoryAPI, errs.SubtypeCrossTenant, false}, - 1064511: {errs.CategoryAPI, errs.SubtypeCrossBrand, false}, - 1310246: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false}, - 1063006: {errs.CategoryAPI, errs.SubtypeRateLimit, false}, // drive perm-apply quota; 5/day, not short-term retryable - 1063007: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false}, - 231205: {errs.CategoryAPI, errs.SubtypeOwnershipMismatch, false}, + 99991400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Retryable: true}, + 1061045: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, + 131009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, // wiki write-path lock contention; retryable with backoff + 1064510: {Category: errs.CategoryAPI, Subtype: errs.SubtypeCrossTenant}, + 1064511: {Category: errs.CategoryAPI, Subtype: errs.SubtypeCrossBrand}, + 1310246: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, + 1063006: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit}, // drive perm-apply quota; 5/day, not short-term retryable + 1063007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, + 231205: {Category: errs.CategoryAPI, Subtype: errs.SubtypeOwnershipMismatch}, // CategoryConfig - 99991543: {errs.CategoryConfig, errs.SubtypeInvalidClient, false}, // RFC 6749 §5.2 — app_id / app_secret incorrect + 99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API) + 10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543) // CategoryPolicy - 21000: {errs.CategoryPolicy, errs.SubtypeChallengeRequired, false}, - 21001: {errs.CategoryPolicy, errs.SubtypeAccessDenied, false}, + 21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired}, + 21001: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeAccessDenied}, } // LookupCodeMeta is the single lookup entry. Returns ok=false for unknown codes — diff --git a/internal/errclass/codemeta_drive.go b/internal/errclass/codemeta_drive.go new file mode 100644 index 000000000..fe231633b --- /dev/null +++ b/internal/errclass/codemeta_drive.go @@ -0,0 +1,17 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errclass + +import "github.com/larksuite/cli/errs" + +// driveCodeMeta holds drive/docs-service Lark code → CodeMeta mappings. +// Only codes whose meaning is verifiable from repo evidence are registered; +// ambiguous codes fall back to CategoryAPI via BuildAPIError. +// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta. +var driveCodeMeta = map[int]CodeMeta{ + 1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload) + 1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters" +} + +func init() { mergeCodeMeta(driveCodeMeta, "drive") } diff --git a/internal/errclass/codemeta_drive_test.go b/internal/errclass/codemeta_drive_test.go new file mode 100644 index 000000000..a65a94f2c --- /dev/null +++ b/internal/errclass/codemeta_drive_test.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errclass + +import ( + "fmt" + "testing" + + "github.com/larksuite/cli/errs" +) + +// TestLookupCodeMeta_DriveCodes pins each drive-service code registered via the +// codemeta_drive.go init() merge to its expected Category/Subtype/Retryable. +// Each case traces to repo evidence (see codemeta_drive.go comments). +func TestLookupCodeMeta_DriveCodes(t *testing.T) { + cases := []struct { + code int + wantCat errs.Category + wantSubtype errs.Subtype + wantRetry bool + }{ + // 1061044: upload with a nonexistent parent folder token. The drive E2E + // (tests_e2e/drive/2026_06_01_errs_migrate_drive_test.go) drives this + // producer via a nonexistent parent folder → referenced resource missing. + {1061044, errs.CategoryAPI, errs.SubtypeNotFound, false}, + // 1069302: comment endpoint's opaque "Invalid or missing parameters" + // (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection. + {1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) { + meta, ok := LookupCodeMeta(tc.code) + if !ok { + t.Fatalf("code %d not registered in codeMeta", tc.code) + } + if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry { + t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v", + tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry) + } + }) + } +} diff --git a/internal/errclass/codemeta_task.go b/internal/errclass/codemeta_task.go index 4d5e3c728..9449eabbc 100644 --- a/internal/errclass/codemeta_task.go +++ b/internal/errclass/codemeta_task.go @@ -5,20 +5,21 @@ package errclass import "github.com/larksuite/cli/errs" -// taskCodeMeta holds the task-service-specific Lark code classifications. -// 1470403 permission_denied is CategoryAuthorization (exit 3); the other task -// codes route to CategoryAPI / CategoryValidation. BuildAPIError consumes this -// map via mergeCodeMeta + LookupCodeMeta. +// taskCodeMeta holds task-service Lark code → CodeMeta mappings. +// All Subtypes are framework-shared (errs.SubtypeXxx) — task does not declare +// service-specific Subtypes because none of these codes carry semantics beyond +// the cross-service taxonomy (NotFound / QuotaExceeded / etc.). +// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta. var taskCodeMeta = map[int]CodeMeta{ - 1470400: {errs.CategoryValidation, errs.SubtypeTaskInvalidParams, false}, - 1470403: {errs.CategoryAuthorization, errs.SubtypeTaskPermissionDenied, false}, // permission_denied - 1470404: {errs.CategoryAPI, errs.SubtypeTaskNotFound, false}, - 1470422: {errs.CategoryAPI, errs.SubtypeTaskConflict, true}, - 1470500: {errs.CategoryAPI, errs.SubtypeTaskServerError, true}, - 1470610: {errs.CategoryAPI, errs.SubtypeTaskAssigneeLimit, false}, - 1470611: {errs.CategoryAPI, errs.SubtypeTaskFollowerLimit, false}, - 1470612: {errs.CategoryAPI, errs.SubtypeTaskTasklistMemberLimit, false}, - 1470613: {errs.CategoryAPI, errs.SubtypeTaskReminderExists, false}, + 1470400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid_params + 1470403: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // permission_denied (resource-level) + 1470404: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // not_found + 1470422: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, // conflict (retryable) + 1470500: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // server_error (retryable) + 1470610: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // assignee_limit + 1470611: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // follower_limit + 1470612: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tasklist_member_limit + 1470613: {Category: errs.CategoryAPI, Subtype: errs.SubtypeAlreadyExists}, // reminder_exists } func init() { mergeCodeMeta(taskCodeMeta, "task") } diff --git a/internal/errclass/codemeta_test.go b/internal/errclass/codemeta_test.go index ff965ae52..300159079 100644 --- a/internal/errclass/codemeta_test.go +++ b/internal/errclass/codemeta_test.go @@ -4,12 +4,45 @@ package errclass import ( + "fmt" "strings" "testing" "github.com/larksuite/cli/errs" ) +func TestLookupCodeMeta_CredentialCodes(t *testing.T) { + cases := []struct { + code int + wantCat errs.Category + wantSubtype errs.Subtype + wantRetry bool + }{ + {99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, false}, + {99991671, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, + {99991668, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, + {99991663, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, + {99991677, errs.CategoryAuthentication, errs.SubtypeTokenExpired, false}, + {20026, errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false}, + {20037, errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false}, + {20064, errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false}, + {20073, errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false}, + {20050, errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) { + meta, ok := LookupCodeMeta(tc.code) + if !ok { + t.Fatalf("code %d not registered in codeMeta", tc.code) + } + if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry { + t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v", + tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry) + } + }) + } +} + func TestLookupCodeMeta_MissingScope(t *testing.T) { got, ok := LookupCodeMeta(99991679) if !ok { @@ -29,8 +62,8 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) { if got.Category != errs.CategoryAuthorization { t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization) } - if got.Subtype != errs.Subtype("task_permission_denied") { - t.Errorf("Subtype = %q, want %q", got.Subtype, "task_permission_denied") + if got.Subtype != errs.SubtypePermissionDenied { + t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypePermissionDenied) } if got.Retryable { t.Errorf("Retryable = true, want false") @@ -70,6 +103,27 @@ func TestLookupCodeMeta_Unknown(t *testing.T) { } } +// TestLookupCodeMeta_ConfigCode_99991543 pins the Lark "app_id or app_secret +// is incorrect" code to CategoryConfig / SubtypeInvalidClient. The CLI cannot +// retry around a wrong app credential — the operator has to edit the local +// config — so this MUST stay non-retryable and live in the config category +// (not the API category it was originally classed under). +func TestLookupCodeMeta_ConfigCode_99991543(t *testing.T) { + meta, ok := LookupCodeMeta(99991543) + if !ok { + t.Fatal("99991543 not registered in codeMeta") + } + if meta.Category != errs.CategoryConfig { + t.Errorf("category = %v, want %v", meta.Category, errs.CategoryConfig) + } + if meta.Subtype != errs.SubtypeInvalidClient { + t.Errorf("subtype = %v, want %v", meta.Subtype, errs.SubtypeInvalidClient) + } + if meta.Retryable { + t.Errorf("Retryable = true, want false (wrong app credential is operator-fix)") + } +} + func TestLookupCodeMeta_PolicyChallengeRequired(t *testing.T) { got, ok := LookupCodeMeta(21000) if !ok { @@ -93,7 +147,7 @@ func TestMergeCodeMeta_PanicsOnDuplicate(t *testing.T) { if !ok { t.Fatalf("panic value is not a string: %T (%v)", r, r) } - for _, needle := range []string{"1470403", "task_permission_denied", "intruder", "test"} { + for _, needle := range []string{"1470403", "permission_denied", "intruder", "test"} { if !strings.Contains(msg, needle) { t.Errorf("panic message %q missing substring %q", msg, needle) } diff --git a/internal/errcompat/promote.go b/internal/errcompat/promote.go index dc4638e24..7f5cd6fa7 100644 --- a/internal/errcompat/promote.go +++ b/internal/errcompat/promote.go @@ -1,32 +1,48 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -// Package errcompat bridges the legacy *core.ConfigError shape into the -// canonical typed errors taxonomy in errs/. It is a thin boundary helper — -// placed in its own package so it can import both core (for the legacy -// type) and errs (for the typed targets) without creating an import cycle -// with internal/errclass, which intentionally avoids depending on -// internal/core. +// Package errcompat provides boundary helpers that bridge legacy error types +// to the typed errs/ taxonomy. These helpers run at the dispatcher boundary +// (cmd/root.go.handleRootError) before the typed envelope writer, converting +// pre-typed-taxonomy errors (*core.ConfigError, *internalauth.NeedAuthorizationError) +// into typed *errs.* errors while preserving the original error in the Cause +// chain so existing `errors.As` callers continue to match. package errcompat import ( + "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" ) -// PromoteConfigError is the stage-2 boundary helper that will convert a -// *core.ConfigError into the matching typed errs.* error. In stage 1 it -// is a passthrough — the dispatcher continues to render *core.ConfigError -// via the legacy envelope path (cmd/root.go asExitError) so the wire -// shape stays identical to pre-PR. Per-domain typed migration in stage 2+ -// will fill in the actual promotion logic alongside its corresponding -// wire-change announcement. +// PromoteConfigError converts a legacy *core.ConfigError into the matching +// typed errs.*Error based on cfgErr.Type. Called from cmd/root.go.handleRootError +// before the typed envelope writer. The original *core.ConfigError is preserved +// in the Cause chain so external `errors.As(&core.ConfigError{})` callers +// (cmd/auth/list.go, cmd/doctor/doctor.go, etc.) still match. func PromoteConfigError(cfgErr *core.ConfigError) error { if cfgErr == nil { return nil } - return cfgErr + switch cfgErr.Type { + case "auth": + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "%s", cfgErr.Message). + WithHint("%s", cfgErr.Hint). + WithCause(cfgErr) + case "config": + subtype := errs.SubtypeNotConfigured + lower := strings.ToLower(cfgErr.Message) + if strings.Contains(lower, "parse") || strings.Contains(lower, "invalid") { + subtype = errs.SubtypeInvalidConfig + } + return errs.NewConfigError(subtype, "%s", cfgErr.Message). + WithHint("%s", cfgErr.Hint). + WithCause(cfgErr) + default: + // dynamic Type (e.g. workspace name like "bind"/"hermes"/"openclaw") → NotConfigured + return errs.NewConfigError(errs.SubtypeNotConfigured, "%s", cfgErr.Message). + WithHint("%s", cfgErr.Hint). + WithCause(cfgErr) + } } - -// _ keeps the errs import live so stage-2 fill-in does not need to re-add it. -var _ = errs.CategoryConfig diff --git a/internal/errcompat/promote_auth.go b/internal/errcompat/promote_auth.go new file mode 100644 index 000000000..b85457c61 --- /dev/null +++ b/internal/errcompat/promote_auth.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errcompat + +import ( + "github.com/larksuite/cli/errs" + internalauth "github.com/larksuite/cli/internal/auth" +) + +// PromoteAuthError converts a legacy *internalauth.NeedAuthorizationError into +// *errs.AuthenticationError{Subtype: TokenMissing}. The Message field MUST +// contain "need_user_authorization" so the marker invariant guardrail in +// cmd/root_test.go and internal/auth/errors_test.go still holds. +// +// Hint mirrors newTokenMissingError in internal/client/client.go so both +// token-missing surfaces converge on the same recovery vocabulary. cmd's +// applyNeedAuthorizationHint appends per-command scopes onto this Hint with +// a "\n" join, so the action prompt is preserved even when scopes are added. +// +// Called from cmd/root.go.handleRootError when errors.As matches +// *NeedAuthorizationError, before WriteTypedErrorEnvelope. +func PromoteAuthError(err *internalauth.NeedAuthorizationError) error { + if err == nil { + return nil + } + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, + "need_user_authorization (user: %s)", err.UserOpenId). + WithUserOpenID(err.UserOpenId). + WithHint("run: lark-cli auth login to re-authorize"). + WithCause(err) +} diff --git a/internal/errcompat/promote_auth_test.go b/internal/errcompat/promote_auth_test.go new file mode 100644 index 000000000..8e670c642 --- /dev/null +++ b/internal/errcompat/promote_auth_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errcompat + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + internalauth "github.com/larksuite/cli/internal/auth" +) + +func TestPromoteAuthError_PromotesNeedAuthorizationError(t *testing.T) { + needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"} + got := PromoteAuthError(needAuth) + + var authErr *errs.AuthenticationError + if !errors.As(got, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T", got) + } + if authErr.Subtype != errs.SubtypeTokenMissing { + t.Errorf("subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing) + } + + // Cause chain must preserve original *NeedAuthorizationError so legacy + // consumers (auth.IsNeedUserAuthorizationError + errors.As pattern in + // internal/auth/errors.go:42) still match. + var preserved *internalauth.NeedAuthorizationError + if !errors.As(got, &preserved) { + t.Error("Unwrap chain lost *NeedAuthorizationError — breaks auth.IsNeedUserAuthorizationError consumer") + } +} + +func TestPromoteAuthError_PreservesNeedUserAuthorizationMarker(t *testing.T) { + needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"} + got := PromoteAuthError(needAuth) + if !strings.Contains(got.Error(), "need_user_authorization") { + t.Errorf("Message must contain need_user_authorization marker, got: %q", got.Error()) + } +} + +func TestPromoteAuthError_PreservesUserOpenID(t *testing.T) { + needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_test_open_id"} + got := PromoteAuthError(needAuth) + + var authErr *errs.AuthenticationError + if !errors.As(got, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T", got) + } + if authErr.UserOpenID != "u_test_open_id" { + t.Errorf("UserOpenID = %q, want preserved", authErr.UserOpenID) + } +} + +// TestPromoteAuthError_CarriesAuthLoginHint pins that the recovery action +// prompt is attached at promotion time — without this Hint, downstream +// consumers see authentication/token_missing but no "run: lark-cli auth login" +// guidance, mirroring the pre-typed UX failure when NeedAuthorizationError +// surfaced as a bare network error. cmd's applyNeedAuthorizationHint relies +// on this Hint being non-empty so scope enrichment appends instead of +// overwrites the recovery prompt. +func TestPromoteAuthError_CarriesAuthLoginHint(t *testing.T) { + got := PromoteAuthError(&internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}) + var authErr *errs.AuthenticationError + if !errors.As(got, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T", got) + } + if !strings.Contains(authErr.Hint, "lark-cli auth login") { + t.Errorf("Hint must guide user to re-authorize, got: %q", authErr.Hint) + } +} + +func TestPromoteAuthError_Nil_ReturnsNil(t *testing.T) { + if got := PromoteAuthError(nil); got != nil { + t.Errorf("nil input should return nil, got %v", got) + } +} diff --git a/internal/errcompat/promote_test.go b/internal/errcompat/promote_test.go index 43ffea74e..cebeb9b26 100644 --- a/internal/errcompat/promote_test.go +++ b/internal/errcompat/promote_test.go @@ -5,33 +5,101 @@ package errcompat_test import ( "errors" + "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/errcompat" ) -// TestPromoteConfigError_Stage1Passthrough pins the stage-1 passthrough -// behaviour: every input *core.ConfigError flows out unchanged so the -// dispatcher's legacy envelope path emits the same wire shape as pre-PR. -// Per-domain typed migration will replace this in stage 2+. -func TestPromoteConfigError_Stage1Passthrough(t *testing.T) { - for _, cfgType := range []string{"config", "auth", "openclaw", ""} { - t.Run(cfgType, func(t *testing.T) { - src := &core.ConfigError{Code: 3, Type: cfgType, Message: "msg", Hint: "hint"} - out := errcompat.PromoteConfigError(src) - var got *core.ConfigError - if !errors.As(out, &got) || got != src { - t.Fatalf("Type=%q: expected passthrough of original *core.ConfigError, got %T (%v)", cfgType, out, out) +func TestPromoteConfigError_TypeAuth_PromotesToAuthenticationError(t *testing.T) { + cfg := &core.ConfigError{ + Type: "auth", + Code: 3, + Message: "not logged in", + Hint: "run: lark-cli auth login", + } + got := errcompat.PromoteConfigError(cfg) + + var authErr *errs.AuthenticationError + if !errors.As(got, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T", got) + } + if authErr.Subtype != errs.SubtypeTokenMissing { + t.Errorf("subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing) + } + // Cause chain must preserve original *core.ConfigError for errors.As compat. + var cfgPreserved *core.ConfigError + if !errors.As(got, &cfgPreserved) { + t.Error("Unwrap chain lost *core.ConfigError — breaks cmd/auth/list.go consumer") + } +} + +func TestPromoteConfigError_TypeConfig_PromotesToConfigError(t *testing.T) { + cases := []struct { + name string + msg string + wantSubtype errs.Subtype + }{ + {"not_configured", "not configured", errs.SubtypeNotConfigured}, + {"invalid_config_parse", "failed to parse config", errs.SubtypeInvalidConfig}, + {"invalid_config_keyword", "invalid config file", errs.SubtypeInvalidConfig}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := &core.ConfigError{Type: "config", Code: 3, Message: tc.msg} + got := errcompat.PromoteConfigError(cfg) + + var ce *errs.ConfigError + if !errors.As(got, &ce) { + t.Fatalf("expected *errs.ConfigError, got %T", got) + } + if ce.Subtype != tc.wantSubtype { + t.Errorf("subtype = %v, want %v", ce.Subtype, tc.wantSubtype) } }) } } -// TestPromoteConfigError_NilInputReturnsNil pins that PromoteConfigError on a -// nil input returns nil rather than panicking on the (cfgErr.Type) access. -func TestPromoteConfigError_NilInputReturnsNil(t *testing.T) { - if got := errcompat.PromoteConfigError(nil); got != nil { - t.Errorf("PromoteConfigError(nil) = %v, want nil", got) +func TestPromoteConfigError_TypeDynamic_PromotesToConfigError(t *testing.T) { + for _, wsName := range []string{"openclaw", "hermes", "bind"} { + t.Run(wsName, func(t *testing.T) { + cfg := &core.ConfigError{Type: wsName, Code: 3, Message: "not configured"} + got := errcompat.PromoteConfigError(cfg) + + var ce *errs.ConfigError + if !errors.As(got, &ce) { + t.Fatalf("expected *errs.ConfigError, got %T", got) + } + if ce.Subtype != errs.SubtypeNotConfigured { + t.Errorf("subtype = %v, want %v", ce.Subtype, errs.SubtypeNotConfigured) + } + }) + } +} + +func TestPromoteConfigError_Nil_ReturnsNil(t *testing.T) { + if got := errcompat.PromoteConfigError(nil); got != nil { + t.Errorf("nil input should return nil, got %v", got) + } +} + +func TestPromoteConfigError_PreservesMessageHint(t *testing.T) { + cfg := &core.ConfigError{ + Type: "auth", + Message: "session expired (user: u_xxx)", + Hint: "re-authenticate", + } + got := errcompat.PromoteConfigError(cfg) + if !strings.Contains(got.Error(), "session expired") { + t.Errorf("message lost in promotion: %v", got) + } + var authErr *errs.AuthenticationError + if !errors.As(got, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T", got) + } + if authErr.Hint != "re-authenticate" { + t.Errorf("hint = %q, want preserved", authErr.Hint) } } diff --git a/internal/i18n/lang.go b/internal/i18n/lang.go new file mode 100644 index 000000000..f9a69713e --- /dev/null +++ b/internal/i18n/lang.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package i18n + +// Lang is a Feishu locale (e.g. "zh_cn"); "" means unset. +type Lang string + +const ( + LangZhCN Lang = "zh_cn" + LangEnUS Lang = "en_us" + LangJaJP Lang = "ja_jp" + LangKoKR Lang = "ko_kr" + LangFrFR Lang = "fr_fr" + LangDeDE Lang = "de_de" + LangEsES Lang = "es_es" + LangItIT Lang = "it_it" + LangRuRU Lang = "ru_ru" + LangPtBR Lang = "pt_br" + LangThTH Lang = "th_th" + LangViVN Lang = "vi_vn" + LangIdID Lang = "id_id" + LangMsMY Lang = "ms_my" +) + +type langEntry struct { + Code Lang // canonical Feishu locale + Short string // ISO 639-1 code, also accepted as input shorthand +} + +// catalog is the single source of truth; order drives --help and error listing. +var catalog = []langEntry{ + {LangZhCN, "zh"}, {LangEnUS, "en"}, {LangJaJP, "ja"}, {LangKoKR, "ko"}, + {LangFrFR, "fr"}, {LangDeDE, "de"}, {LangEsES, "es"}, {LangItIT, "it"}, + {LangRuRU, "ru"}, {LangPtBR, "pt"}, {LangThTH, "th"}, {LangViVN, "vi"}, + {LangIdID, "id"}, {LangMsMY, "ms"}, +} + +// find matches a short code or Feishu locale against the catalog (case-sensitive). +func find(s string) (langEntry, bool) { + for _, e := range catalog { + if string(e.Code) == s || e.Short == s { + return e, true + } + } + return langEntry{}, false +} + +// Parse resolves a short code or Feishu locale to its canonical Lang. +// "" and unrecognized values return ("", false). +func Parse(s string) (Lang, bool) { + e, ok := find(s) + return e.Code, ok +} + +// IsEnglish reports whether l uses the English TUI bundle (robust to "en_us" +// and legacy "en"). +func (l Lang) IsEnglish() bool { + e, _ := find(string(l)) + return e.Code == LangEnUS +} + +// Base returns the ISO 639-1 short code ("en_us" → "en"), or "" if unknown. +func (l Lang) Base() string { + e, _ := find(string(l)) + return e.Short +} + +// Codes lists the canonical locales, for --help and error messages. +func Codes() []string { + out := make([]string, len(catalog)) + for i, e := range catalog { + out[i] = string(e.Code) + } + return out +} diff --git a/internal/i18n/lang_test.go b/internal/i18n/lang_test.go new file mode 100644 index 000000000..2d3cafa41 --- /dev/null +++ b/internal/i18n/lang_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package i18n + +import "testing" + +func TestParse(t *testing.T) { + tests := []struct { + in string + want Lang + wantOK bool + }{ + {"zh", LangZhCN, true}, // short code + {"zh_cn", LangZhCN, true}, // canonical locale + {"en", LangEnUS, true}, // short code + {"en_us", LangEnUS, true}, // canonical locale + {"ja", LangJaJP, true}, // short code + {"pt", LangPtBR, true}, // pt → pt_br, not pt_pt + {"ms", LangMsMY, true}, // ms → ms_my + {"", "", false}, // unset + {"ZH", "", false}, // case-sensitive + {"zh-CN", "", false}, // hyphen form not accepted + {"zh_CN", "", false}, // case-sensitive region + {"ar", "", false}, // not in the supported set + {"xx", "", false}, // unknown + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + got, ok := Parse(tt.in) + if got != tt.want || ok != tt.wantOK { + t.Errorf("Parse(%q) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.wantOK) + } + }) + } +} + +func TestIsEnglish(t *testing.T) { + tests := []struct { + lang Lang + want bool + }{ + {LangEnUS, true}, + {Lang("en"), true}, // legacy short value on disk stays robust + {LangZhCN, false}, + {LangJaJP, false}, + {Lang("zh"), false}, + {Lang(""), false}, // unset → not English (zh bundle) + {Lang("garbage"), false}, + } + for _, tt := range tests { + t.Run(string(tt.lang), func(t *testing.T) { + if got := tt.lang.IsEnglish(); got != tt.want { + t.Errorf("Lang(%q).IsEnglish() = %v, want %v", tt.lang, got, tt.want) + } + }) + } +} + +func TestBase(t *testing.T) { + tests := []struct { + lang Lang + want string + }{ + {LangEnUS, "en"}, + {LangZhCN, "zh"}, + {LangJaJP, "ja"}, + {Lang("en"), "en"}, // legacy short value + {Lang("zh"), "zh"}, + {Lang(""), ""}, // unset + {Lang("garbage"), ""}, // unknown + } + for _, tt := range tests { + t.Run(string(tt.lang), func(t *testing.T) { + if got := tt.lang.Base(); got != tt.want { + t.Errorf("Lang(%q).Base() = %q, want %q", tt.lang, got, tt.want) + } + }) + } +} + +func TestCodes(t *testing.T) { + codes := Codes() + if len(codes) != 14 { + t.Fatalf("len(Codes()) = %d, want 14", len(codes)) + } + if codes[0] != "zh_cn" { + t.Errorf("Codes()[0] = %q, want %q (catalog order)", codes[0], "zh_cn") + } + // Every code must round-trip through Parse to itself (canonical). + for _, c := range codes { + if got, ok := Parse(c); !ok || string(got) != c { + t.Errorf("Parse(%q) = (%q, %v), want (%q, true)", c, got, ok, c) + } + } +} diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index e2cdecc11..3af04ca50 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -41,6 +41,7 @@ func wrapError(op string, err error) error { if errors.Is(err, errNotInitialized) { hint = "The keychain master key may have been cleaned up or deleted. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain, you can try running this outside the sandbox. Otherwise, please reconfigure the CLI by running lark-cli config init." } + hint += extraHint(err) func() { defer func() { recover() }() diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go index 8e92e2bcc..d92a05560 100644 --- a/internal/keychain/keychain_darwin.go +++ b/internal/keychain/keychain_darwin.go @@ -43,6 +43,12 @@ var keyringGet = keyring.Get // keyringSet is overridden in tests to simulate system keychain writes. var keyringSet = keyring.Set +// errKeychainBlocked is returned when the OS Keychain is reachable but +// denies access — sandbox restriction, user-denied prompt, or a 5-second +// timeout (typically caused by an ignored permission dialog). Distinct +// from errNotInitialized (master key entry genuinely absent). +var errKeychainBlocked = errors.New("keychain access blocked") + // StorageDir returns the storage directory for a given service name on macOS. func StorageDir(service string) string { home, err := vfs.UserHomeDir() @@ -85,7 +91,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) { return } else if !errors.Is(err, keyring.ErrNotFound) { // Not ErrNotFound, which means access was denied or blocked by the system - resCh <- result{key: nil, err: errors.New("keychain access blocked")} + resCh <- result{key: nil, err: errKeychainBlocked} return } @@ -117,7 +123,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) { return res.key, res.err case <-ctx.Done(): // Timeout is usually caused by ignored/blocked permission prompts - return nil, errors.New("keychain access blocked") + return nil, errKeychainBlocked } } @@ -265,11 +271,7 @@ func platformGet(service, account string) (string, error) { if err != nil { return "", err } - plaintext, err := decryptData(data, key) - if err != nil { - return "", err - } - return plaintext, nil + return decryptData(data, key) } // platformSet stores a value in the macOS keychain. @@ -316,3 +318,116 @@ func platformRemove(service, account string) error { } return nil } + +// DowngradeResult reports what DowngradeMasterKeyToFile did. The command +// never writes to or removes from the OS Keychain — it only reads from it +// and only writes to the local file fallback. +type DowngradeResult int + +const ( + // DowngradeAlreadyDone means master.key.file was already present and valid. + DowngradeAlreadyDone DowngradeResult = iota + // DowngradeUsedKeychainKey means the existing OS Keychain master key was + // copied verbatim into the local file fallback. Existing .enc credentials + // remain readable via the file path. + DowngradeUsedKeychainKey + // DowngradeCreatedNewKey means the OS Keychain held no master key, so a + // fresh random key was generated and written to the file fallback only. + // The OS Keychain was not touched. + DowngradeCreatedNewKey +) + +// MasterKeyFilePath returns the absolute path of the file fallback master key +// for the given service. +func MasterKeyFilePath(service string) string { + return filepath.Join(StorageDir(service), fileMasterKeyName) +} + +// DowngradeMasterKeyToFile materializes the OS Keychain master key into the +// local file fallback so that subsequent platformGet calls take the file-first +// path and bypass the OS Keychain entirely. The Keychain entry itself is kept +// as a cold backup; nothing is removed there. +// +// Idempotent: if master.key.file is already present and valid, returns +// DowngradeAlreadyDone without touching anything. +func DowngradeMasterKeyToFile(service string) (DowngradeResult, error) { + dir := StorageDir(service) + keyPath := filepath.Join(dir, fileMasterKeyName) + + existing, statErr := vfs.ReadFile(keyPath) + if statErr == nil { + if len(existing) == masterKeyBytes { + return DowngradeAlreadyDone, nil + } + return 0, errors.New("keychain is corrupted") + } + if !errors.Is(statErr, os.ErrNotExist) { + return 0, statErr + } + + result := DowngradeUsedKeychainKey + key, err := getMasterKey(service, false) + if err != nil { + if !errors.Is(err, errNotInitialized) { + return 0, err + } + // Keychain has no master key. Generate a fresh one *locally* — do + // NOT call getMasterKey(service, true), which would write the new + // key into the OS Keychain as a side effect. keychain-downgrade + // must never modify the OS Keychain; it only ever reads from it. + key = make([]byte, masterKeyBytes) + if _, err := rand.Read(key); err != nil { + return 0, err + } + result = DowngradeCreatedNewKey + } + + if err := vfs.MkdirAll(dir, 0700); err != nil { + return 0, err + } + file, err := vfs.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + if errors.Is(err, os.ErrExist) { + concurrent, readErr := vfs.ReadFile(keyPath) + if readErr == nil && len(concurrent) == masterKeyBytes { + return DowngradeAlreadyDone, nil + } + if readErr != nil { + return 0, readErr + } + return 0, errors.New("keychain is corrupted") + } + return 0, err + } + writeFailed := true + defer func() { + if writeFailed { + _ = vfs.Remove(keyPath) + } + }() + if _, err := file.Write(key); err != nil { + _ = file.Close() + return 0, err + } + if err := file.Close(); err != nil { + return 0, err + } + writeFailed = false + return result, nil +} + +// extraHint appends a darwin-specific suggestion to wrapError's hint message +// when the failure is one keychain-downgrade can recover from: either the +// master key is missing (errNotInitialized) or the OS Keychain is reachable +// but blocking access (errKeychainBlocked — sandbox, denied prompt, timeout). +// In both cases the user can run keychain-downgrade from an interactive +// Terminal session, after which the file fallback is readable from any +// context (sandbox, automation, CI, etc.). Corruption errors are +// deliberately excluded — downgrade would re-read the same bad bytes and +// fail; the right fix there is to delete the corrupt Keychain entry first. +func extraHint(err error) string { + if errors.Is(err, errNotInitialized) || errors.Is(err, errKeychainBlocked) { + return " On macOS, you can also open an interactive Terminal session (where the system Keychain is reachable) and run `lark-cli config keychain-downgrade` to materialize the master key into a local file; subsequent runs in this sandbox/automation context will then read from the file and succeed. Trade-off: after downgrade, any process running as your macOS user can read that file (file permissions replace the Keychain's per-app ACL)." + } + return "" +} diff --git a/internal/keychain/keychain_darwin_test.go b/internal/keychain/keychain_darwin_test.go index 5dc9ddb9a..3d24ca759 100644 --- a/internal/keychain/keychain_darwin_test.go +++ b/internal/keychain/keychain_darwin_test.go @@ -10,8 +10,10 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" + "github.com/larksuite/cli/internal/output" "github.com/zalando/go-keyring" ) @@ -111,6 +113,305 @@ func TestPlatformGetPrefersFileMasterKey(t *testing.T) { } } +// TestDowngradeAlreadyDoneIsIdempotent verifies that re-running downgrade +// when master.key.file already exists is a no-op and reports AlreadyDone +// without touching the system keychain. +func TestDowngradeAlreadyDoneIsIdempotent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(service, user string) (string, error) { + t.Fatalf("keyringGet should not be called when master.key.file is already valid") + return "", nil + } + keyringSet = func(service, user, password string) error { + t.Fatalf("keyringSet should not be called when master.key.file is already valid") + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + service := "test-service" + dir := StorageDir(service) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + preExisting := make([]byte, masterKeyBytes) + for i := range preExisting { + preExisting[i] = byte(i + 7) + } + keyPath := filepath.Join(dir, fileMasterKeyName) + if err := os.WriteFile(keyPath, preExisting, 0600); err != nil { + t.Fatalf("WriteFile(master key) error = %v", err) + } + + result, err := DowngradeMasterKeyToFile(service) + if err != nil { + t.Fatalf("DowngradeMasterKeyToFile() error = %v", err) + } + if result != DowngradeAlreadyDone { + t.Fatalf("result = %v, want DowngradeAlreadyDone", result) + } + + after, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if !bytesEqual(after, preExisting) { + t.Fatalf("master.key.file content changed; want preserved") + } +} + +// TestDowngradeCopiesKeychainKeyToFile verifies the happy path: a keychain +// key exists, the file does not, and downgrade copies the bytes verbatim +// so that existing .enc files (encrypted with the keychain key) remain +// readable via the file fallback. +func TestDowngradeCopiesKeychainKeyToFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + keychainKey := make([]byte, masterKeyBytes) + for i := range keychainKey { + keychainKey[i] = byte(i + 11) + } + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(service, user string) (string, error) { + return base64.StdEncoding.EncodeToString(keychainKey), nil + } + keyringSet = func(service, user, password string) error { + t.Fatalf("keyringSet should not be called when keychain already has a master key") + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + service := "test-service" + + result, err := DowngradeMasterKeyToFile(service) + if err != nil { + t.Fatalf("DowngradeMasterKeyToFile() error = %v", err) + } + if result != DowngradeUsedKeychainKey { + t.Fatalf("result = %v, want DowngradeUsedKeychainKey", result) + } + + got, err := os.ReadFile(MasterKeyFilePath(service)) + if err != nil { + t.Fatalf("ReadFile(master.key.file) error = %v", err) + } + if !bytesEqual(got, keychainKey) { + t.Fatalf("file key bytes do not match keychain key; existing .enc files would become unreadable") + } +} + +// TestDowngradeCreatesNewKeyWhenStorageEmpty verifies the "fresh user" +// path: keychain is empty and no .enc files exist, so we generate a new +// random key and write it to the file fallback. The OS Keychain is NOT +// modified (regression guard for the side-effecting getMasterKey(_, true) +// call we used to make). +func TestDowngradeCreatesNewKeyWhenStorageEmpty(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(service, user string) (string, error) { + return "", keyring.ErrNotFound + } + keyringSet = func(service, user, password string) error { + t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain") + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + service := "test-service" + + result, err := DowngradeMasterKeyToFile(service) + if err != nil { + t.Fatalf("DowngradeMasterKeyToFile() error = %v", err) + } + if result != DowngradeCreatedNewKey { + t.Fatalf("result = %v, want DowngradeCreatedNewKey", result) + } + + fileKey, err := os.ReadFile(MasterKeyFilePath(service)) + if err != nil { + t.Fatalf("ReadFile(master.key.file) error = %v", err) + } + if len(fileKey) != masterKeyBytes { + t.Fatalf("file key length = %d, want %d", len(fileKey), masterKeyBytes) + } +} + +// TestDowngradeDoesNotClobberConcurrentlyWrittenKey is the regression guard +// for the TOCTOU between the initial existence check and the final write. +// Race trace the fix closes: +// +// T0 proc A: ReadFile(keyPath) → ErrNotExist (initial check passes) +// T1 proc B: platformSet → getFileMasterKey(_, true) creates keyPath with K_B +// then writes .enc encrypted with K_B +// T2 proc A: rand.Read → K_A; would overwrite K_B and orphan B's .enc +// +// We simulate proc B's interleaving by performing the concurrent file write +// inside the keyringGet hook — by the time DowngradeMasterKeyToFile gets back +// to the final OpenFile call, the file already exists, the O_EXCL branch +// fires, and the concurrent key is preserved verbatim. +func TestDowngradeDoesNotClobberConcurrentlyWrittenKey(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + service := "test-service" + dir := StorageDir(service) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + concurrentKey := make([]byte, masterKeyBytes) + for i := range concurrentKey { + concurrentKey[i] = byte(i + 77) + } + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(svc, user string) (string, error) { + if err := os.WriteFile(filepath.Join(dir, fileMasterKeyName), concurrentKey, 0600); err != nil { + t.Fatalf("simulated concurrent write failed: %v", err) + } + return "", keyring.ErrNotFound + } + keyringSet = func(svc, user, password string) error { + t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain") + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + result, err := DowngradeMasterKeyToFile(service) + if err != nil { + t.Fatalf("DowngradeMasterKeyToFile() error = %v", err) + } + if result != DowngradeAlreadyDone { + t.Fatalf("result = %v, want DowngradeAlreadyDone (concurrent write must be preserved)", result) + } + got, err := os.ReadFile(filepath.Join(dir, fileMasterKeyName)) + if err != nil { + t.Fatalf("ReadFile error = %v", err) + } + if !bytesEqual(got, concurrentKey) { + t.Fatalf("master.key.file was clobbered; concurrent platformSet's encrypted credentials would be orphaned") + } +} + +// TestPlatformGetSurfacesKeychainBlocked verifies that "keychain access blocked" +// (the sandbox case) propagates as errKeychainBlocked through platformGet, so +// the wrapError hint chain can attach the keychain-downgrade suggestion. +func TestPlatformGetSurfacesKeychainBlocked(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origGet := keyringGet + origSet := keyringSet + keyringGet = func(service, user string) (string, error) { + return "", errors.New("sandbox denied keychain access") + } + keyringSet = func(service, user, password string) error { + return nil + } + t.Cleanup(func() { + keyringGet = origGet + keyringSet = origSet + }) + + service := "test-service" + account := "test-account" + dir := StorageDir(service) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + lostKey := make([]byte, masterKeyBytes) + for i := range lostKey { + lostKey[i] = byte(i + 55) + } + encrypted, err := encryptData("secret", lostKey) + if err != nil { + t.Fatalf("encryptData() error = %v", err) + } + if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), encrypted, 0600); err != nil { + t.Fatalf("WriteFile(.enc) error = %v", err) + } + + _, err = platformGet(service, account) + if !errors.Is(err, errKeychainBlocked) { + t.Fatalf("err = %v, want errKeychainBlocked", err) + } +} + +// TestWrapErrorHintMentionsDowngradeForRecoverableCases is the regression +// guard for the bug where `lark-cli api ...` inside a sandbox surfaced +// "keychain access blocked" but the hint did NOT mention keychain-downgrade +// — the very command meant to recover from that exact situation. Root cause: +// the blocked path used an anonymous errors.New string, so the extraHint +// `errors.Is` check (only matched errNotInitialized) couldn't recognize it. +// +// Asserts the full wrapError → ExitError.Detail.Hint pipeline: +// - errKeychainBlocked + errNotInitialized → hint mentions keychain-downgrade +// - "keychain is corrupted" (downgrade would re-read the same bad bytes) → no mention +// - generic errors → no mention +// +// Add new cases here whenever extraHint's matcher widens, to keep the +// promise that the hint is suggested iff downgrade can actually help. +func TestWrapErrorHintMentionsDowngradeForRecoverableCases(t *testing.T) { + cases := []struct { + name string + err error + wantHint bool + }{ + {"access blocked (sandbox / denied prompt / timeout)", errKeychainBlocked, true}, + {"not initialized (missing master key)", errNotInitialized, true}, + {"corrupted (downgrade would re-read the same bad bytes)", errors.New("keychain is corrupted"), false}, + {"unrelated generic error", errors.New("something else entirely"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := wrapError("Get", tc.err) + var ee *output.ExitError + if !errors.As(err, &ee) || ee.Detail == nil { + t.Fatalf("wrapError returned %#v; expected *output.ExitError with Detail", err) + } + got := strings.Contains(ee.Detail.Hint, "keychain-downgrade") + if got != tc.wantHint { + t.Fatalf("hint mentions keychain-downgrade = %v, want %v\n full hint: %q", got, tc.wantHint, ee.Detail.Hint) + } + }) + } +} + +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + // TestPlatformSetPrefersExistingFileMasterKey verifies writes stay on the file-based // master key path once the fallback master key already exists. func TestPlatformSetPrefersExistingFileMasterKey(t *testing.T) { diff --git a/internal/keychain/keychain_hint_other.go b/internal/keychain/keychain_hint_other.go new file mode 100644 index 000000000..7e3d4fe3e --- /dev/null +++ b/internal/keychain/keychain_hint_other.go @@ -0,0 +1,10 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !darwin + +package keychain + +// extraHint is a no-op on non-darwin platforms. The keychain-downgrade +// command is macOS-only, so there is no extra suggestion to surface. +func extraHint(err error) string { return "" } diff --git a/internal/output/errors.go b/internal/output/errors.go index ee9caa95b..69fcd8339 100644 --- a/internal/output/errors.go +++ b/internal/output/errors.go @@ -17,13 +17,8 @@ import ( // It is propagated up the call chain and handled by main.go to produce // a JSON error envelope on stderr and the correct exit code. // -// Deprecated: *output.ExitError is the legacy error type that predates the -// typed error contract introduced by errs/. New code MUST NOT instantiate it -// — return a typed *errs.XxxError (see errs/ for the available categories: -// *AuthenticationError / *PermissionError / *ValidationError / *NetworkError / -// *APIError / *InternalError / etc.). This type is retained only while -// existing call sites are migrated; it will be removed once they have moved -// to the typed surface. +// Deprecated: legacy error type. Return a typed *errs.XxxError instead +// (see errs/types.go). type ExitError struct { Code int Detail *ErrDetail @@ -47,12 +42,12 @@ func (e *ExitError) Unwrap() error { // MarkRaw sets Raw=true on an ExitError so that the dispatcher skips // enrichment (e.g. enrichPermissionError, enrichMissingScopeError) and -// preserves the original API error detail. Returns the original error +// preserves the upstream message verbatim. Returns the original error // unchanged if it is not (or does not wrap) an ExitError. // // Used by `cmd/api` and other "passthrough" call sites where the caller -// explicitly wants the raw Lark API detail (log_id, troubleshooter, etc.) -// on the wire rather than the enriched message/hint variant. +// wants the original Lark response wording rather than the enriched +// message/hint variant. func MarkRaw(err error) error { var exitErr *ExitError if errors.As(err, &exitErr) { @@ -63,13 +58,8 @@ func MarkRaw(err error) error { // WriteErrorEnvelope writes a JSON error envelope for the given ExitError to w. // -// Deprecated: WriteErrorEnvelope is the legacy envelope writer paired with -// *output.ExitError, which predates the typed error contract introduced by -// errs/. New code MUST NOT call this directly — return a typed *errs.XxxError -// from the command, and cmd/root.go handleRootError will dispatch through -// WriteTypedErrorEnvelope. This writer is retained only while existing -// *ExitError producers are migrated; it will be removed once they have moved -// to the typed surface. +// Deprecated: legacy envelope writer. Typed errors are dispatched by +// cmd/root.go through WriteTypedErrorEnvelope. func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) { if err.Detail == nil { return @@ -95,12 +85,8 @@ func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) { // Errorf creates an ExitError with the given code, type, and formatted message. // -// Deprecated: Errorf belongs to the legacy *output.ExitError surface that -// predates the typed error contract introduced by errs/. New code MUST NOT -// use it — construct a typed *errs.XxxError directly (e.g. -// *errs.ValidationError, *errs.InternalError). This helper is retained only -// while existing call sites are migrated; it will be removed once they have -// moved to the typed surface. +// Deprecated: construct a typed *errs.XxxError directly +// (e.g. errs.NewValidationError, errs.NewInternalError). func Errorf(code int, errType, format string, args ...any) *ExitError { var err error for _, arg := range args { @@ -117,42 +103,26 @@ func Errorf(code int, errType, format string, args ...any) *ExitError { } // ErrValidation creates a validation ExitError (exit 2, wire type -// "validation"). The legacy *output.ExitError envelope emits only -// `type`+`message` — no `subtype`/`param` extension fields. -// -// Stage-1 status: still acceptable to use in new code that only needs the -// (type, message) pair. To carry extension fields (Subtype, Param, etc.) -// on the wire, construct `&errs.ValidationError{...}` directly so -// cmd/root.go routes it through the typed envelope writer. Per-domain -// typed migration in stage 2+ will migrate existing call sites and -// remove this helper. +// "validation"). The legacy envelope emits only `type`+`message`; for +// `subtype` / `param` extension fields, construct a typed +// *errs.ValidationError directly. func ErrValidation(format string, args ...any) *ExitError { return Errorf(ExitValidation, "validation", format, args...) } // ErrAuth creates an authentication ExitError (exit 3, wire type "auth"). // -// Stage-1 status: kept as the canonical helper for token-missing / -// login-required errors, so the 19 existing call sites in cmd/auth, -// cmd/config, cmd/event, internal/client, and shortcuts/common keep -// emitting `type: "auth"`. To migrate a single call site to the typed -// taxonomy (`type: "authentication"` on the wire), construct -// `&errs.AuthenticationError{...}` directly — but note that flips a -// user-visible wire field and belongs in the per-domain stage-2 PR for -// that area, not in unrelated new code. +// New code should construct a typed *errs.AuthenticationError directly; +// the typed envelope emits the canonical `type: "authentication"`. +// Migrating an existing call site flips a user-visible wire field. func ErrAuth(format string, args ...any) *ExitError { return Errorf(ExitAuth, "auth", format, args...) } // ErrNetwork creates a network ExitError (exit 4, wire type "network"). -// The legacy *output.ExitError envelope emits only `type`+`message` — no -// `subtype`/`cause` extension fields. -// -// Stage-1 status: still acceptable to use in new code that only needs the -// (type, message) pair. To carry extension fields (Subtype "transport" / -// "timeout" / "tls" / "dns", retryable hint, etc.) on the wire, construct -// `&errs.NetworkError{...}` directly. Per-domain typed migration in -// stage 2+ will migrate existing call sites and remove this helper. +// The legacy envelope emits only `type`+`message`; for `subtype` +// ("transport" / "timeout" / "tls" / "dns") and retryable hint extension +// fields, construct a typed *errs.NetworkError directly. func ErrNetwork(format string, args ...any) *ExitError { return Errorf(ExitNetwork, "network", format, args...) } @@ -160,14 +130,9 @@ func ErrNetwork(format string, args ...any) *ExitError { // ErrAPI creates an API ExitError using ClassifyLarkError. // For permission errors, uses a concise message; the raw API response is preserved in Detail. // -// Deprecated: ErrAPI belongs to the legacy *output.ExitError surface that -// predates the typed error contract introduced by errs/. New code SHOULD -// construct a typed *errs.XxxError directly. The stage-2+ migration will -// route classification through internal/errclass.BuildAPIError (shipped -// but not yet invoked from production paths) so the typed envelope carries -// Category, Subtype, MissingScopes, ConsoleURL, and Identity from the -// source. This helper is retained only while existing call sites are -// migrated; it will be removed once they have moved to the typed surface. +// Deprecated: route through errclass.BuildAPIError, which emits typed +// *errs.PermissionError / *errs.AuthenticationError / etc. with +// MissingScopes, ConsoleURL, and Identity at the source. func ErrAPI(larkCode int, msg string, detail any) *ExitError { exitCode, errType, hint := ClassifyLarkError(larkCode, msg) if errType == "permission" { @@ -187,12 +152,8 @@ func ErrAPI(larkCode int, msg string, detail any) *ExitError { // ErrWithHint creates an ExitError with a hint string. // -// Deprecated: ErrWithHint belongs to the legacy *output.ExitError surface -// that predates the typed error contract introduced by errs/. New code MUST -// NOT use it — construct a typed *errs.XxxError directly and set its Hint -// field (the typed envelope promotes Problem.Hint to the wire). This helper -// is retained only while existing call sites are migrated; it will be -// removed once they have moved to the typed surface. +// Deprecated: construct a typed *errs.XxxError directly and set its Hint +// field; the typed envelope promotes Problem.Hint to the wire. func ErrWithHint(code int, errType, msg, hint string) *ExitError { return &ExitError{ Code: code, @@ -201,27 +162,57 @@ func ErrWithHint(code int, errType, msg, hint string) *ExitError { } // ErrBare creates an ExitError with only an exit code and no envelope. -// Used for cases like `auth check` where the JSON output is already written to stdout. -// -// Deprecated: ErrBare belongs to the legacy *output.ExitError surface that -// predates the typed error contract introduced by errs/. New code MUST NOT -// use it — express the "exit with code, emit no envelope" semantics -// explicitly at the call site (e.g. return a typed *errs.XxxError or call -// os.Exit directly from RunE). This helper is retained only while existing -// call sites are migrated; it will be removed once they have moved to the -// typed surface. +// The predicate-command silent-exit signal: stdout has already been +// written and the caller wants the matching exit code without a stderr +// envelope (e.g. `auth check` emitting its JSON result and then exiting +// non-zero on a no-token state). Outside the typed-envelope contract. func ErrBare(code int) *ExitError { return &ExitError{Code: code} } +// PartialFailureError is the exit signal for a batch / multi-status command that +// has already written an ok:false result envelope to stdout. The per-item +// outcomes are the primary, machine-readable output and live on stdout, so the +// dispatcher sets only the exit code and writes nothing to stderr. +// +// It is deliberately distinct from ErrBare (the predicate silent-exit signal) +// so the predicate contract stays narrow, and from a typed *errs.XxxError +// (which owns the stderr error envelope): a partial failure is a result, not an +// error envelope. +type PartialFailureError struct { + Code int +} + +func (e *PartialFailureError) Error() string { + return fmt.Sprintf("partial failure (exit %d)", e.Code) +} + +// PartialFailure builds the partial-failure exit signal with the given code. +func PartialFailure(code int) *PartialFailureError { + return &PartialFailureError{Code: code} +} + // WriteTypedErrorEnvelope writes the JSON error envelope for a typed error. // Each typed error owns its wire shape via its own struct tags: Problem fields // are promoted to the top level through embedding, and extension fields // (MissingScopes, ChallengeURL, etc.) sit alongside as siblings — not inside // a `detail` sub-object. // -// Returns true when err was a typed error (envelope written) and false when -// err had no Problem (caller should fall back to WriteErrorEnvelope). +// Two-stage write: +// +// 1. Serialize the envelope into an in-memory buffer. If serialization +// fails, return false so the dispatcher falls back to the legacy +// envelope path; nothing is written to w. +// 2. Best-effort write of the serialized bytes to w. A partial write is +// accepted (return value still true): the typed exit code has already +// been determined upstream by handleRootError calling ExitCodeOf(err) +// before this writer runs, so a torn envelope on stderr must not +// downgrade the caller's typed exit (3/4/6/10) to plain 1. Consumers +// parse-or-skip on malformed JSON. +// +// Returns true when err was a typed error and serialization succeeded. +// Returns false only when err carries no Problem (caller should fall back +// to WriteErrorEnvelope) or when JSON encoding itself failed. func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool { typed, ok := errs.UnwrapTypedError(err) if !ok { @@ -242,12 +233,11 @@ func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool { // back to the legacy envelope writer so stderr is never blank. return false } - if _, writeErr := buf.WriteTo(w); writeErr != nil { - // Write failed mid-envelope. Return false so the dispatcher does - // not silently treat a half-written stderr as a successful emit - // and skip every other fallback. - return false - } + // Best-effort write. Partial-write does not downgrade the success status: + // the dispatcher has already captured ExitCodeOf(err) before calling us, + // and a torn stderr is preferable to falling through to the plain + // "Error:" path with exit 1. + _, _ = w.Write(buf.Bytes()) return true } diff --git a/internal/output/errors_test.go b/internal/output/errors_test.go index ab4a50789..9d4fe9e6a 100644 --- a/internal/output/errors_test.go +++ b/internal/output/errors_test.go @@ -7,9 +7,47 @@ import ( "bytes" "encoding/json" "errors" + "io" "testing" + + "github.com/larksuite/cli/errs" ) +// failingWriter writes up to limit bytes then returns io.ErrShortWrite on +// the write that would push past the limit. Used to simulate a stderr that +// dies mid-envelope. +type failingWriter struct { + limit int + n int +} + +func (f *failingWriter) Write(p []byte) (int, error) { + if f.n+len(p) > f.limit { + canWrite := f.limit - f.n + if canWrite < 0 { + canWrite = 0 + } + f.n += canWrite + return canWrite, io.ErrShortWrite + } + f.n += len(p) + return len(p), nil +} + +// TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus pins that +// when serialization succeeds but the underlying write fails mid-envelope, +// WriteTypedErrorEnvelope returns true so the dispatcher does NOT fall +// through to the legacy "Error:" path and clobber the typed exit code with +// 1. Exit code is preserved separately by handleRootError computing +// ExitCodeOf(err) before the write. +func TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus(t *testing.T) { + err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired") + w := &failingWriter{limit: 20} // dies mid-envelope + if ok := WriteTypedErrorEnvelope(w, err, "user"); !ok { + t.Error("partial write must return true; exit code is preserved separately") + } +} + func TestWriteErrorEnvelope_WithNotice(t *testing.T) { // Set up PendingNotice origNotice := PendingNotice @@ -119,11 +157,11 @@ func TestGetNotice(t *testing.T) { PendingNotice = origNotice } -// TestErrValidation_LegacyExitErrorShape pins the stage-1 wire contract for -// output.ErrValidation: the helper MUST return *output.ExitError (so callers -// using errors.As(&exitErr) continue to work), with wire fields restricted -// to type+message — no `subtype` emission. The typed envelope shape (which -// adds subtype, param, etc.) is reserved for stage-2 per-domain migration. +// TestErrValidation_LegacyExitErrorShape pins the wire contract for +// output.ErrValidation: the helper MUST return *output.ExitError (so +// callers using errors.As(&exitErr) continue to work), with wire fields +// restricted to type+message — no `subtype` emission. Typed +// *errs.ValidationError carries the extension fields when needed. func TestErrValidation_LegacyExitErrorShape(t *testing.T) { err := ErrValidation("bad arg: %s", "x") @@ -163,7 +201,7 @@ func TestErrValidation_LegacyExitErrorShape(t *testing.T) { } } -// TestErrNetwork_LegacyExitErrorShape pins the stage-1 wire contract for +// TestErrNetwork_LegacyExitErrorShape pins the wire contract for // output.ErrNetwork: same legacy *output.ExitError shape as ErrValidation — // no subtype field, errors.As(&exitErr) must succeed, exit code ExitNetwork. func TestErrNetwork_LegacyExitErrorShape(t *testing.T) { diff --git a/internal/output/exitcode.go b/internal/output/exitcode.go index 6b8c2310b..953a31042 100644 --- a/internal/output/exitcode.go +++ b/internal/output/exitcode.go @@ -61,6 +61,10 @@ func ExitCodeOf(err error) int { if _, ok := errs.ProblemOf(err); ok { return ExitCodeForCategory(errs.CategoryOf(err)) } + var pfErr *PartialFailureError + if errors.As(err, &pfErr) { + return pfErr.Code + } var exitErr *ExitError if errors.As(err, &exitErr) { return exitErr.Code diff --git a/internal/output/lark_errors.go b/internal/output/lark_errors.go index 83ecda9f0..892c28177 100644 --- a/internal/output/lark_errors.go +++ b/internal/output/lark_errors.go @@ -31,10 +31,15 @@ const ( LarkErrUserNotAuthorized = 230027 // user not authorized // App credential / status. - LarkErrAppCredInvalid = 99991543 // app_id or app_secret is incorrect - LarkErrAppNotInUse = 99991662 // app is disabled or not installed in this tenant + LarkErrAppCredInvalid = 99991543 // app_id or app_secret is incorrect (Open API) + LarkErrAppNotInUse = 99991662 // app is disabled in this tenant LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation + // TAT-endpoint variant of the "wrong app credentials" condition. + // /open-apis/auth/v3/tenant_access_token/internal returns code 10014 + // ("app secret invalid") instead of 99991543 when the secret is wrong. + LarkErrTATInvalidSecret = 10014 + // Rate limit. LarkErrRateLimit = 99991400 // request frequency limit exceeded @@ -66,6 +71,19 @@ const ( // IM resource ownership mismatch. LarkErrOwnershipMismatch = 231205 + + // Mail send: account / mailbox-level failures returned by + // POST /open-apis/mail/v1/user_mailboxes/:user_mailbox_id/drafts/:draft_id/send. + // Mail v1 uses service-scoped 123xxxx codes; keep the full upstream code + // because ErrAPI preserves Detail.Code exactly as returned by the server. + // These codes indicate the entire batch will keep failing identically and + // are consumed by shortcuts/mail.isFatalSendErr to abort early. + LarkErrMailboxNotFound = 1234013 // mailbox not found or not active + LarkErrMailSendQuotaUser = 1236007 // user daily send count exceeded + LarkErrMailSendQuotaUserExt = 1236008 // user daily external recipient count exceeded + LarkErrMailSendQuotaTenantExt = 1236009 // tenant daily external recipient count exceeded + LarkErrMailQuota = 1236010 // mail quota limit + LarkErrTenantStorageLimit = 1236013 // tenant storage limit exceeded ) // legacyHints supplies the per-code actionable hint string for the legacy @@ -81,14 +99,15 @@ var legacyHints = map[int]string{ LarkErrATInvalid: "run: lark-cli auth login to re-authorize", LarkErrTokenExpired: "run: lark-cli auth login to re-authorize", - LarkErrAppScopeNotEnabled: "check app permissions or re-authorize: lark-cli auth login", - LarkErrTokenNoPermission: "check app permissions or re-authorize: lark-cli auth login", - LarkErrUserScopeInsufficient: "check app permissions or re-authorize: lark-cli auth login", - LarkErrUserNotAuthorized: "check app permissions or re-authorize: lark-cli auth login", + LarkErrAppScopeNotEnabled: "the app developer must apply for the required scope(s) at the developer console", + LarkErrTokenNoPermission: "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued", + LarkErrUserScopeInsufficient: "run `lark-cli auth login` to re-authorize the user with the updated scope set", + LarkErrUserNotAuthorized: "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy", - LarkErrAppCredInvalid: "check app_id / app_secret: lark-cli config set", - LarkErrAppNotInUse: "app is disabled or not installed — check developer console", - LarkErrAppUnauthorized: "app is disabled or not installed — check developer console", + LarkErrAppCredInvalid: "run `lark-cli config init` to set valid app_id and app_secret", + LarkErrTATInvalidSecret: "run `lark-cli config init` to set valid app_id and app_secret", + LarkErrAppNotInUse: "ask the tenant admin to re-enable the app in the Lark admin console", + LarkErrAppUnauthorized: "ask the tenant admin to check the app's install status in the Lark admin console", LarkErrRateLimit: "please try again later", LarkErrDriveResourceContention: "please retry later and avoid concurrent duplicate requests", @@ -104,32 +123,18 @@ var legacyHints = map[int]string{ // ClassifyLarkError maps a Lark API error code + message to the legacy // (exitCode, errType, hint) tuple consumed by the *ExitError path. // -// Classification (Category / Subtype) is sourced from -// errclass.LookupCodeMeta — the single source of truth shipped for both -// this legacy adapter and the stage-2+ typed pipeline (errclass.BuildAPIError, -// not yet invoked in production). This function adapts that result back to -// the legacy tuple shape for callers that still go through *ExitError: +// Classification is sourced from errclass.LookupCodeMeta (the single source +// of truth). exitCode follows legacyExitCode below, which differs from +// ExitCodeForCategory in two preserved-legacy quirks: Authorization + +// permission subtypes return ExitAPI (legacy treated "permission" as +// exit 1), and Config returns ExitAuth (legacy bundled "check +// app_id/secret" under exit 3). errType maps to a legacy short string; +// unknown subtypes fall back to "api_error". Unknown codes classify as +// (ExitAPI, "api_error", ""). // -// - exitCode: derived from (Category, Subtype) via legacyExitCode below. -// Note this differs from the typed pipeline's ExitCodeForCategory in -// two preserved-legacy-quirks: Authorization+permission subtypes return -// ExitAPI (legacy treats "permission" as exit 1) and Config returns -// ExitAuth (legacy bundles "check app_id/secret" under exit 3). -// - errType: legacy short string per (Category, Subtype), mapped by -// legacyErrType. Subtypes not present in the legacy taxonomy fall back -// to "api_error". -// - hint: per-code lookup in legacyHints; "" when absent. -// -// Unknown codes (LookupCodeMeta returns false) classify as -// (ExitAPI, "api_error", "") — matching the prior default. -// -// Deprecated: ClassifyLarkError belongs to the legacy *output.ExitError -// surface that predates the typed error contract introduced by errs/. New -// code MUST NOT use it — classify Lark API responses via -// internal/errclass.BuildAPIError, which emits a typed *errs.XxxError with -// Category, Subtype, and identity-aware extension fields populated at the -// source. This helper is retained only while existing call sites are -// migrated; it will be removed once they have moved to the typed surface. +// Deprecated: route Lark API responses through errclass.BuildAPIError, +// which emits a typed *errs.XxxError with Category, Subtype, and +// identity-aware extension fields populated at the source. func ClassifyLarkError(code int, msg string) (int, string, string) { meta, ok := errclass.LookupCodeMeta(code) if !ok { @@ -167,7 +172,7 @@ func legacyExitCode(cat errs.Category, sub errs.Subtype) int { errs.SubtypeTokenScopeInsufficient: return ExitAPI case errs.SubtypeAppUnavailable, - errs.SubtypeAppNotInstalled: + errs.SubtypeAppDisabled: return ExitAuth } return ExitAPI @@ -193,7 +198,7 @@ func legacyErrType(cat errs.Category, sub errs.Subtype) string { errs.SubtypeTokenScopeInsufficient: return "permission" case errs.SubtypeAppUnavailable, - errs.SubtypeAppNotInstalled: + errs.SubtypeAppDisabled: return "app_status" } return "permission" diff --git a/internal/output/lark_errors_test.go b/internal/output/lark_errors_test.go index 3e8c0e67f..9f7fae8d2 100644 --- a/internal/output/lark_errors_test.go +++ b/internal/output/lark_errors_test.go @@ -91,6 +91,32 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) { } } +func TestMailSendErrorConstantsUseServiceScopedCodes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + got int + want int + }{ + {name: "mailbox not found", got: LarkErrMailboxNotFound, want: 1234013}, + {name: "user daily send quota", got: LarkErrMailSendQuotaUser, want: 1236007}, + {name: "user external recipient quota", got: LarkErrMailSendQuotaUserExt, want: 1236008}, + {name: "tenant external recipient quota", got: LarkErrMailSendQuotaTenantExt, want: 1236009}, + {name: "mail quota", got: LarkErrMailQuota, want: 1236010}, + {name: "tenant storage limit", got: LarkErrTenantStorageLimit, want: 1236013}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if tt.got != tt.want { + t.Fatalf("code=%d, want %d", tt.got, tt.want) + } + }) + } +} + // TestClassifyLarkError_WikiLockContention verifies the wiki write-lock // contention error (131009) maps to an actionable retry hint instead of // a generic "api_error". Surfaces during concurrent wiki +node-create diff --git a/internal/platform/error.go b/internal/platform/error.go index 8ee037aa6..0934a6bed 100644 --- a/internal/platform/error.go +++ b/internal/platform/error.go @@ -39,7 +39,6 @@ const ( ReasonDuplicateHookName = "duplicate_hook_name" ReasonInvalidHookRegister = "invalid_hook_registration" ReasonInvalidRule = "invalid_rule" - ReasonDoubleRestrict = "double_restrict" ReasonRestrictsMismatch = "restricts_mismatch" ReasonCapabilityUnmet = "capability_unmet" ReasonCapabilitiesPanic = "capabilities_panic" diff --git a/internal/platform/host.go b/internal/platform/host.go index 2f13cf59d..b8265ce60 100644 --- a/internal/platform/host.go +++ b/internal/platform/host.go @@ -201,10 +201,10 @@ func installOne(name string, p platform.Plugin, result *InstallResult) error { for _, e := range staging.stagedLifecycles { result.Registry.AddLifecycle(e) } - if staging.rule != nil { + for _, rule := range staging.rules { result.PluginRules = append(result.PluginRules, cmdpolicy.PluginRule{ PluginName: name, - Rule: staging.rule, + Rule: rule, }) } diff --git a/internal/platform/host_test.go b/internal/platform/host_test.go index 13b1f574e..c00a7868a 100644 --- a/internal/platform/host_test.go +++ b/internal/platform/host_test.go @@ -389,3 +389,45 @@ func TestInstallAll_atomicRollback(t *testing.T) { t.Fatalf("error must be *PluginInstallError, got %T", err) } } + +// multiRestrictPlugin calls r.Restrict twice -- the multi-rule case. A +// single plugin may declare several scoped grants; both must be collected +// into PluginRules under the same plugin name, in registration order. +type multiRestrictPlugin struct{} + +func (multiRestrictPlugin) Name() string { return "secaudit" } +func (multiRestrictPlugin) Version() string { return "1.0.0" } +func (multiRestrictPlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{ + Restricts: true, + FailurePolicy: platform.FailClosed, + } +} +func (multiRestrictPlugin) Install(r platform.Registrar) error { + r.Restrict(&platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: platform.RiskRead}) + r.Restrict(&platform.Rule{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: platform.RiskWrite}) + return nil +} + +// A single plugin calling Restrict more than once is valid (multi-rule +// support): both rules are collected, in order, under the one plugin name. +// This pins the behaviour change from the old "Restrict at most once" +// double_restrict error. +func TestInstallAll_multipleRestrictPerPlugin(t *testing.T) { + result, err := internalplatform.InstallAll([]platform.Plugin{multiRestrictPlugin{}}, nil) + if err != nil { + t.Fatalf("multiple Restrict per plugin must succeed, got %v", err) + } + if len(result.PluginRules) != 2 { + t.Fatalf("PluginRules = %d, want 2", len(result.PluginRules)) + } + for _, pr := range result.PluginRules { + if pr.PluginName != "secaudit" { + t.Errorf("PluginName = %q, want secaudit", pr.PluginName) + } + } + if result.PluginRules[0].Rule.Name != "docs-ro" || result.PluginRules[1].Rule.Name != "im-rw" { + t.Errorf("rules out of order: %q, %q", + result.PluginRules[0].Rule.Name, result.PluginRules[1].Rule.Name) + } +} diff --git a/internal/platform/inventory.go b/internal/platform/inventory.go index 1127f9f46..063cb3747 100644 --- a/internal/platform/inventory.go +++ b/internal/platform/inventory.go @@ -24,8 +24,10 @@ type PluginEntry struct { Version string Capabilities CapabilitiesView - // Rule is non-nil only when the plugin called r.Restrict. - Rule *RuleView + // Rules holds the plugin's Restrict contributions, one per r.Restrict + // call (a plugin may declare several scoped rules). Empty when the + // plugin did not call r.Restrict. + Rules []*RuleView Observers []HookEntry Wrappers []HookEntry @@ -149,15 +151,15 @@ func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, ru for _, r := range rules { if entry := byPlugin[r.PluginName]; entry != nil { - entry.Rule = &RuleView{ + entry.Rules = append(entry.Rules, &RuleView{ Name: r.RuleName, Description: r.Desc, - Allow: r.Allow, - Deny: r.Deny, + Allow: append([]string(nil), r.Allow...), + Deny: append([]string(nil), r.Deny...), MaxRisk: r.MaxRisk, - Identities: r.Identities, + Identities: append([]string(nil), r.Identities...), AllowUnannotated: r.AllowUnannotated, - } + }) } } return out @@ -248,12 +250,18 @@ func cloneInventory(in *Inventory) *Inventory { Version: p.Version, Capabilities: p.Capabilities, } - if p.Rule != nil { - rv := *p.Rule - rv.Allow = append([]string(nil), p.Rule.Allow...) - rv.Deny = append([]string(nil), p.Rule.Deny...) - rv.Identities = append([]string(nil), p.Rule.Identities...) - entry.Rule = &rv + if p.Rules != nil { + entry.Rules = make([]*RuleView, len(p.Rules)) + for j, r := range p.Rules { + if r == nil { + continue + } + rv := *r + rv.Allow = append([]string(nil), r.Allow...) + rv.Deny = append([]string(nil), r.Deny...) + rv.Identities = append([]string(nil), r.Identities...) + entry.Rules[j] = &rv + } } entry.Observers = append([]HookEntry(nil), p.Observers...) entry.Wrappers = append([]HookEntry(nil), p.Wrappers...) diff --git a/internal/platform/inventory_test.go b/internal/platform/inventory_test.go index a9d8d8b51..992722cff 100644 --- a/internal/platform/inventory_test.go +++ b/internal/platform/inventory_test.go @@ -56,8 +56,8 @@ func TestBuildInventory_groupsByPluginName(t *testing.T) { if got := len(a.Lifecycles); got != 1 { t.Errorf("a.Lifecycles = %d, want 1", got) } - if a.Rule == nil || a.Rule.Name != "a-rule" { - t.Errorf("a.Rule = %+v, want name a-rule", a.Rule) + if len(a.Rules) != 1 || a.Rules[0].Name != "a-rule" { + t.Errorf("a.Rules = %+v, want single rule name a-rule", a.Rules) } if a.Capabilities.FailurePolicy != "FailClosed" { t.Errorf("a.Capabilities.FailurePolicy = %q, want FailClosed", a.Capabilities.FailurePolicy) @@ -66,14 +66,42 @@ func TestBuildInventory_groupsByPluginName(t *testing.T) { if got := len(b.Observers); got != 1 { t.Errorf("b.Observers = %d, want 1 (only b.audit)", got) } - if b.Rule != nil { - t.Errorf("b.Rule = %+v, want nil (b did not call Restrict)", b.Rule) + if len(b.Rules) != 0 { + t.Errorf("b.Rules = %+v, want empty (b did not call Restrict)", b.Rules) } if b.Capabilities.FailurePolicy != "FailOpen" { t.Errorf("b.Capabilities.FailurePolicy = %q, want FailOpen (zero value)", b.Capabilities.FailurePolicy) } } +// A plugin contributing several rules (same PluginName, multiple +// RuleInventorySource entries) must surface ALL of them under Rules, in +// order -- not silently overwrite down to the last one. Pins the +// multi-rule inventory fix. +func TestBuildInventory_multipleRulesPerPlugin(t *testing.T) { + plugins := []internalplatform.PluginInventorySource{ + {Name: "a", Version: "1.0", Capabilities: platform.Capabilities{ + Restricts: true, FailurePolicy: platform.FailClosed, + }}, + } + rules := []internalplatform.RuleInventorySource{ + {PluginName: "a", RuleName: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"}, + {PluginName: "a", RuleName: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"}, + } + + inv := internalplatform.BuildInventory(plugins, nil, rules) + a := findPlugin(inv, "a") + if a == nil { + t.Fatalf("missing entry a") + } + if len(a.Rules) != 2 { + t.Fatalf("a.Rules = %d, want 2 (both rules preserved, no overwrite)", len(a.Rules)) + } + if a.Rules[0].Name != "docs-ro" || a.Rules[1].Name != "im-rw" { + t.Errorf("rules out of order: %q, %q", a.Rules[0].Name, a.Rules[1].Name) + } +} + func TestBuildInventory_empty(t *testing.T) { inv := internalplatform.BuildInventory(nil, nil, nil) if got := len(inv.Plugins); got != 0 { diff --git a/internal/platform/staging.go b/internal/platform/staging.go index 1b0b7668a..e3bb48295 100644 --- a/internal/platform/staging.go +++ b/internal/platform/staging.go @@ -30,14 +30,15 @@ type stagingRegistrar struct { stagedWrappers []hook.WrapperEntry stagedLifecycles []hook.LifecycleEntry - // rule is the staged Restrict contribution, captured for the host - // to merge with the yaml side later. nil means the plugin did not - // call r.Restrict. - rule *platform.Rule + // rules holds the staged Restrict contributions, captured for the + // host to feed into the resolver later. Empty means the plugin did + // not call r.Restrict. A plugin may call Restrict more than once; + // each call adds one scoped Rule (OR-combined by the engine). + rules []*platform.Rule - // actuallyRestricted records whether r.Restrict was called at all. - // Even a Restrict(nil) flips this to true so the - // Restricts-vs-actual consistency check can detect the call. + // actuallyRestricted records whether r.Restrict was called at all + // (even Restrict(nil)), so the Restricts-vs-actual consistency check + // can detect the call. actuallyRestricted bool // seenHookNames detects duplicate hookName within this plugin's @@ -126,10 +127,6 @@ func (r *stagingRegistrar) On(event platform.LifecycleEvent, name string, fn pla } func (r *stagingRegistrar) Restrict(rule *platform.Rule) { - if r.actuallyRestricted { - r.bufferErr(ReasonDoubleRestrict, "Restrict called more than once") - return - } r.actuallyRestricted = true if rule == nil { r.bufferErr(ReasonInvalidRule, "Restrict(nil)") @@ -144,7 +141,7 @@ func (r *stagingRegistrar) Restrict(rule *platform.Rule) { cp.Allow = append([]string(nil), rule.Allow...) cp.Deny = append([]string(nil), rule.Deny...) cp.Identities = append([]platform.Identity(nil), rule.Identities...) - r.rule = &cp + r.rules = append(r.rules, &cp) } // --- helpers --- diff --git a/internal/registry/helpers.go b/internal/registry/helpers.go index 6f564adfc..39668f2ee 100644 --- a/internal/registry/helpers.go +++ b/internal/registry/helpers.go @@ -38,6 +38,45 @@ func GetStrSliceFromMap(m map[string]interface{}, key string) []string { return result } +// DeclaredScopesForMethod returns the scopes declared by a method's +// from_meta entry for the given identity. Prefers the explicit +// `requiredScopes` field when present; otherwise returns the single +// recommended scope from `scopes` (or the first scope as a final fallback). +// Returns nil when the method has no scope information. +func DeclaredScopesForMethod(method map[string]interface{}, identity string) []string { + if method == nil { + return nil + } + if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 { + out := make([]string, 0, len(requiredRaw)) + for _, v := range requiredRaw { + if s, ok := v.(string); ok && s != "" { + out = append(out, s) + } + } + if len(out) > 0 { + return out + } + } + rawScopes, _ := method["scopes"].([]interface{}) + if len(rawScopes) == 0 { + return nil + } + recommended := SelectRecommendedScope(rawScopes, identity) + if recommended == "" { + for _, raw := range rawScopes { + if s, ok := raw.(string); ok && s != "" { + recommended = s + break + } + } + } + if recommended == "" { + return nil + } + return []string{recommended} +} + // SelectRecommendedScope selects the known scope with the highest priority score // (higher = more recommended / least privilege). // Scopes not in the priority table are skipped to avoid recommending invalid/unknown scopes. diff --git a/internal/registry/loader.go b/internal/registry/loader.go index a310326d6..93360c2da 100644 --- a/internal/registry/loader.go +++ b/internal/registry/loader.go @@ -22,6 +22,64 @@ var registryFS embed.FS // embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in. var embeddedMetaJSON []byte +// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers +// that need to parse key order or other JSON-level structure not exposed by +// LoadFromMeta (which loses map insertion order). +func EmbeddedMetaJSON() []byte { + return embeddedMetaJSON +} + +var ( + embeddedServicesMap map[string]map[string]interface{} // service name -> spec + embeddedServiceNames []string // sorted + embeddedParseOnce sync.Once +) + +// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map +// without touching mergedServices. Safe to call multiple times (sync.Once). +func parseEmbeddedServices() { + embeddedParseOnce.Do(func() { + embeddedServicesMap = make(map[string]map[string]interface{}) + if len(embeddedMetaJSON) == 0 { + return + } + var wrapper struct { + Services []map[string]interface{} `json:"services"` + } + if err := json.Unmarshal(embeddedMetaJSON, &wrapper); err != nil { + return + } + for _, svc := range wrapper.Services { + name, _ := svc["name"].(string) + if name == "" { + continue + } + embeddedServicesMap[name] = svc + } + embeddedServiceNames = make([]string, 0, len(embeddedServicesMap)) + for name := range embeddedServicesMap { + embeddedServiceNames = append(embeddedServiceNames, name) + } + sort.Strings(embeddedServiceNames) + }) +} + +// EmbeddedSpec returns the embedded spec for one service, or nil if unknown. +// Bypasses remote overlay — used for deterministic envelope output. +func EmbeddedSpec(serviceName string) map[string]interface{} { + parseEmbeddedServices() + return embeddedServicesMap[serviceName] +} + +// EmbeddedServiceNames returns sorted embedded service names (no overlay). +// Returns a defensive copy — callers must not mutate the package-level slice. +func EmbeddedServiceNames() []string { + parseEmbeddedServices() + out := make([]string, len(embeddedServiceNames)) + copy(out, embeddedServiceNames) + return out +} + var ( mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec mergedProjectList []string // sorted project names diff --git a/internal/registry/remote.go b/internal/registry/remote.go index b229f641f..e13ec66d0 100644 --- a/internal/registry/remote.go +++ b/internal/registry/remote.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" ) @@ -178,7 +179,9 @@ func saveCachedMerged(data []byte, meta CacheMeta) error { // localVersion is sent as data_version query param for server-side version comparison. // Returns (data, reg, err). A nil reg means the version is unchanged (not modified). func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) { - client := &http.Client{Timeout: fetchTimeout} + // Route through the shared proxy-plugin-aware transport so remote API + // definition fetches honor proxy plugin mode instead of bypassing it. + client := transport.NewHTTPClient(fetchTimeout) req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil) if err != nil { return nil, nil, err diff --git a/internal/schema/assembler.go b/internal/schema/assembler.go new file mode 100644 index 000000000..59f014805 --- /dev/null +++ b/internal/schema/assembler.go @@ -0,0 +1,874 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "bytes" + "encoding/json" + "sort" + "strconv" + "sync" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/registry" +) + +// MethodKeyOrder records the natural meta_data.json key order for one method's +// parameters / requestBody / responseBody. Nested object key orders are stored +// under NestedKeys, keyed by dotted path from the method root +// (e.g. "responseBody.items.properties"). +type MethodKeyOrder struct { + Parameters []string + RequestBody []string + ResponseBody []string + NestedKeys map[string][]string +} + +var ( + keyOrderIndex map[string]*MethodKeyOrder // dottedPath -> order + keyOrderInitOnce sync.Once +) + +// lookupKeyOrder returns the key-order record for service.resourcePath.method, +// or nil if the method is not in the embedded data (e.g. remote-cached). +func lookupKeyOrder(service string, resourcePath []string, method string) *MethodKeyOrder { + keyOrderInitOnce.Do(buildKeyOrderIndex) + if keyOrderIndex == nil { + return nil + } + dotted := dottedPath(service, resourcePath, method) + return keyOrderIndex[dotted] +} + +func dottedPath(service string, resourcePath []string, method string) string { + var buf bytes.Buffer + buf.WriteString(service) + for _, r := range resourcePath { + buf.WriteByte('.') + buf.WriteString(r) + } + buf.WriteByte('.') + buf.WriteString(method) + return buf.String() +} + +// buildKeyOrderIndex parses the embedded meta_data.json bytes once at init, +// walking services -> resources -> methods -> {parameters,requestBody,responseBody} +// and recording each map's key insertion order via json.Decoder.Token(). +func buildKeyOrderIndex() { + raw := registry.EmbeddedMetaJSON() + if len(raw) == 0 { + return + } + keyOrderIndex = make(map[string]*MethodKeyOrder) + + dec := json.NewDecoder(bytes.NewReader(raw)) + // Top-level: { "services": [...], "version": "..." } + if !expectDelim(dec, '{') { + return + } + for dec.More() { + key, _ := readKey(dec) + if key != "services" { + skipValue(dec) + continue + } + if !expectDelim(dec, '[') { + return + } + for dec.More() { + parseService(dec) + } + // closing ] + _, _ = dec.Token() + } +} + +// parseService consumes one service object inside services[]. +// meta_data.json may emit "resources" before "name", so we first capture both +// raw fields, then walk resources with the resolved service name. +func parseService(dec *json.Decoder) { + if !expectDelim(dec, '{') { + return + } + var serviceName string + var resourcesRaw json.RawMessage + for dec.More() { + key, _ := readKey(dec) + switch key { + case "name": + tok, _ := dec.Token() + if s, ok := tok.(string); ok { + serviceName = s + } + case "resources": + if err := dec.Decode(&resourcesRaw); err != nil { + skipValue(dec) + } + default: + skipValue(dec) + } + } + _, _ = dec.Token() // closing } + if serviceName != "" && len(resourcesRaw) > 0 { + subDec := json.NewDecoder(bytes.NewReader(resourcesRaw)) + parseResources(subDec, serviceName, nil) + } +} + +// parseResources walks a resources map (resName -> resource object). +// resourcePath is the accumulated path of parent resources (for nested resources). +func parseResources(dec *json.Decoder, service string, resourcePath []string) { + if !expectDelim(dec, '{') { + return + } + for dec.More() { + resName, _ := readKey(dec) + parseResourceObj(dec, service, append(resourcePath, resName)) + } + _, _ = dec.Token() +} + +// parseResourceObj consumes one resource value: { methods: {...}, ... } and may +// recurse into nested resources via "resources" key if present. +func parseResourceObj(dec *json.Decoder, service string, resourcePath []string) { + if !expectDelim(dec, '{') { + return + } + for dec.More() { + key, _ := readKey(dec) + switch key { + case "methods": + parseMethods(dec, service, resourcePath) + case "resources": + parseResources(dec, service, resourcePath) + default: + skipValue(dec) + } + } + _, _ = dec.Token() +} + +// parseMethods consumes the methods map (methodName -> method object). +func parseMethods(dec *json.Decoder, service string, resourcePath []string) { + if !expectDelim(dec, '{') { + return + } + for dec.More() { + methodName, _ := readKey(dec) + mko := parseMethod(dec) + dotted := dottedPath(service, resourcePath, methodName) + keyOrderIndex[dotted] = mko + } + _, _ = dec.Token() +} + +// parseMethod consumes one method object and records key orders. +func parseMethod(dec *json.Decoder) *MethodKeyOrder { + mko := &MethodKeyOrder{NestedKeys: make(map[string][]string)} + if !expectDelim(dec, '{') { + return mko + } + for dec.More() { + key, _ := readKey(dec) + switch key { + case "parameters": + mko.Parameters = recordObjectKeysRecursive(dec, "parameters", mko.NestedKeys) + case "requestBody": + mko.RequestBody = recordObjectKeysRecursive(dec, "requestBody", mko.NestedKeys) + case "responseBody": + mko.ResponseBody = recordObjectKeysRecursive(dec, "responseBody", mko.NestedKeys) + default: + skipValue(dec) + } + } + _, _ = dec.Token() + return mko +} + +// recordObjectKeysRecursive consumes an object and records the top-level key +// order. It also recurses into each child's "properties" submap, recording +// nested orders under prefix.subpath in nestedKeys. Returns the top-level keys +// in order. +func recordObjectKeysRecursive(dec *json.Decoder, prefix string, nestedKeys map[string][]string) []string { + if !expectDelim(dec, '{') { + return nil + } + var order []string + for dec.More() { + key, _ := readKey(dec) + order = append(order, key) + // Each child value is itself an object; we want its nested "properties" order if present. + consumeFieldRecursive(dec, prefix+"."+key, nestedKeys) + } + _, _ = dec.Token() + if prefix != "" && len(order) > 0 { + nestedKeys[prefix] = order + } + return order +} + +// consumeFieldRecursive consumes a field object (e.g. one parameter spec) and, +// if it contains "properties": {...}, recursively records that submap's order. +func consumeFieldRecursive(dec *json.Decoder, path string, nestedKeys map[string][]string) { + tok, err := dec.Token() + if err != nil { + return + } + delim, ok := tok.(json.Delim) + if !ok || delim != '{' { + // Not an object — skip the rest of the value + skipValueAfterToken(dec, tok) + return + } + for dec.More() { + fieldKey, _ := readKey(dec) + if fieldKey == "properties" { + recordObjectKeysRecursive(dec, path+".properties", nestedKeys) + } else { + skipValue(dec) + } + } + _, _ = dec.Token() +} + +// --- json.Decoder helpers --- + +func expectDelim(dec *json.Decoder, want json.Delim) bool { + tok, err := dec.Token() + if err != nil { + return false + } + delim, ok := tok.(json.Delim) + return ok && delim == want +} + +func readKey(dec *json.Decoder) (string, error) { + tok, err := dec.Token() + if err != nil { + return "", err + } + s, _ := tok.(string) + return s, nil +} + +// skipValue consumes the next complete value (scalar, object, or array). +func skipValue(dec *json.Decoder) { + tok, err := dec.Token() + if err != nil { + return + } + skipValueAfterToken(dec, tok) +} + +func skipValueAfterToken(dec *json.Decoder, tok json.Token) { + delim, ok := tok.(json.Delim) + if !ok { + return + } + // We started inside a container of type `delim` ({ or [) and must eat + // tokens until that container closes, tracking nested containers of any + // kind. depth counts how many open containers we are currently inside. + _ = delim + depth := 1 + for depth > 0 { + t, err := dec.Token() + if err != nil { + return + } + if d, ok := t.(json.Delim); ok { + switch d { + case '{', '[': + depth++ + case '}', ']': + depth-- + } + } + } +} + +// coerceLiteral converts a meta_data literal (default / enum / example) to +// the JSON Schema type declared by the field (integer/number/boolean/string). +// meta_data stores every literal as a string, so without coercion an +// `integer` field would emit string literals and fail any standard validator. +// Already-typed values pass through unchanged. Returns (value, true) on +// success, or (nil, false) when the literal cannot be coerced (caller should +// drop it). +func coerceLiteral(fieldType string, raw interface{}) (interface{}, bool) { + s, isStr := raw.(string) + if !isStr { + // Already typed (e.g. meta_data emitted a JSON number/bool directly). + return raw, true + } + switch fieldType { + case "integer": + if v, err := strconv.ParseInt(s, 10, 64); err == nil { + return v, true + } + return nil, false + case "number": + if v, err := strconv.ParseFloat(s, 64); err == nil { + return v, true + } + return nil, false + case "boolean": + switch s { + case "true": + return true, true + case "false": + return false, true + } + return nil, false + default: // "string", "" (nested objects), or unknown + return s, true + } +} + +// sortEnum sorts an enum slice in-place using a comparator appropriate for +// the declared JSON Schema type, so integer enums end up [1, 2, 10] rather +// than the lexicographic [1, 10, 2]. +func sortEnum(fieldType string, vals []interface{}) { + sort.SliceStable(vals, func(i, j int) bool { + switch fieldType { + case "integer": + ai, _ := vals[i].(int64) + bi, _ := vals[j].(int64) + return ai < bi + case "number": + af, _ := vals[i].(float64) + bf, _ := vals[j].(float64) + return af < bf + case "boolean": + ab, _ := vals[i].(bool) + bb, _ := vals[j].(bool) + return !ab && bb // false < true + default: + as, _ := vals[i].(string) + bs, _ := vals[j].(string) + return as < bs + } + }) +} + +// convertProperty recursively converts one meta_data field map into a Property. +// nestedPath is the dotted lookup key into the current method's NestedKeys map +// (e.g. "responseBody.items.properties"). Empty path = top-level, no nested +// lookup needed. +func convertProperty(field map[string]interface{}, nestedPath string) Property { + var p Property + + rawType, _ := field["type"].(string) + switch rawType { + case "file": + p.Type = "string" + p.Format = "binary" + case "list": + // meta_data uses non-standard "list" on a couple of fields; + // translate to JSON Schema "array" so validators accept it. + p.Type = "array" + default: + p.Type = rawType + } + + if s, ok := field["description"].(string); ok { + p.Description = s + } + if v, ok := field["default"]; ok { + // Coerce default literal to match the declared JSON Schema type so + // validators do not reject e.g. {type:"integer", default:"500"}. + // When coercion fails (e.g. default:"" on an integer field, which + // meta_data uses to mean "no default"), omit the field entirely + // instead of emitting a type-mismatched default — the result is a + // missing `default` key rather than a contract violation. + if coerced, ok := coerceLiteral(p.Type, v); ok { + p.Default = coerced + } + } + if v, ok := field["example"]; ok { + // meta_data stores examples as strings even when the field is integer/ + // boolean/number; coerce to the declared type so downstream validators + // accept the envelope. Drop on coerce failure (same policy as default). + if coerced, ok := coerceLiteral(p.Type, v); ok { + p.Example = coerced + } + } + + // min / max are stored as strings in meta_data; parse on best-effort. + if minStr, ok := field["min"].(string); ok && minStr != "" { + if v, err := strconv.ParseFloat(minStr, 64); err == nil { + p.Minimum = &v + } + } + if maxStr, ok := field["max"].(string); ok && maxStr != "" { + if v, err := strconv.ParseFloat(maxStr, 64); err == nil { + p.Maximum = &v + } + } + + // enum: prefer existing "enum" array; else extract from options[].value. + // Values are typed per p.Type so integer fields get integer enums, etc. + // (JSON Schema 2020-12 requires enum value types to match the declared + // type — meta_data stores everything as strings.) + if enumRaw, ok := field["enum"].([]interface{}); ok && len(enumRaw) > 0 { + for _, e := range enumRaw { + if v, ok := coerceLiteral(p.Type, e); ok { + p.Enum = append(p.Enum, v) + } + } + // Numeric/boolean enums get sorted (no inherent meaning in meta_data + // order); string enums keep meta_data order, which sometimes carries + // semantic priority (e.g. image_type ["message","avatar"]). + if p.Type != "string" && p.Type != "" { + sortEnum(p.Type, p.Enum) + } + } else if optsRaw, ok := field["options"].([]interface{}); ok && len(optsRaw) > 0 { + seen := make(map[string]bool) + for _, o := range optsRaw { + om, ok := o.(map[string]interface{}) + if !ok { + continue + } + raw, ok := om["value"].(string) + if !ok || seen[raw] { + continue + } + seen[raw] = true + if v, ok := coerceLiteral(p.Type, raw); ok { + p.Enum = append(p.Enum, v) + } + } + // Same policy as the `enum` branch: numeric/boolean enums get sorted + // (no semantic meaning in source order); string enums keep meta_data + // order, which may carry semantic priority. + if p.Type != "string" && p.Type != "" { + sortEnum(p.Type, p.Enum) + } + } + + // nested properties: recurse + if propsRaw, ok := field["properties"].(map[string]interface{}); ok && len(propsRaw) > 0 { + nested, nestedRequired := buildOrderedProps(propsRaw, nestedPath) + if p.Type == "array" { + // meta_data quirk: array element schema is wrapped in "properties". + // Unfold into Items: { type: "object", properties: } + p.Items = &Property{ + Type: "object", + Properties: nested, + Required: nestedRequired, + } + // Property.Properties stays nil for arrays + } else { + if p.Type == "" { + p.Type = "object" // infer + } + p.Properties = nested + p.Required = nestedRequired + } + } + + // array items fallback: emit `items: {}` (any schema) for every array that + // meta_data does not describe an element shape for — whether it arrived as + // "list" or natively as "array". Without this, typeless arrays (e.g. arrays + // of bare ID strings) violate the L1 lint rule and are not JSON Schema valid + // for consumers that require `items`. + if p.Type == "array" && p.Items == nil { + p.Items = &Property{} + } + + return p +} + +// buildOrderedProps converts a map[string]interface{} of field specs into an +// OrderedProps plus the alphabetized list of child keys marked `required:true` +// in meta_data. Callers attach that list to the enclosing object's `required`, +// so nested objects faithfully report their call contract (top-level required +// is handled separately by buildInputSchema). +func buildOrderedProps(raw map[string]interface{}, nestedPath string) (*OrderedProps, []string) { + op := &OrderedProps{Map: make(map[string]Property, len(raw))} + + var required []string + keys := orderedKeys(raw, nestedPath) + for _, k := range keys { + fieldRaw, _ := raw[k].(map[string]interface{}) + op.Order = append(op.Order, k) + op.Map[k] = convertProperty(fieldRaw, nestedPath+"."+k+".properties") + if req, _ := fieldRaw["required"].(bool); req { + required = append(required, k) + } + } + sort.Strings(required) + return op, required +} + +// currentMethodOrder is the per-method key-order context used by orderedKeys. +// It is set inside AssembleEnvelope (under assembleMu) and reset on return. +var currentMethodOrder *MethodKeyOrder + +// parseAffordance lifts the affordance overlay from a method's raw meta_data.json +// entry into a typed *Affordance. Returns nil when the field is absent, malformed, +// or carries no populated subfields. +// +// Affordance is authored in larksuite-cli-registry's registry-config.yaml under +// overrides...affordance and flows through gen-registry.py's +// deep_merge into the embedded meta_data.json. +func parseAffordance(raw interface{}) *Affordance { + if raw == nil { + return nil + } + b, err := json.Marshal(raw) + if err != nil { + return nil + } + var a Affordance + if err := json.Unmarshal(b, &a); err != nil { + return nil + } + if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 { + return nil + } + return &a +} + +// convertAccessTokens translates from_meta accessTokens (uses "tenant") into +// CLI --as form (uses "bot"). The result is deduped and sorted alphabetically. +// Unknown tokens are dropped. Returns an empty slice for nil/empty input. +func convertAccessTokens(raw []interface{}) []string { + seen := make(map[string]bool) + for _, t := range raw { + s, ok := t.(string) + if !ok { + continue + } + switch s { + case "tenant": + seen["bot"] = true + case "user": + seen["user"] = true + } + } + out := make([]string, 0, len(seen)) + for k := range seen { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// buildMeta produces the _meta extension namespace. +func buildMeta(method map[string]interface{}) *Meta { + m := &Meta{ + EnvelopeVersion: "1.0", + RequiredScopes: []string{}, // never nil for stable JSON + } + + if scopesRaw, ok := method["scopes"].([]interface{}); ok { + for _, s := range scopesRaw { + if str, ok := s.(string); ok { + m.Scopes = append(m.Scopes, str) + } + } + } + if rsRaw, ok := method["requiredScopes"].([]interface{}); ok { + for _, s := range rsRaw { + if str, ok := s.(string); ok { + m.RequiredScopes = append(m.RequiredScopes, str) + } + } + } + + atRaw, _ := method["accessTokens"].([]interface{}) + m.AccessTokens = convertAccessTokens(atRaw) + + m.Danger, _ = method["danger"].(bool) + + if risk, _ := method["risk"].(string); risk != "" { + m.Risk = risk + } else { + m.Risk = cmdutil.RiskRead + } + + if docURL, _ := method["docUrl"].(string); docURL != "" { + m.DocURL = docURL + } + + m.Affordance = parseAffordance(method["affordance"]) + return m +} + +// buildInputSchema produces the inputSchema for one API method. +// +// Top-level shape: +// +// { type: object, +// required: [<"params" if any param required>, <"data" if any body required>], +// properties: { +// params: { type: object, required: [...], properties: { ...path/query fields } }, // only if method has parameters +// data: { type: object, required: [...], properties: { ...body fields } }, // only if method has requestBody +// yes: { type: boolean, default: false, ... } // only when risk == "high-risk-write" +// } } +// +// The params / data wrapping mirrors the CLI's actual flag layout: +// path+query → --params JSON, body → --data JSON, file → --file. AI consumers +// can pluck inputSchema.properties.params and pass it verbatim to --params. +// +// Caller must set currentMethodOrder for property-order preservation. +func buildInputSchema(method map[string]interface{}) *InputSchema { + is := &InputSchema{ + Type: "object", + Required: []string{}, // never nil — stable envelope shape + Properties: &OrderedProps{Map: make(map[string]Property)}, + } + + // Build the "params" sub-object from method.parameters (path + query). + paramsRaw, _ := method["parameters"].(map[string]interface{}) + paramsProps := &OrderedProps{Map: make(map[string]Property)} + var paramsRequired []string + for _, k := range orderedKeys(paramsRaw, "parameters") { + field, _ := paramsRaw[k].(map[string]interface{}) + prop := convertProperty(field, "parameters."+k+".properties") + paramsProps.Order = append(paramsProps.Order, k) + paramsProps.Map[k] = prop + if req, _ := field["required"].(bool); req { + paramsRequired = append(paramsRequired, k) + } + } + if len(paramsProps.Order) > 0 { + sort.Strings(paramsRequired) + is.Properties.Order = append(is.Properties.Order, "params") + is.Properties.Map["params"] = Property{ + Type: "object", + Required: paramsRequired, + Properties: paramsProps, + } + if len(paramsRequired) > 0 { + is.Required = append(is.Required, "params") + } + } + + // Split method.requestBody into two buckets: + // - data: non-file body fields → corresponds to CLI --data JSON + // - file: type:file body fields → corresponds to CLI --file = + // File fields are kept *out* of `data` so the schema mirrors the actual + // CLI flag dispatch: --file owns one wire format (multipart upload), + // --data owns the rest (JSON body). + bodyRaw, _ := method["requestBody"].(map[string]interface{}) + dataProps := &OrderedProps{Map: make(map[string]Property)} + fileProps := &OrderedProps{Map: make(map[string]Property)} + var dataRequired []string + var fileRequired []string + for _, k := range orderedKeys(bodyRaw, "requestBody") { + field, _ := bodyRaw[k].(map[string]interface{}) + prop := convertProperty(field, "requestBody."+k+".properties") + isFile := false + if t, _ := field["type"].(string); t == "file" { + isFile = true + } + if isFile { + fileProps.Order = append(fileProps.Order, k) + fileProps.Map[k] = prop + if req, _ := field["required"].(bool); req { + fileRequired = append(fileRequired, k) + } + } else { + dataProps.Order = append(dataProps.Order, k) + dataProps.Map[k] = prop + if req, _ := field["required"].(bool); req { + dataRequired = append(dataRequired, k) + } + } + } + if len(dataProps.Order) > 0 { + sort.Strings(dataRequired) + is.Properties.Order = append(is.Properties.Order, "data") + is.Properties.Map["data"] = Property{ + Type: "object", + Required: dataRequired, + Properties: dataProps, + } + if len(dataRequired) > 0 { + is.Required = append(is.Required, "data") + } + } + if len(fileProps.Order) > 0 { + sort.Strings(fileRequired) + is.Properties.Order = append(is.Properties.Order, "file") + is.Properties.Map["file"] = Property{ + Type: "object", + Description: "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file =.", + Required: fileRequired, + Properties: fileProps, + } + if len(fileRequired) > 0 { + is.Required = append(is.Required, "file") + } + } + + // high-risk-write injects a top-level `yes` confirmation flag — sibling + // of params/data. It is a CLI gate (consumed by lark-cli, not sent to + // the backend), not an API field. + if risk, _ := method["risk"].(string); risk == cmdutil.RiskHighRiskWrite { + is.Properties.Order = append(is.Properties.Order, "yes") + falseVal := false + is.Properties.Map["yes"] = Property{ + Type: "boolean", + Default: falseVal, + Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.", + } + // yes is intentionally NOT added to top-level Required; the gate is + // enforced semantically (yes==true) by the CLI, not structurally. + } + + sort.Strings(is.Required) // alphabetical + return is +} + +// buildOutputSchema produces the outputSchema for one API method. +func buildOutputSchema(method map[string]interface{}) *OutputSchema { + os := &OutputSchema{ + Type: "object", + Properties: &OrderedProps{Map: make(map[string]Property)}, + } + respRaw, _ := method["responseBody"].(map[string]interface{}) + for _, k := range orderedKeys(respRaw, "responseBody") { + field, _ := respRaw[k].(map[string]interface{}) + os.Properties.Order = append(os.Properties.Order, k) + os.Properties.Map[k] = convertProperty(field, "responseBody."+k+".properties") + } + return os +} + +// assembleMu serializes AssembleEnvelope calls so that the package-level +// currentMethodOrder pointer is safe for concurrent callers. +var assembleMu sync.Mutex + +// AssembleEnvelope is the main entry point: takes a service / resource path / +// method name plus its meta_data spec, and produces a fully assembled MCP +// envelope. Output is fully determined by inputs (same arguments → same +// envelope), but assembly briefly publishes the per-method key-order context +// through the package-level currentMethodOrder so orderedKeys can reach it +// without threading it through every helper. assembleMu serializes that +// publish, which is why concurrent callers are still safe — they queue +// rather than run in parallel. +// +// If parallelism becomes a bottleneck, replace currentMethodOrder with an +// assembler struct or pass *MethodKeyOrder explicitly down the call chain. +func AssembleEnvelope(serviceName string, resourcePath []string, methodName string, method map[string]interface{}) Envelope { + assembleMu.Lock() + defer assembleMu.Unlock() + currentMethodOrder = lookupKeyOrder(serviceName, resourcePath, methodName) + defer func() { currentMethodOrder = nil }() + + name := serviceName + for _, r := range resourcePath { + name += " " + r + } + name += " " + methodName + + desc, _ := method["description"].(string) + + return Envelope{ + Name: name, + Description: desc, + InputSchema: buildInputSchema(method), + OutputSchema: buildOutputSchema(method), + Meta: buildMeta(method), + } +} + +// MethodFilter is an optional predicate used by AssembleService and +// AssembleAll to filter methods (e.g. by access token for strict mode). +// Pass nil to include all methods. +type MethodFilter func(method map[string]interface{}) bool + +// AssembleService assembles all methods under one service into a sorted +// envelope slice (sorted by Envelope.Name ascending). +func AssembleService(serviceName string, spec map[string]interface{}, filter MethodFilter) []Envelope { + if spec == nil { + return nil + } + resources, _ := spec["resources"].(map[string]interface{}) + var out []Envelope + walkMethods(resources, nil, func(resourcePath []string, methodName string, method map[string]interface{}) { + if filter != nil && !filter(method) { + return + } + out = append(out, AssembleEnvelope(serviceName, resourcePath, methodName, method)) + }) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// AssembleAll assembles every embedded service into one big sorted slice. +// Uses embedded data only (bypasses remote overlay) so envelope output is +// deterministic across machines (CI vs dev vs different user brands). +func AssembleAll(filter MethodFilter) []Envelope { + var out []Envelope + for _, svc := range registry.EmbeddedServiceNames() { + spec := registry.EmbeddedSpec(svc) + out = append(out, AssembleService(svc, spec, filter)...) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// walkMethods recursively walks resources -> methods, calling visit for each +// terminal method. It supports nested resources via the optional "resources" +// key inside a resource value (matches meta_data.json structure). +func walkMethods(resources map[string]interface{}, parentPath []string, + visit func(resourcePath []string, methodName string, method map[string]interface{})) { + for resName, resRaw := range resources { + resMap, ok := resRaw.(map[string]interface{}) + if !ok { + continue + } + curPath := append(append([]string(nil), parentPath...), resName) + if methods, ok := resMap["methods"].(map[string]interface{}); ok { + for mName, mRaw := range methods { + if m, ok := mRaw.(map[string]interface{}); ok { + visit(curPath, mName, m) + } + } + } + if nested, ok := resMap["resources"].(map[string]interface{}); ok { + walkMethods(nested, curPath, visit) + } + } +} + +// orderedKeys returns the keys of raw in their meta_data natural order if +// the current per-method key-order context has them recorded; otherwise +// alphabetical fallback. +func orderedKeys(raw map[string]interface{}, nestedPath string) []string { + if currentMethodOrder != nil && nestedPath != "" { + if order, ok := currentMethodOrder.NestedKeys[nestedPath]; ok { + // Filter to keys that actually exist in raw (defensive) + out := make([]string, 0, len(order)) + seen := make(map[string]bool) + for _, k := range order { + if _, ok := raw[k]; ok { + out = append(out, k) + seen[k] = true + } + } + // Append any keys present in raw but missing from order (defensive), + // alphabetically for determinism. + var extra []string + for k := range raw { + if !seen[k] { + extra = append(extra, k) + } + } + sort.Strings(extra) + out = append(out, extra...) + return out + } + } + // Fallback: alphabetical + keys := make([]string, 0, len(raw)) + for k := range raw { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/schema/assembler_test.go b/internal/schema/assembler_test.go new file mode 100644 index 000000000..6fa0e8bf2 --- /dev/null +++ b/internal/schema/assembler_test.go @@ -0,0 +1,782 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "encoding/json" + "os" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/internal/registry" +) + +// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so +// the suite gives the same answer on every machine. Without this, a stale +// local remote_meta.json could surface methods that aren't in the embedded +// snapshot (or alter their data) depending on the contributor's environment. +// +// Note: os.Exit skips deferred functions, so cleanup is done explicitly +// after m.Run before exiting. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "schema-test-cfg-*") + if err != nil { + // Surface the failure rather than silently running against the host + // cache — that defeats the whole purpose of this isolation. + println("schema test setup: MkdirTemp failed:", err.Error()) + os.Exit(2) + } + os.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + os.Setenv("LARKSUITE_CLI_REMOTE_META", "off") // never touch network + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} + +func TestKeyOrderIndex_ImReactionsList(t *testing.T) { + // We only assert key-set membership, not absolute order — the upstream + // meta_data API does not guarantee a stable JSON key sequence across + // fetches, so hard-coding the order makes CI flaky. Order preservation + // from input to output is tested separately in TestBuildInputSchema_*. + order := lookupKeyOrder("im", []string{"reactions"}, "list") + if order == nil { + t.Fatal("expected key order for im.reactions.list, got nil") + } + wantParams := map[string]bool{ + "message_id": true, "reaction_type": true, "page_token": true, + "page_size": true, "user_id_type": true, + } + if got, want := len(order.Parameters), len(wantParams); got != want { + t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters) + } + for _, k := range order.Parameters { + if !wantParams[k] { + t.Errorf("unexpected parameter key %q", k) + } + } + // im.reactions.list 是 GET,没有 requestBody + if len(order.RequestBody) != 0 { + t.Errorf("expected empty RequestBody, got %v", order.RequestBody) + } +} + +func TestKeyOrderIndex_ImImagesCreate(t *testing.T) { + // Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList. + order := lookupKeyOrder("im", []string{"images"}, "create") + if order == nil { + t.Fatal("expected key order for im.images.create, got nil") + } + wantBody := map[string]bool{"image_type": true, "image": true} + if got, want := len(order.RequestBody), len(wantBody); got != want { + t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody) + } + for _, k := range order.RequestBody { + if !wantBody[k] { + t.Errorf("unexpected requestBody key %q", k) + } + } +} + +func TestKeyOrderIndex_UnknownPath(t *testing.T) { + // 远端缓存的命令(不在 embedded 内)查不到 key order,返回 nil 走字母序兜底 + order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar") + if order != nil { + t.Errorf("expected nil for unknown path, got %+v", order) + } +} + +func TestConvertProperty_BasicTypes(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + wantType string + }{ + {"string", map[string]interface{}{"type": "string"}, "string"}, + {"integer", map[string]interface{}{"type": "integer"}, "integer"}, + {"boolean", map[string]interface{}{"type": "boolean"}, "boolean"}, + {"number", map[string]interface{}{"type": "number"}, "number"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := convertProperty(tt.input, "") + if got.Type != tt.wantType { + t.Errorf("Type = %q, want %q", got.Type, tt.wantType) + } + }) + } +} + +func TestConvertProperty_FileBinary(t *testing.T) { + input := map[string]interface{}{"type": "file", "description": "upload"} + got := convertProperty(input, "") + if got.Type != "string" { + t.Errorf("Type = %q, want \"string\"", got.Type) + } + if got.Format != "binary" { + t.Errorf("Format = %q, want \"binary\"", got.Format) + } +} + +func TestConvertProperty_OptionsToEnum(t *testing.T) { + input := map[string]interface{}{ + "type": "string", + "options": []interface{}{ + map[string]interface{}{"value": "banana"}, + map[string]interface{}{"value": "apple"}, + map[string]interface{}{"value": "banana"}, // duplicate + }, + } + got := convertProperty(input, "") + // string enums preserve source order (deduped), matching the `enum` + // branch. Numeric/boolean enums would still be sorted by value. + want := []interface{}{"banana", "apple"} + if !reflect.DeepEqual(got.Enum, want) { + t.Errorf("Enum = %v, want %v", got.Enum, want) + } +} + +func TestConvertProperty_EnumPassThrough(t *testing.T) { + input := map[string]interface{}{ + "type": "string", + "enum": []interface{}{"x", "y"}, + } + got := convertProperty(input, "") + want := []interface{}{"x", "y"} // pass through, no sort + if !reflect.DeepEqual(got.Enum, want) { + t.Errorf("Enum = %v, want %v", got.Enum, want) + } +} + +func TestConvertProperty_EnumIntegerCoerce(t *testing.T) { + input := map[string]interface{}{ + "type": "integer", + "options": []interface{}{ + map[string]interface{}{"value": "10"}, + map[string]interface{}{"value": "1"}, + map[string]interface{}{"value": "2"}, + }, + } + got := convertProperty(input, "") + want := []interface{}{int64(1), int64(2), int64(10)} // typed + numerically sorted + if !reflect.DeepEqual(got.Enum, want) { + t.Errorf("Enum = %v, want %v", got.Enum, want) + } +} + +func TestConvertProperty_ListTypeFallback(t *testing.T) { + input := map[string]interface{}{ + "type": "list", + "description": "ids", + } + got := convertProperty(input, "") + if got.Type != "array" { + t.Errorf("Type = %q, want %q", got.Type, "array") + } + if got.Items == nil { + t.Fatalf("Items = nil, want non-nil (any-schema fallback)") + } +} + +func TestConvertProperty_MinMaxParsing(t *testing.T) { + input := map[string]interface{}{"type": "integer", "min": "10", "max": "50"} + got := convertProperty(input, "") + if got.Minimum == nil || *got.Minimum != 10.0 { + t.Errorf("Minimum = %v, want 10", got.Minimum) + } + if got.Maximum == nil || *got.Maximum != 50.0 { + t.Errorf("Maximum = %v, want 50", got.Maximum) + } +} + +func TestConvertProperty_MinMaxInvalid(t *testing.T) { + input := map[string]interface{}{"type": "integer", "min": "not_a_number"} + got := convertProperty(input, "") + if got.Minimum != nil { + t.Errorf("Minimum = %v, want nil for unparseable min", got.Minimum) + } +} + +func TestConvertProperty_ArrayWithProperties(t *testing.T) { + // meta_data quirk: array element schema is in "properties" not "items" + input := map[string]interface{}{ + "type": "array", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string"}, + "name": map[string]interface{}{"type": "string"}, + }, + } + got := convertProperty(input, "") + if got.Type != "array" { + t.Fatalf("Type = %q, want \"array\"", got.Type) + } + if got.Items == nil { + t.Fatal("Items is nil, want non-nil") + } + if got.Items.Type != "object" { + t.Errorf("Items.Type = %q, want \"object\"", got.Items.Type) + } + if got.Items.Properties == nil || len(got.Items.Properties.Map) != 2 { + t.Errorf("Items.Properties did not contain both id and name") + } + if got.Properties != nil { + t.Error("array Property must not have top-level Properties after unfold") + } +} + +func TestConvertProperty_ObjectWithProperties(t *testing.T) { + input := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "x": map[string]interface{}{"type": "string"}, + }, + } + got := convertProperty(input, "") + if got.Type != "object" { + t.Errorf("Type = %q, want \"object\"", got.Type) + } + if got.Properties == nil || got.Properties.Map["x"].Type != "string" { + t.Errorf("nested Properties not preserved") + } +} + +func TestConvertProperty_InferObjectFromProperties(t *testing.T) { + input := map[string]interface{}{ + "properties": map[string]interface{}{ + "y": map[string]interface{}{"type": "string"}, + }, + } + got := convertProperty(input, "") + if got.Type != "object" { + t.Errorf("Type = %q, want \"object\" (inferred)", got.Type) + } +} + +func TestConvertProperty_DropsRefAndAnnotations(t *testing.T) { + input := map[string]interface{}{ + "type": "string", + "ref": "operator", + "annotations": []interface{}{"readOnly"}, + "enumName": "FooEnum", + } + got := convertProperty(input, "") + // 这些字段直接被丢弃;Property 结构里也没存这些字段,断言只有 type 设置即可 + if got.Type != "string" { + t.Errorf("Type = %q", got.Type) + } +} + +func TestConvertProperty_DescriptionDefaultExample(t *testing.T) { + input := map[string]interface{}{ + "type": "string", + "description": "hello\nworld", + "default": "", + "example": "ex", + } + got := convertProperty(input, "") + if got.Description != "hello\nworld" { + t.Errorf("Description not preserved verbatim") + } + if got.Default != "" { + t.Errorf("Default = %v, want empty string (preserved)", got.Default) + } + if got.Example != "ex" { + t.Errorf("Example = %v, want \"ex\"", got.Example) + } +} + +func TestBuildInputSchema_ReactionsList(t *testing.T) { + method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") + mko := lookupKeyOrder("im", []string{"reactions"}, "list") + currentMethodOrder = mko + defer func() { currentMethodOrder = nil }() + + is := buildInputSchema(method) + + if is.Type != "object" { + t.Errorf("Type = %q, want \"object\"", is.Type) + } + // top-level required: ["params"] because message_id is a required path param + if !reflect.DeepEqual(is.Required, []string{"params"}) { + t.Errorf("Required = %v, want [params]", is.Required) + } + // top-level properties only contains "params" (no body fields, no high-risk-write) + if !reflect.DeepEqual(is.Properties.Order, []string{"params"}) { + t.Errorf("top-level properties order = %v, want [params]", is.Properties.Order) + } + // params sub-object: required + property order + params := is.Properties.Map["params"] + if params.Type != "object" { + t.Errorf("params.Type = %q, want \"object\"", params.Type) + } + if !reflect.DeepEqual(params.Required, []string{"message_id"}) { + t.Errorf("params.Required = %v, want [message_id]", params.Required) + } + if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) { + t.Errorf("params.properties order = %v, want (from key index) %v", + params.Properties.Order, mko.Parameters) + } +} + +func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) { + method := loadMethodFromRegistry(t, "im", []string{"images"}, "create") + currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create") + defer func() { currentMethodOrder = nil }() + + is := buildInputSchema(method) + + // top-level required: ["data", "file"] — image_type body required + image file required + if !reflect.DeepEqual(is.Required, []string{"data", "file"}) { + t.Errorf("Required = %v, want [data, file]", is.Required) + } + // top-level properties: data (for non-file body) + file (for binary upload) + if !reflect.DeepEqual(is.Properties.Order, []string{"data", "file"}) { + t.Errorf("top-level properties order = %v, want [data, file]", is.Properties.Order) + } + // data sub-object carries only non-file body fields (image_type) + data := is.Properties.Map["data"] + if !reflect.DeepEqual(data.Required, []string{"image_type"}) { + t.Errorf("data.Required = %v, want [image_type]", data.Required) + } + if !reflect.DeepEqual(data.Properties.Order, []string{"image_type"}) { + t.Errorf("data.properties order = %v, want [image_type]", data.Properties.Order) + } + if it := data.Properties.Map["image_type"]; !reflect.DeepEqual(it.Enum, []interface{}{"message", "avatar"}) { + t.Errorf("image_type unexpected: %+v", it) + } + if _, isFile := data.Properties.Map["image"]; isFile { + t.Errorf("image (file field) should NOT appear in data sub-object") + } + + // file sub-object carries the binary upload field + file := is.Properties.Map["file"] + if file.Type != "object" { + t.Errorf("file.Type = %q, want \"object\"", file.Type) + } + if !reflect.DeepEqual(file.Required, []string{"image"}) { + t.Errorf("file.Required = %v, want [image]", file.Required) + } + if !reflect.DeepEqual(file.Properties.Order, []string{"image"}) { + t.Errorf("file.properties order = %v, want [image]", file.Properties.Order) + } + img := file.Properties.Map["image"] + if img.Type != "string" { + t.Errorf("image.Type = %q, want \"string\"", img.Type) + } + if img.Format != "binary" { + t.Errorf("image.Format = %q, want \"binary\"", img.Format) + } +} + +func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) { + // Synthesized method to avoid registry-overlay variance (remote cache may + // strip `risk` field); buildInputSchema only cares about the method map. + method := map[string]interface{}{ + "risk": "high-risk-write", + "parameters": map[string]interface{}{ + "message_id": map[string]interface{}{ + "type": "string", + "location": "path", + "required": true, + }, + }, + } + currentMethodOrder = nil + defer func() { currentMethodOrder = nil }() + + is := buildInputSchema(method) + + // yes lives at inputSchema.properties.yes (sibling of params/data) + yes, ok := is.Properties.Map["yes"] + if !ok { + t.Fatal("expected top-level `yes` property in high-risk-write envelope, not found") + } + if yes.Type != "boolean" { + t.Errorf("yes.Type = %q, want \"boolean\"", yes.Type) + } + if v, _ := yes.Default.(bool); v != false { + t.Errorf("yes.Default = %v, want false", yes.Default) + } + // yes must NOT be in top-level required + for _, r := range is.Required { + if r == "yes" { + t.Errorf("`yes` should not appear in top-level required") + } + } + // yes is appended to properties.Order + last := is.Properties.Order[len(is.Properties.Order)-1] + if last != "yes" { + t.Errorf("`yes` should be last in properties.Order, got: %v", is.Properties.Order) + } +} + +func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) { + method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") + mko := lookupKeyOrder("im", []string{"reactions"}, "list") + currentMethodOrder = mko + defer func() { currentMethodOrder = nil }() + + is := buildInputSchema(method) + if _, ok := is.Properties.Map["yes"]; ok { + t.Errorf("`yes` must not be injected for risk=read") + } +} + +func TestBuildOutputSchema_ReactionsList(t *testing.T) { + method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") + mko := lookupKeyOrder("im", []string{"reactions"}, "list") + currentMethodOrder = mko + defer func() { currentMethodOrder = nil }() + + os := buildOutputSchema(method) + + if os.Type != "object" { + t.Errorf("Type = %q, want \"object\"", os.Type) + } + // Top-level response: has_more, page_token, items + if _, ok := os.Properties.Map["items"]; !ok { + t.Fatal("items not found in outputSchema") + } + items := os.Properties.Map["items"] + if items.Type != "array" { + t.Errorf("items.Type = %q, want \"array\"", items.Type) + } + if items.Items == nil { + t.Fatal("items.Items is nil (array unfold failed)") + } + if items.Items.Type != "object" { + t.Errorf("items.Items.Type = %q, want \"object\"", items.Items.Type) + } +} + +func TestConvertAccessTokens(t *testing.T) { + tests := []struct { + name string + input []interface{} + want []string + }{ + {"tenant only", []interface{}{"tenant"}, []string{"bot"}}, + {"user only", []interface{}{"user"}, []string{"user"}}, + {"tenant then user", []interface{}{"tenant", "user"}, []string{"bot", "user"}}, + {"user then tenant", []interface{}{"user", "tenant"}, []string{"bot", "user"}}, + {"deduped", []interface{}{"tenant", "tenant", "user"}, []string{"bot", "user"}}, + {"empty", []interface{}{}, []string{}}, + {"nil", nil, []string{}}, + {"unknown skipped", []interface{}{"user", "admin"}, []string{"user"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := convertAccessTokens(tt.input) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestBuildMeta_FullFields(t *testing.T) { + // Synthesized method to avoid runtime variance from remote-cache overlay + // (which strips `risk` from merged services). All other field semantics + // match the real im.images.create entry in meta_data.json. + method := map[string]interface{}{ + "risk": "write", + "danger": true, + "scopes": []interface{}{ + "im:resource:upload", + "im:resource", + }, + "accessTokens": []interface{}{"tenant"}, + "docUrl": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create", + } + m := buildMeta(method) + + if m.EnvelopeVersion != "1.0" { + t.Errorf("EnvelopeVersion = %q", m.EnvelopeVersion) + } + if m.Risk != "write" { + t.Errorf("Risk = %q, want \"write\"", m.Risk) + } + if !m.Danger { + t.Errorf("Danger = false, want true") + } + if !reflect.DeepEqual(m.AccessTokens, []string{"bot"}) { + t.Errorf("AccessTokens = %v, want [bot]", m.AccessTokens) + } + if m.DocURL == "" { + t.Errorf("DocURL should be present for im.images.create") + } + if !reflect.DeepEqual(m.Scopes, []string{"im:resource:upload", "im:resource"}) { + t.Errorf("Scopes = %v, want [im:resource:upload, im:resource] (meta_data natural order)", m.Scopes) + } + if m.RequiredScopes == nil { + t.Errorf("RequiredScopes should be empty slice, not nil") + } + if len(m.RequiredScopes) != 0 { + t.Errorf("RequiredScopes should be empty for this method, got %v", m.RequiredScopes) + } + if m.Affordance != nil { + t.Errorf("Affordance must be nil when method has no affordance field, got %+v", m.Affordance) + } +} + +func TestBuildMeta_MissingRiskDefaultsToRead(t *testing.T) { + method := map[string]interface{}{ + "scopes": []interface{}{"x"}, + "accessTokens": []interface{}{"user"}, + // no risk field + } + m := buildMeta(method) + if m.Risk != "read" { + t.Errorf("Risk = %q, want \"read\" (default for missing risk)", m.Risk) + } +} + +func TestBuildMeta_RequiredScopesPresent(t *testing.T) { + method := loadMethodFromRegistry(t, "mail", []string{"user_mailbox", "messages"}, "get") + m := buildMeta(method) + if len(m.RequiredScopes) == 0 { + t.Errorf("RequiredScopes should be non-empty for mail.user_mailbox.messages.get") + } +} + +func TestParseAffordance_NilOrEmpty(t *testing.T) { + cases := []struct { + name string + raw interface{} + }{ + {"nil", nil}, + {"empty object", map[string]interface{}{}}, + {"all-five-empty-arrays", map[string]interface{}{ + "use_when": []interface{}{}, + "do_not_use_when": []interface{}{}, + "prerequisites": []interface{}{}, + "examples": []interface{}{}, + "related": []interface{}{}, + }}, + {"malformed (string)", "not an object"}, + {"malformed (number)", 42}, + {"malformed (nested type mismatch)", map[string]interface{}{ + "examples": "should be a list, not a string", + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := parseAffordance(c.raw); got != nil { + t.Errorf("parseAffordance(%v) = %+v, want nil", c.raw, got) + } + }) + } +} + +func TestParseAffordance_FullPopulated(t *testing.T) { + raw := map[string]interface{}{ + "use_when": []interface{}{"需要拿到当前用户的主日历 ID"}, + "do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"}, + "prerequisites": []interface{}{"user 身份登录"}, + "examples": []interface{}{ + map[string]interface{}{"description": "获取主日历", "command": "lark-cli calendar calendars primary"}, + }, + "related": []interface{}{"calendars.list"}, + } + a := parseAffordance(raw) + if a == nil { + t.Fatal("parseAffordance returned nil, want populated") + } + if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" { + t.Errorf("UseWhen = %v", a.UseWhen) + } + if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || + a.Examples[0].Command != "lark-cli calendar calendars primary" { + t.Errorf("Examples = %+v", a.Examples) + } + if len(a.Related) != 1 || a.Related[0] != "calendars.list" { + t.Errorf("Related = %v", a.Related) + } +} + +func TestBuildMeta_AffordanceFromMethod(t *testing.T) { + method := map[string]interface{}{ + "scopes": []interface{}{"x"}, + "accessTokens": []interface{}{"user"}, + "risk": "read", + "affordance": map[string]interface{}{ + "use_when": []interface{}{"trigger"}, + }, + } + m := buildMeta(method) + if m.Affordance == nil { + t.Fatal("Affordance should be populated from method[\"affordance\"]") + } + if len(m.Affordance.UseWhen) != 1 || m.Affordance.UseWhen[0] != "trigger" { + t.Errorf("UseWhen = %v", m.Affordance.UseWhen) + } +} + +func TestBuildMeta_MissingDocURLOmitted(t *testing.T) { + method := map[string]interface{}{ + "scopes": []interface{}{"x"}, + "accessTokens": []interface{}{"user"}, + "risk": "read", + // no docUrl + } + m := buildMeta(method) + if m.DocURL != "" { + t.Errorf("DocURL = %q, want empty (will be omitempty)", m.DocURL) + } + // Verify JSON serialization omits doc_url + b, _ := json.Marshal(m) + if strings.Contains(string(b), "doc_url") { + t.Errorf("doc_url should be omitted from JSON, got: %s", b) + } +} + +func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) { + // 装配器对空 responseBody 应生成 properties = {} (不 nil) + method := map[string]interface{}{} + currentMethodOrder = nil + os := buildOutputSchema(method) + if os.Type != "object" { + t.Errorf("Type = %q, want \"object\"", os.Type) + } + if os.Properties == nil { + t.Fatal("Properties is nil, want empty OrderedProps") + } + if len(os.Properties.Order) != 0 { + t.Errorf("Properties.Order should be empty, got %v", os.Properties.Order) + } +} + +func TestAssembleEnvelope_ReactionsList_FullStructure(t *testing.T) { + method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") + env := AssembleEnvelope("im", []string{"reactions"}, "list", method) + + if env.Name != "im reactions list" { + t.Errorf("Name = %q, want \"im reactions list\"", env.Name) + } + if env.Description == "" { + t.Errorf("Description should not be empty for im.reactions.list") + } + if env.InputSchema == nil || env.OutputSchema == nil || env.Meta == nil { + t.Fatal("InputSchema/OutputSchema/Meta must all be non-nil") + } + if env.Meta.EnvelopeVersion != "1.0" { + t.Errorf("Meta.EnvelopeVersion = %q", env.Meta.EnvelopeVersion) + } +} + +func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) { + // im.chat.members.create — resource path is one element "chat.members" with + // an internal dot. Substituted from plan's `bots` because remote-cache + // overlay strips `bots` from the loaded method map on this environment; + // the assertion is about name joining, not method specifics. + method := loadMethodFromRegistry(t, "im", []string{"chat.members"}, "create") + env := AssembleEnvelope("im", []string{"chat.members"}, "create", method) + // chat.members resourcePath stays as one element in the slice with a dot; + // name should split it to "im chat.members create" — we keep the dot as-is + // inside the resource segment to round-trip with completion logic. + if env.Name != "im chat.members create" { + t.Errorf("Name = %q, want \"im chat.members create\"", env.Name) + } +} + +func TestAssembleEnvelope_JSONIsStable(t *testing.T) { + // Assemble twice; JSON output must be byte-identical (determinism). + method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") + a := AssembleEnvelope("im", []string{"reactions"}, "list", method) + b := AssembleEnvelope("im", []string{"reactions"}, "list", method) + ja, _ := json.MarshalIndent(a, "", " ") + jb, _ := json.MarshalIndent(b, "", " ") + if string(ja) != string(jb) { + t.Errorf("envelope assembly is non-deterministic:\nfirst:\n%s\nsecond:\n%s", ja, jb) + } +} + +func TestAssembleService_Im(t *testing.T) { + spec := registry.LoadFromMeta("im") + envs := AssembleService("im", spec, nil) + if len(envs) == 0 { + t.Fatal("expected non-empty envelopes for service im") + } + // Every envelope.Name starts with "im " + for _, e := range envs { + if !strings.HasPrefix(e.Name, "im ") { + t.Errorf("envelope name %q does not start with \"im \"", e.Name) + } + } + // Sorted by name + for i := 1; i < len(envs); i++ { + if envs[i-1].Name > envs[i].Name { + t.Errorf("envelopes not sorted by name at idx %d: %q > %q", i, envs[i-1].Name, envs[i].Name) + } + } +} + +func TestAssembleService_FilterByAccessToken(t *testing.T) { + spec := registry.LoadFromMeta("im") + // Filter to bot-only (--as bot, which corresponds to "tenant") + envs := AssembleService("im", spec, func(method map[string]interface{}) bool { + tokens, _ := method["accessTokens"].([]interface{}) + for _, t := range tokens { + if s, _ := t.(string); s == "tenant" { + return true + } + } + return false + }) + // Every envelope's _meta.access_tokens must contain "bot" + for _, e := range envs { + found := false + for _, t := range e.Meta.AccessTokens { + if t == "bot" { + found = true + break + } + } + if !found { + t.Errorf("envelope %q does not declare bot access", e.Name) + } + } +} + +func TestAssembleAll_AtLeast193(t *testing.T) { + envs := AssembleAll(nil) + // Envelope assembly is overlay-independent (Task 17b): AssembleAll walks the + // embedded meta_data.json directly, so the count is stable across machines. + if len(envs) < 193 { + t.Errorf("AssembleAll returned %d envelopes, expected >= 193", len(envs)) + } + // Spot check: im reactions list should be present + found := false + for _, e := range envs { + if e.Name == "im reactions list" { + found = true + break + } + } + if !found { + t.Errorf("im reactions list not found in AssembleAll output") + } +} + +// loadMethodFromRegistry is a test helper that pulls one method's spec from the +// real embedded meta_data.json via the registry package. +func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) map[string]interface{} { + t.Helper() + spec := registry.LoadFromMeta(service) + if spec == nil { + t.Fatalf("service %q not found in registry", service) + } + resources, _ := spec["resources"].(map[string]interface{}) + resKey := strings.Join(resourcePath, ".") + res, ok := resources[resKey].(map[string]interface{}) + if !ok { + t.Fatalf("resource %q.%s not found", service, resKey) + } + methods, _ := res["methods"].(map[string]interface{}) + m, ok := methods[methodName].(map[string]interface{}) + if !ok { + t.Fatalf("method %q.%s.%s not found", service, resKey, methodName) + } + return m +} diff --git a/internal/schema/lint.go b/internal/schema/lint.go new file mode 100644 index 000000000..2af3baef1 --- /dev/null +++ b/internal/schema/lint.go @@ -0,0 +1,233 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "errors" + "fmt" + + "github.com/larksuite/cli/internal/cmdutil" +) + +var validJSONSchemaTypes = map[string]bool{ + "string": true, + "integer": true, + "number": true, + "boolean": true, + "array": true, + "object": true, +} + +var validAccessTokens = map[string]bool{ + "user": true, + "bot": true, +} + +// lintEnvelope runs L1-L3 checks and returns a list of errors. Empty slice +// means the envelope is compliant. +func lintEnvelope(env Envelope) []error { + var errs []error + + // ---- L1: structural ---- + if env.Name == "" { + errs = append(errs, errors.New("L1: name must not be empty")) + } + if env.InputSchema == nil { + errs = append(errs, errors.New("L1: inputSchema must not be nil")) + } else { + if env.InputSchema.Type != "object" { + errs = append(errs, fmt.Errorf("L1: inputSchema.type = %q, want \"object\"", env.InputSchema.Type)) + } + if env.InputSchema.Properties == nil { + errs = append(errs, errors.New("L1: inputSchema.properties must not be nil")) + } + } + if env.OutputSchema == nil { + errs = append(errs, errors.New("L1: outputSchema must not be nil")) + } else { + if env.OutputSchema.Type != "object" { + errs = append(errs, fmt.Errorf("L1: outputSchema.type = %q, want \"object\"", env.OutputSchema.Type)) + } + } + if env.Meta == nil { + errs = append(errs, errors.New("L1: _meta must not be nil")) + // Cannot continue meta-dependent checks + return errs + } + if env.Meta.EnvelopeVersion != "1.0" { + errs = append(errs, fmt.Errorf("L1: _meta.envelope_version = %q, want \"1.0\"", env.Meta.EnvelopeVersion)) + } + + // L1: validate every Property type recursively + if env.InputSchema != nil && env.InputSchema.Properties != nil { + validatePropertyTypes(env.InputSchema.Properties, &errs) + } + if env.OutputSchema != nil && env.OutputSchema.Properties != nil { + validatePropertyTypes(env.OutputSchema.Properties, &errs) + } + + // ---- L2: type-level consistency ---- + if env.InputSchema != nil && env.InputSchema.Properties != nil { + // Walk the whole property tree so format/min-max checks reach leaf + // fields nested under the params/data wrapper. + walkForL2(env.InputSchema.Properties, &errs) + // Top-level required keys must exist in top-level properties. + for _, r := range env.InputSchema.Required { + if _, ok := env.InputSchema.Properties.Map[r]; !ok { + errs = append(errs, fmt.Errorf("L2: required key %q not found in properties", r)) + } + } + } + + // ---- L3: cross-field self-consistency ---- + dangerExpected := env.Meta.Risk == cmdutil.RiskWrite || env.Meta.Risk == cmdutil.RiskHighRiskWrite + if env.Meta.Danger != dangerExpected { + errs = append(errs, fmt.Errorf("L3: _meta.danger=%v inconsistent with risk=%q", env.Meta.Danger, env.Meta.Risk)) + } + + // `yes` lives at inputSchema.properties.yes (sibling of params/data), + // injected only for risk == RiskHighRiskWrite. + hasYes := false + if env.InputSchema != nil && env.InputSchema.Properties != nil { + _, hasYes = env.InputSchema.Properties.Map["yes"] + } + wantYes := env.Meta.Risk == cmdutil.RiskHighRiskWrite + if hasYes != wantYes { + errs = append(errs, fmt.Errorf("L3: inputSchema `yes` property=%v inconsistent with risk=%q", hasYes, env.Meta.Risk)) + } + + if len(env.Meta.AccessTokens) == 0 { + errs = append(errs, errors.New("L3: _meta.access_tokens must not be empty")) + } + for _, t := range env.Meta.AccessTokens { + if !validAccessTokens[t] { + errs = append(errs, fmt.Errorf("L3: _meta.access_tokens contains invalid value %q (allowed: user, bot)", t)) + } + } + + return errs +} + +// walkForL2 recursively applies per-field L2 checks (format:binary on +// non-string; minimum>=maximum) plus the sub-object required-exists invariant. +// Required only matters on object-typed Properties (e.g. the params / data +// wrappers); leaf scalars ignore it. +func walkForL2(props *OrderedProps, errs *[]error) { + if props == nil { + return + } + for _, k := range props.Order { + p := props.Map[k] + if p.Format == "binary" && p.Type != "string" { + *errs = append(*errs, fmt.Errorf("L2: field %q has format: binary but type = %q (want string)", k, p.Type)) + } + if p.Minimum != nil && p.Maximum != nil && *p.Minimum >= *p.Maximum { + *errs = append(*errs, fmt.Errorf("L2: field %q minimum (%v) >= maximum (%v)", k, *p.Minimum, *p.Maximum)) + } + if len(p.Required) > 0 && p.Properties != nil { + for _, r := range p.Required { + if _, ok := p.Properties.Map[r]; !ok { + *errs = append(*errs, fmt.Errorf("L2: required key %q in %q not found in its properties", r, k)) + } + } + } + if p.Properties != nil { + walkForL2(p.Properties, errs) + } + } +} + +// validatePropertyTypes walks an OrderedProps tree and asserts: +// - every Property.Type is in validJSONSchemaTypes (or empty for nested objects with only properties) +// - array Properties have Items +// +// Errors are appended to *errs. +func validatePropertyTypes(props *OrderedProps, errs *[]error) { + if props == nil { + return + } + for _, k := range props.Order { + p := props.Map[k] + if p.Type != "" && !validJSONSchemaTypes[p.Type] { + *errs = append(*errs, fmt.Errorf("L1: property %q has invalid type %q", k, p.Type)) + } + if p.Type == "array" && p.Items == nil { + *errs = append(*errs, fmt.Errorf("L1: array property %q missing items", k)) + } + if p.Properties != nil { + validatePropertyTypes(p.Properties, errs) + } + // Validate the array-element schema itself, not only its child + // properties — a primitive element with an invalid type (e.g. + // `items.type = "list"`) would otherwise slip past lint. + if p.Items != nil { + validateItemSchema(k, p.Items, errs) + } + } +} + +// validateItemSchema checks a single array element schema for invalid types, +// then recurses into any further nested properties/items. +func validateItemSchema(parentKey string, item *Property, errs *[]error) { + if item.Type != "" && !validJSONSchemaTypes[item.Type] { + *errs = append(*errs, fmt.Errorf("L1: array property %q items has invalid type %q", parentKey, item.Type)) + } + if item.Type == "array" && item.Items == nil { + *errs = append(*errs, fmt.Errorf("L1: array property %q items (nested array) missing items", parentKey)) + } + if item.Properties != nil { + validatePropertyTypes(item.Properties, errs) + } + if item.Items != nil { + validateItemSchema(parentKey, item.Items, errs) + } +} + +// coverageBaseline is the per-metric warn threshold for L4 coverage checks. +// If the measured rate drops below the baseline, t.Logf emits a warning but +// does NOT fail the test. Adjust these constants upward as meta_data quality +// improves over time. +var coverageBaseline = map[string]float64{ + "description": 0.99, + "scopes": 1.00, + "doc_url": 0.98, + "risk": 0.96, +} + +// measureCoverage returns the non-empty rate for each tracked metric. +func measureCoverage(envs []Envelope) map[string]float64 { + if len(envs) == 0 { + return map[string]float64{ + "description": 0, + "scopes": 0, + "doc_url": 0, + "risk": 0, + } + } + total := float64(len(envs)) + var descNonEmpty, scopesNonEmpty, docURLNonEmpty, riskNonEmpty float64 + for _, e := range envs { + if e.Description != "" { + descNonEmpty++ + } + if e.Meta == nil { + continue + } + if len(e.Meta.Scopes) > 0 { + scopesNonEmpty++ + } + if e.Meta.DocURL != "" { + docURLNonEmpty++ + } + if e.Meta.Risk != "" { + riskNonEmpty++ + } + } + return map[string]float64{ + "description": descNonEmpty / total, + "scopes": scopesNonEmpty / total, + "doc_url": docURLNonEmpty / total, + "risk": riskNonEmpty / total, + } +} diff --git a/internal/schema/lint_test.go b/internal/schema/lint_test.go new file mode 100644 index 000000000..265c4c775 --- /dev/null +++ b/internal/schema/lint_test.go @@ -0,0 +1,379 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/registry" +) + +// validEnvelope builds a baseline valid envelope used as a starting point in +// negative tests below. +func validEnvelope() Envelope { + props := &OrderedProps{Map: map[string]Property{}} + return Envelope{ + Name: "x y z", + Description: "ok", + InputSchema: &InputSchema{ + Type: "object", + Properties: props, + }, + OutputSchema: &OutputSchema{ + Type: "object", + Properties: &OrderedProps{Map: map[string]Property{}}, + }, + Meta: &Meta{ + EnvelopeVersion: "1.0", + AccessTokens: []string{"user"}, + Risk: "read", + Danger: false, + }, + } +} + +func TestLintEnvelope_Valid(t *testing.T) { + env := validEnvelope() + errs := lintEnvelope(env) + if len(errs) != 0 { + t.Errorf("expected no errors, got: %v", errs) + } +} + +func TestLintEnvelope_L1_StructuralChecks(t *testing.T) { + tests := []struct { + name string + mutate func(*Envelope) + wantSub string + }{ + { + name: "empty name", + mutate: func(e *Envelope) { e.Name = "" }, + wantSub: "name", + }, + { + name: "nil InputSchema", + mutate: func(e *Envelope) { e.InputSchema = nil }, + wantSub: "inputSchema", + }, + { + name: "inputSchema type not object", + mutate: func(e *Envelope) { e.InputSchema.Type = "string" }, + wantSub: "inputSchema.type", + }, + { + name: "nil OutputSchema", + mutate: func(e *Envelope) { e.OutputSchema = nil }, + wantSub: "outputSchema", + }, + { + name: "nil Meta", + mutate: func(e *Envelope) { e.Meta = nil }, + wantSub: "_meta", + }, + { + name: "wrong envelope version", + mutate: func(e *Envelope) { e.Meta.EnvelopeVersion = "0.9" }, + wantSub: "envelope_version", + }, + { + name: "invalid property type", + mutate: func(e *Envelope) { + e.InputSchema.Properties.Order = []string{"x"} + e.InputSchema.Properties.Map["x"] = Property{Type: "unknown_type"} + }, + wantSub: "invalid type", + }, + { + name: "array missing items", + mutate: func(e *Envelope) { + e.InputSchema.Properties.Order = []string{"x"} + e.InputSchema.Properties.Map["x"] = Property{Type: "array"} // no Items + }, + wantSub: "items", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := validEnvelope() + tt.mutate(&env) + errs := lintEnvelope(env) + if len(errs) == 0 { + t.Fatalf("expected lint error, got none") + } + found := false + for _, e := range errs { + if strings.Contains(e.Error(), tt.wantSub) { + found = true + break + } + } + if !found { + t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs) + } + }) + } +} + +func TestLintEnvelope_L2_TypeChecks(t *testing.T) { + tests := []struct { + name string + mutate func(*Envelope) + wantSub string + }{ + { + name: "format binary on non-string", + mutate: func(e *Envelope) { + e.InputSchema.Properties.Order = []string{"f"} + e.InputSchema.Properties.Map["f"] = Property{Type: "integer", Format: "binary"} + }, + wantSub: "format: binary", + }, + { + name: "required key not in properties", + mutate: func(e *Envelope) { + e.InputSchema.Required = []string{"nonexistent"} + }, + wantSub: "required", + }, + { + name: "minimum >= maximum", + mutate: func(e *Envelope) { + min, max := 50.0, 10.0 + e.InputSchema.Properties.Order = []string{"n"} + e.InputSchema.Properties.Map["n"] = Property{Type: "integer", Minimum: &min, Maximum: &max} + }, + wantSub: "minimum", + }, + { + // Regression guard: walkForL2 must recurse into the params/data + // sub-objects introduced by the 4-bucket inputSchema, not only the + // top-level Properties map. + name: "format binary on non-string inside params sub-object", + mutate: func(e *Envelope) { + e.InputSchema.Properties.Order = []string{"params"} + e.InputSchema.Properties.Map["params"] = Property{ + Type: "object", + Properties: &OrderedProps{ + Order: []string{"id"}, + Map: map[string]Property{ + "id": {Type: "integer", Format: "binary"}, // wrong: binary on integer + }, + }, + } + }, + wantSub: "format: binary", + }, + { + name: "sub-object required references missing property", + mutate: func(e *Envelope) { + e.InputSchema.Properties.Order = []string{"data"} + e.InputSchema.Properties.Map["data"] = Property{ + Type: "object", + Required: []string{"ghost"}, // not in properties below + Properties: &OrderedProps{ + Order: []string{"real"}, + Map: map[string]Property{"real": {Type: "string"}}, + }, + } + }, + wantSub: "ghost", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := validEnvelope() + tt.mutate(&env) + errs := lintEnvelope(env) + if len(errs) == 0 { + t.Fatalf("expected lint error, got none") + } + found := false + for _, e := range errs { + if strings.Contains(e.Error(), tt.wantSub) { + found = true + break + } + } + if !found { + t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs) + } + }) + } +} + +func TestLintEnvelope_L3_CrossFieldChecks(t *testing.T) { + tests := []struct { + name string + mutate func(*Envelope) + wantSub string + }{ + { + name: "danger true but risk read", + mutate: func(e *Envelope) { + e.Meta.Danger = true + e.Meta.Risk = "read" + }, + wantSub: "danger", + }, + { + name: "high-risk-write without yes", + mutate: func(e *Envelope) { + e.Meta.Risk = "high-risk-write" + e.Meta.Danger = true + // no yes injection + }, + wantSub: "yes", + }, + { + name: "yes injected but risk not high-risk-write", + mutate: func(e *Envelope) { + e.InputSchema.Properties.Order = []string{"yes"} + e.InputSchema.Properties.Map["yes"] = Property{Type: "boolean"} + }, + wantSub: "yes", + }, + { + name: "empty access_tokens", + mutate: func(e *Envelope) { + e.Meta.AccessTokens = []string{} + }, + wantSub: "access_tokens", + }, + { + name: "invalid access_token value", + mutate: func(e *Envelope) { + e.Meta.AccessTokens = []string{"admin"} + }, + wantSub: "access_tokens", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := validEnvelope() + tt.mutate(&env) + errs := lintEnvelope(env) + if len(errs) == 0 { + t.Fatalf("expected lint error, got none") + } + found := false + for _, e := range errs { + if strings.Contains(e.Error(), tt.wantSub) { + found = true + break + } + } + if !found { + t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs) + } + }) + } +} + +func TestMeasureCoverage_Counts(t *testing.T) { + envs := []Envelope{ + {Description: "ok", Meta: &Meta{Scopes: []string{"s"}, Risk: "read", DocURL: "http://x"}}, + {Description: "", Meta: &Meta{Scopes: []string{}, Risk: "", DocURL: ""}}, + {Description: "ok2", Meta: &Meta{Scopes: []string{"s"}, Risk: "write", DocURL: "http://y"}}, + } + c := measureCoverage(envs) + // 2/3 have non-empty description = ~0.667 + if c["description"] < 0.66 || c["description"] > 0.67 { + t.Errorf("description coverage = %v, want ~0.667", c["description"]) + } + // 2/3 have non-empty scopes + if c["scopes"] < 0.66 || c["scopes"] > 0.67 { + t.Errorf("scopes coverage = %v, want ~0.667", c["scopes"]) + } + // 2/3 have doc_url + if c["doc_url"] < 0.66 || c["doc_url"] > 0.67 { + t.Errorf("doc_url coverage = %v, want ~0.667", c["doc_url"]) + } + // 2/3 have non-empty risk (but our builder always fills risk with "read" default — this test uses raw envs) + if c["risk"] < 0.66 || c["risk"] > 0.67 { + t.Errorf("risk coverage = %v, want ~0.667", c["risk"]) + } +} + +// isKnownDataInconsistency returns true for lint errors that originate from +// real meta_data quality issues we still have to ship around in PR-1. With +// Task 17b the assembler walks embedded data only, so overlay-induced +// inconsistencies (risk-stripping) no longer appear; only the true embedded +// meta_data data-quality patterns remain. +// +// As meta_data quality improves this filter should be tightened/removed so +// TestAllEnvelopesPass becomes a hard gate again. +func isKnownDataInconsistency(msg string) bool { + switch { + case strings.Contains(msg, `L3: _meta.danger=false inconsistent with risk="write"`): + // Embedded meta_data has ~7 envelopes (e.g. attendance.user_tasks.query, + // drive.user.subscription, mail.user_mailbox.event.subscribe) where + // `risk="write"` but `danger` is missing (defaults to false). Needs a + // meta_data fix to set danger=true on these write methods. + return true + case strings.Contains(msg, `L3: _meta.danger=true inconsistent with risk="read"`): + // Embedded meta_data has ~9 envelopes (e.g. calendar.events.search_event, + // drive.metas.batch_query, mail.user_mailbox.templates.create) where + // `danger=true` but `risk` is missing (defaults to "read"). Needs a + // meta_data fix to set the proper risk level on these methods. + return true + case strings.Contains(msg, "L2: field") && strings.Contains(msg, "minimum") && strings.Contains(msg, "maximum"): + // meta_data sets min == max on some fields (e.g. + // mail.user_mailbox.event.subscribe.event_type), which the lint reads + // as min >= max. Real fix is in meta_data. + return true + } + return false +} + +func TestAllEnvelopesPass(t *testing.T) { + failCount := 0 + knownWarnings := 0 + knownEnvelopes := map[string]bool{} + // Use embedded data only so the gate is deterministic across machines + // (matches Task 17b: envelope assembly is overlay-independent). + for _, svc := range registry.EmbeddedServiceNames() { + spec := registry.EmbeddedSpec(svc) + envs := AssembleService(svc, spec, nil) + for _, env := range envs { + errs := lintEnvelope(env) + if len(errs) == 0 { + continue + } + var realErrs []error + for _, e := range errs { + if isKnownDataInconsistency(e.Error()) { + t.Logf("env %s skipped: known data-level inconsistency: %v", env.Name, e) + knownWarnings++ + knownEnvelopes[env.Name] = true + continue + } + realErrs = append(realErrs, e) + } + if len(realErrs) > 0 { + for _, e := range realErrs { + t.Errorf("%s: %v", env.Name, e) + } + failCount++ + } + } + } + t.Logf("L1-L3 known data-level inconsistencies: %d warnings across %d envelopes (danger/risk mismatch + min==max)", knownWarnings, len(knownEnvelopes)) + if failCount > 0 { + t.Fatalf("%d envelopes failed L1-L3 lint with non-data-level errors", failCount) + } + + // L4 coverage report (warn-only via t.Logf) + all := AssembleAll(nil) + c := measureCoverage(all) + for metric, rate := range c { + baseline := coverageBaseline[metric] + if rate < baseline { + t.Logf("L4 coverage warn: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100) + } else { + t.Logf("L4 coverage ok: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100) + } + } +} diff --git a/internal/schema/path.go b/internal/schema/path.go new file mode 100644 index 000000000..a29b34136 --- /dev/null +++ b/internal/schema/path.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import "strings" + +// ParsePath normalizes the positional arguments of `lark-cli schema` into a +// slice of path segments. It accepts two equivalent forms: +// +// lark-cli schema im.messages.reply -> single arg, split on "." +// lark-cli schema im messages reply -> multiple args, used as-is +// lark-cli schema "im chat.members bots" is NOT a supported form; quote +// arguments individually if your shell needs it. Nested resources keep their +// internal dots (e.g. "chat.members"). +// +// Returns nil for zero args (bare invocation). +func ParsePath(args []string) []string { + switch len(args) { + case 0: + return nil + case 1: + if strings.Contains(args[0], ".") { + return strings.Split(args[0], ".") + } + return []string{args[0]} + default: + return args + } +} diff --git a/internal/schema/path_test.go b/internal/schema/path_test.go new file mode 100644 index 000000000..ec8934450 --- /dev/null +++ b/internal/schema/path_test.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "reflect" + "testing" +) + +func TestParsePath(t *testing.T) { + tests := []struct { + name string + args []string + want []string + }{ + {"empty args -> nil", nil, nil}, + {"empty slice -> nil", []string{}, nil}, + {"single dotted", []string{"im.messages.reply"}, []string{"im", "messages", "reply"}}, + {"single no-dot", []string{"im"}, []string{"im"}}, + {"multi args", []string{"im", "messages", "reply"}, []string{"im", "messages", "reply"}}, + {"two args", []string{"im", "messages"}, []string{"im", "messages"}}, + {"nested resource dotted", []string{"im.chat.members.bots"}, []string{"im", "chat", "members", "bots"}}, + {"nested resource space form", []string{"im", "chat.members", "bots"}, []string{"im", "chat.members", "bots"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParsePath(tt.args) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParsePath(%v) = %v, want %v", tt.args, got, tt.want) + } + }) + } +} diff --git a/internal/schema/types.go b/internal/schema/types.go new file mode 100644 index 000000000..c8b1232c8 --- /dev/null +++ b/internal/schema/types.go @@ -0,0 +1,164 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" +) + +// Envelope is the MCP Tool spec contract for a single API method command. +type Envelope struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema *InputSchema `json:"inputSchema"` + OutputSchema *OutputSchema `json:"outputSchema"` + Meta *Meta `json:"_meta"` +} + +// InputSchema is JSON Schema Draft 2020-12 flattened. +// +// Required is intentionally rendered (no omitempty) so the envelope shape +// stays stable for AI consumers — an empty []string means "no required +// fields" rather than "schema is missing the field". +type InputSchema struct { + Type string `json:"type"` + Required []string `json:"required"` + Properties *OrderedProps `json:"properties"` +} + +// OutputSchema wraps responseBody into a JSON Schema object. +type OutputSchema struct { + Type string `json:"type"` + Properties *OrderedProps `json:"properties"` +} + +// Property is one field's JSON Schema shape, recursive. +// +// Required is used when Property describes a nested object (e.g. the +// "params" / "data" sub-objects inside inputSchema): it lists which keys +// inside that object's Properties are mandatory. Leaf fields ignore it. +type Property struct { + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Enum []interface{} `json:"enum,omitempty"` + Default interface{} `json:"default,omitempty"` + Example interface{} `json:"example,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Format string `json:"format,omitempty"` + Required []string `json:"required,omitempty"` + Properties *OrderedProps `json:"properties,omitempty"` + Items *Property `json:"items,omitempty"` +} + +// Meta is the Lark-specific extension namespace. +type Meta struct { + EnvelopeVersion string `json:"envelope_version"` + Scopes []string `json:"scopes"` + RequiredScopes []string `json:"required_scopes"` + AccessTokens []string `json:"access_tokens"` + Danger bool `json:"danger"` + Risk string `json:"risk"` + DocURL string `json:"doc_url,omitempty"` + Affordance *Affordance `json:"affordance,omitempty"` +} + +// Affordance is the hand-written overlay (PR-1 only defines the type, no YAML loaded). +type Affordance struct { + UseWhen []string `json:"use_when,omitempty"` + DoNotUseWhen []string `json:"do_not_use_when,omitempty"` + Prerequisites []string `json:"prerequisites,omitempty"` + Examples []AffordanceCase `json:"examples,omitempty"` + Related []string `json:"related,omitempty"` +} + +// AffordanceCase is one example entry: a one-line description plus a +// ready-to-run lark-cli command string. +type AffordanceCase struct { + Description string `json:"description"` + Command string `json:"command"` +} + +// OrderedProps is map[string]Property with preserved key order on MarshalJSON. +// It is used wherever JSON output must reflect meta_data.json's natural field +// order rather than Go's default alphabetical map encoding. +type OrderedProps struct { + Order []string + Map map[string]Property +} + +// MarshalJSON emits keys in Order, not alphabetical. If Order is empty but +// Map has entries, fall back to alphabetical key order over Map so callers +// that only populated Map (no explicit ordering) still see their fields. +func (o *OrderedProps) MarshalJSON() ([]byte, error) { + if o == nil || (len(o.Order) == 0 && len(o.Map) == 0) { + return []byte("{}"), nil + } + keys := o.Order + if len(keys) == 0 { + keys = make([]string, 0, len(o.Map)) + for k := range o.Map { + keys = append(keys, k) + } + sort.Strings(keys) + } + var buf bytes.Buffer + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + keyJSON, err := json.Marshal(k) + if err != nil { + return nil, fmt.Errorf("marshal key %q: %w", k, err) + } + buf.Write(keyJSON) + buf.WriteByte(':') + valJSON, err := json.Marshal(o.Map[k]) + if err != nil { + return nil, fmt.Errorf("marshal value for %q: %w", k, err) + } + buf.Write(valJSON) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +// UnmarshalJSON parses an object preserving key order via json.Decoder.Token(). +// Used for round-tripping in tests (and future golden update flows). +func (o *OrderedProps) UnmarshalJSON(data []byte) error { + dec := json.NewDecoder(bytes.NewReader(data)) + tok, err := dec.Token() + if err != nil { + return err + } + if delim, ok := tok.(json.Delim); !ok || delim != '{' { + return fmt.Errorf("expected object, got %v", tok) + } + o.Order = nil + o.Map = make(map[string]Property) + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return err + } + key, ok := keyTok.(string) + if !ok { + return fmt.Errorf("expected string key, got %v", keyTok) + } + var prop Property + if err := dec.Decode(&prop); err != nil { + return err + } + o.Order = append(o.Order, key) + o.Map[key] = prop + } + if _, err := dec.Token(); err != nil { + return err + } + return nil +} diff --git a/internal/schema/types_test.go b/internal/schema/types_test.go new file mode 100644 index 000000000..ab1ae6c4e --- /dev/null +++ b/internal/schema/types_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "encoding/json" + "testing" +) + +// OrderedProps 在测试里验证:MarshalJSON 按 Order 切片顺序输出 key,跳过 Go map 默认字母序。 +func TestOrderedProps_MarshalJSON_PreservesOrder(t *testing.T) { + op := &OrderedProps{ + Order: []string{"z_first", "a_second", "m_third"}, + Map: map[string]Property{ + "z_first": {Type: "string"}, + "a_second": {Type: "integer"}, + "m_third": {Type: "boolean"}, + }, + } + b, err := json.Marshal(op) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + got := string(b) + want := `{"z_first":{"type":"string"},"a_second":{"type":"integer"},"m_third":{"type":"boolean"}}` + if got != want { + t.Errorf("OrderedProps key order not preserved:\ngot: %s\nwant: %s", got, want) + } +} + +func TestOrderedProps_MarshalJSON_Empty(t *testing.T) { + op := &OrderedProps{Order: nil, Map: nil} + b, err := json.Marshal(op) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + if string(b) != "{}" { + t.Errorf("empty OrderedProps should marshal to {}, got: %s", b) + } +} + +func TestOrderedProps_UnmarshalJSON_RoundTrip(t *testing.T) { + in := []byte(`{"first":{"type":"string"},"second":{"type":"integer"}}`) + var op OrderedProps + if err := json.Unmarshal(in, &op); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if len(op.Order) != 2 { + t.Fatalf("expected 2 keys, got %d", len(op.Order)) + } + if op.Order[0] != "first" || op.Order[1] != "second" { + t.Errorf("unmarshal lost order: got %v", op.Order) + } + if op.Map["first"].Type != "string" { + t.Errorf("first.type mismatch") + } +} diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go index d9cb5ab97..8cba0b7d3 100644 --- a/internal/selfupdate/updater.go +++ b/internal/selfupdate/updater.go @@ -78,12 +78,12 @@ func (r *NpmResult) CombinedOutput() string { // Platform-specific methods (PrepareSelfReplace, CleanupStaleFiles) // are in updater_unix.go and updater_windows.go. // -// Override DetectOverride / NpmInstallOverride / SkillsUpdateOverride / VerifyOverride +// Override DetectOverride / NpmInstallOverride / SkillsCommandOverride / VerifyOverride // / RestoreAvailableOverride for testing. type Updater struct { DetectOverride func() DetectResult NpmInstallOverride func(version string) *NpmResult - SkillsUpdateOverride func() *NpmResult + SkillsCommandOverride func(args ...string) *NpmResult VerifyOverride func(expectedVersion string) error RestoreAvailableOverride func() bool @@ -153,12 +153,27 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult { return r } -// RunSkillsUpdate installs skills, trying the .well-known source first and -// falling back to the GitHub repo on failure or timeout. -func (u *Updater) RunSkillsUpdate() *NpmResult { - if u.SkillsUpdateOverride != nil { - return u.SkillsUpdateOverride() +func (u *Updater) ListOfficialSkills() *NpmResult { + r := u.runSkillsListOfficial("https://open.feishu.cn") + if r.Err != nil { + r = u.runSkillsListOfficial("larksuite/cli") } + return r +} + +func (u *Updater) ListGlobalSkills() *NpmResult { + return u.runSkillsListGlobal() +} + +func (u *Updater) InstallSkill(nameList []string) *NpmResult { + r := u.runSkillsInstall("https://open.feishu.cn", nameList) + if r.Err != nil { + r = u.runSkillsInstall("larksuite/cli", nameList) + } + return r +} + +func (u *Updater) InstallAllSkills() *NpmResult { r := u.runSkillsAdd("https://open.feishu.cn") if r.Err != nil { r = u.runSkillsAdd("larksuite/cli") @@ -167,6 +182,28 @@ func (u *Updater) RunSkillsUpdate() *NpmResult { } func (u *Updater) runSkillsAdd(source string) *NpmResult { + return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y") +} + +func (u *Updater) runSkillsListOfficial(source string) *NpmResult { + return u.runSkillsCommand("-y", "skills", "add", source, "--list") +} + +func (u *Updater) runSkillsListGlobal() *NpmResult { + return u.runSkillsCommand("-y", "skills", "ls", "-g") +} + +func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult { + args := []string{"-y", "skills", "add", source, "-s"} + args = append(args, nameList...) + args = append(args, "-g", "-y") + return u.runSkillsCommand(args...) +} + +func (u *Updater) runSkillsCommand(args ...string) *NpmResult { + if u.SkillsCommandOverride != nil { + return u.SkillsCommandOverride(args...) + } r := &NpmResult{} npxPath, err := exec.LookPath("npx") if err != nil { @@ -175,7 +212,7 @@ func (u *Updater) runSkillsAdd(source string) *NpmResult { } ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout) defer cancel() - cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y") + cmd := exec.CommandContext(ctx, npxPath, args...) cmd.Stdout = &r.Stdout cmd.Stderr = &r.Stderr r.Err = cmd.Run() diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go index f13c80b65..458f3f79b 100644 --- a/internal/selfupdate/updater_test.go +++ b/internal/selfupdate/updater_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/larksuite/cli/internal/vfs" @@ -166,3 +167,87 @@ func TestVerifyBinaryEmptyOutput(t *testing.T) { t.Fatal("VerifyBinary(empty output) expected error, got nil") } } + +func TestSkillsCommandsUseExpectedArgs(t *testing.T) { + tests := []struct { + name string + run func(*Updater) *NpmResult + want string + }{ + { + name: "list official primary", + run: func(u *Updater) *NpmResult { + return u.runSkillsListOfficial("https://open.feishu.cn") + }, + want: "-y skills add https://open.feishu.cn --list", + }, + { + name: "list global", + run: func(u *Updater) *NpmResult { + return u.runSkillsListGlobal() + }, + want: "-y skills ls -g", + }, + { + name: "install skill primary", + run: func(u *Updater) *NpmResult { + return u.runSkillsInstall("https://open.feishu.cn", []string{"lark-mail"}) + }, + want: "-y skills add https://open.feishu.cn -s lark-mail -g -y", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell script") + } + dir := t.TempDir() + script := filepath.Join(dir, "npx") + logPath := filepath.Join(dir, "npx.log") + if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \""+logPath+"\"\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + result := tt.run(New()) + if result.Err != nil { + t.Fatalf("command err = %v, want nil", result.Err) + } + raw, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(raw)) != tt.want { + t.Fatalf("args = %q, want %q", strings.TrimSpace(string(raw)), tt.want) + } + }) + } +} + +func TestListOfficialSkillsFallsBack(t *testing.T) { + called := []string{} + updater := &Updater{ + SkillsCommandOverride: func(args ...string) *NpmResult { + called = append(called, strings.Join(args, " ")) + r := &NpmResult{} + if strings.Contains(strings.Join(args, " "), "https://open.feishu.cn") { + r.Err = fmt.Errorf("primary failed") + return r + } + r.Stdout.WriteString("lark-calendar\n") + return r + }, + } + + result := updater.ListOfficialSkills() + if result.Err != nil { + t.Fatalf("ListOfficialSkills() err = %v, want nil", result.Err) + } + if len(called) != 2 { + t.Fatalf("called %d commands, want 2: %#v", len(called), called) + } + if !strings.Contains(called[1], "larksuite/cli --list") { + t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1]) + } +} diff --git a/internal/skillscheck/check.go b/internal/skillscheck/check.go index 429117a18..029a4d01f 100644 --- a/internal/skillscheck/check.go +++ b/internal/skillscheck/check.go @@ -3,46 +3,29 @@ package skillscheck -// Init runs the synchronous skills version check. Stores a StaleNotice -// when the local stamp records a version that does not match -// currentVersion. Safe to call from cmd/root.go before rootCmd.Execute(); -// zero network, zero subprocess — only a local stamp file read. +import "strings" + +// Init runs the synchronous skills version check. Stores a StaleNotice when +// the local skills state records a version that does not match currentVersion. +// Safe to call from cmd/root.go before rootCmd.Execute(); zero network, zero +// subprocess — only a local state file read. // // Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver, // LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out). -// -// Failure modes (all → no notice, no nag): -// - shouldSkip rule met -// - ReadStamp returns an I/O error other than ENOENT -// - Stamp matches currentVersion (in-sync) -// - Stamp is missing (cold start) — only users who ran `lark-cli update` -// opt into drift tracking; npx-only installs are intentionally silent. func Init(currentVersion string) { - // Clear any stale notice from a prior call so early returns below - // (skip rules / read errors / cold start / in-sync) leave pending == nil - // instead of preserving a stale value from a previous Init invocation. SetPending(nil) if shouldSkip(currentVersion) { return } - stamp, err := ReadStamp() - if err != nil { - // Fail closed — don't nag for a transient FS problem. + version, ok := ReadSyncedVersion() + if !ok { return } - if stamp == "" { - // Cold start: the stamp is written exclusively by `lark-cli update` - // (runSkillsAndStamp). Users who installed skills via - // `npx skills add larksuite/cli -g` have no stamp yet — they must - // not be nagged with "skills not installed", since the on-disk - // skills directory may already be fully populated. - return - } - if stamp == currentVersion { + if strings.TrimPrefix(strings.TrimPrefix(version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") { return } SetPending(&StaleNotice{ - Current: stamp, // guaranteed non-empty under the new contract + Current: version, Target: currentVersion, }) } diff --git a/internal/skillscheck/check_test.go b/internal/skillscheck/check_test.go index 64525bc5a..2674d5424 100644 --- a/internal/skillscheck/check_test.go +++ b/internal/skillscheck/check_test.go @@ -18,9 +18,8 @@ func resetPending(t *testing.T) { func TestInit_InSync_NoNotice(t *testing.T) { clearSkillsSkipEnv(t) resetPending(t) - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := WriteStamp("1.0.21"); err != nil { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil { t.Fatal(err) } Init("1.0.21") @@ -39,12 +38,24 @@ func TestInit_ColdStart_NoNotice(t *testing.T) { } } -func TestInit_Drift_NoticeWithStampVersion(t *testing.T) { +func TestInit_NormalizedVersion_NoNotice(t *testing.T) { clearSkillsSkipEnv(t) resetPending(t) - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := WriteStamp("1.0.20"); err != nil { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil { + t.Fatal(err) + } + Init("v1.0.21") + if got := GetPending(); got != nil { + t.Errorf("GetPending() = %+v, want nil (normalized versions are in-sync)", got) + } +} + +func TestInit_Drift_NoticeWithStateVersion(t *testing.T) { + clearSkillsSkipEnv(t) + resetPending(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := WriteState(SkillsState{Version: "1.0.20"}); err != nil { t.Fatal(err) } Init("1.0.21") @@ -61,22 +72,18 @@ func TestInit_Skipped_NoNotice(t *testing.T) { clearSkillsSkipEnv(t) resetPending(t) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - // Even with an empty config dir (no stamp), DEV version should skip - // the check entirely and never emit a notice. Init("DEV") if got := GetPending(); got != nil { t.Errorf("GetPending() = %+v, want nil (skip rules met)", got) } } -func TestInit_ReadStampError_FailsClosed(t *testing.T) { +func TestInit_ReadStateError_FailsClosed(t *testing.T) { clearSkillsSkipEnv(t) resetPending(t) dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - // Make the stamp path a directory so vfs.ReadFile returns a - // non-ENOENT I/O error. - if err := os.MkdirAll(filepath.Join(dir, "skills.stamp"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(dir, "skills-state.json"), 0o755); err != nil { t.Fatal(err) } Init("1.0.21") diff --git a/internal/skillscheck/notice.go b/internal/skillscheck/notice.go index b1f972218..c1425fbb7 100644 --- a/internal/skillscheck/notice.go +++ b/internal/skillscheck/notice.go @@ -3,9 +3,8 @@ // Package skillscheck verifies that the locally installed lark-cli // skills are in sync with the running binary version, by comparing -// the current binary version against a stamp file written when skills -// are last synced (by `lark-cli update`). On mismatch it stores a -// notice for injection into JSON envelopes via output.PendingNotice. +// the current binary version against skills-state.json. On mismatch it +// stores a notice for injection into JSON envelopes via output.PendingNotice. package skillscheck import ( @@ -26,8 +25,7 @@ type StaleNotice struct { // Message returns a single-line, AI-agent-parseable description of the // drift plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message // in style ("..., run: lark-cli update" suffix). Current is guaranteed -// non-empty because Init only emits a StaleNotice for the drift case -// (stamp present and != binary version). +// non-empty because Init only emits a StaleNotice for the drift case. func (s *StaleNotice) Message() string { return fmt.Sprintf( "lark-cli skills %s out of sync with binary %s, run: lark-cli update", diff --git a/internal/skillscheck/stamp.go b/internal/skillscheck/stamp.go deleted file mode 100644 index 052e331c9..000000000 --- a/internal/skillscheck/stamp.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package skillscheck - -import ( - "errors" - "io/fs" - "path/filepath" - "strings" - - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" -) - -const stampFile = "skills.stamp" - -// stampPath returns ~/.lark-cli/skills.stamp. -// Uses the BASE config dir (not workspace-aware) because skills install -// globally via `npx -g`; per-workspace tracking would produce false -// drift signals when switching workspaces. -func stampPath() string { - return filepath.Join(core.GetBaseConfigDir(), stampFile) -} - -// ReadStamp returns the version recorded in the stamp file. Returns -// ("", nil) when the file does not exist (interpreted as "never synced"). -// Other I/O errors are returned as-is so callers can fail closed. -func ReadStamp() (string, error) { - data, err := vfs.ReadFile(stampPath()) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return "", nil - } - return "", err - } - return strings.TrimSpace(string(data)), nil -} - -// WriteStamp records `version` as the last successfully synced skills -// version. Atomic via tmp + rename (validate.AtomicWrite). Creates -// the base config directory if it does not exist. -func WriteStamp(version string) error { - if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil { - return err - } - return validate.AtomicWrite(stampPath(), []byte(version), 0o644) -} diff --git a/internal/skillscheck/stamp_test.go b/internal/skillscheck/stamp_test.go deleted file mode 100644 index 8e60dfbb4..000000000 --- a/internal/skillscheck/stamp_test.go +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package skillscheck - -import ( - "os" - "path/filepath" - "testing" -) - -func TestReadStamp_Missing(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - got, err := ReadStamp() - if err != nil { - t.Fatalf("ReadStamp() err = %v, want nil for ENOENT", err) - } - if got != "" { - t.Errorf("ReadStamp() = %q, want \"\" for missing file", got) - } -} - -func TestReadStamp_Normal(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21"), 0o644); err != nil { - t.Fatal(err) - } - got, err := ReadStamp() - if err != nil || got != "1.0.21" { - t.Errorf("ReadStamp() = (%q, %v), want (\"1.0.21\", nil)", got, err) - } -} - -func TestReadStamp_TrailingNewlineTolerated(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21\n"), 0o644); err != nil { - t.Fatal(err) - } - got, _ := ReadStamp() - if got != "1.0.21" { - t.Errorf("ReadStamp() = %q, want \"1.0.21\" (newline trimmed)", got) - } -} - -func TestReadStamp_EmptyFile(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte(""), 0o644); err != nil { - t.Fatal(err) - } - got, err := ReadStamp() - if err != nil || got != "" { - t.Errorf("ReadStamp() = (%q, %v), want (\"\", nil)", got, err) - } -} - -func TestWriteStamp_CreatesDir(t *testing.T) { - dir := filepath.Join(t.TempDir(), "nested") - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := WriteStamp("1.0.21"); err != nil { - t.Fatalf("WriteStamp() = %v, want nil", err) - } - got, _ := os.ReadFile(filepath.Join(dir, "skills.stamp")) - if string(got) != "1.0.21" { - t.Errorf("file content = %q, want \"1.0.21\"", string(got)) - } -} - -func TestWriteStamp_OverwritesExisting(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := WriteStamp("1.0.20"); err != nil { - t.Fatal(err) - } - if err := WriteStamp("1.0.21"); err != nil { - t.Fatal(err) - } - got, _ := ReadStamp() - if got != "1.0.21" { - t.Errorf("ReadStamp() after overwrite = %q, want \"1.0.21\"", got) - } -} - -func TestWriteStamp_NoTrailingNewline(t *testing.T) { - dir := t.TempDir() - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) - if err := WriteStamp("1.0.21"); err != nil { - t.Fatal(err) - } - raw, _ := os.ReadFile(filepath.Join(dir, "skills.stamp")) - if string(raw) != "1.0.21" { - t.Errorf("raw file = %q, want exactly \"1.0.21\" (no newline)", string(raw)) - } -} - -// TestWriteStamp_MkdirAllFailure verifies WriteStamp returns the mkdir error -// when the base config dir cannot be created (parent path is a regular file). -func TestWriteStamp_MkdirAllFailure(t *testing.T) { - tmp := t.TempDir() - blocker := filepath.Join(tmp, "blocker") - // Create a regular file where MkdirAll wants to create a directory. - if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil { - t.Fatal(err) - } - // Point the config dir at a path UNDER the regular file — MkdirAll must fail. - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(blocker, "child")) - - if err := WriteStamp("1.0.21"); err == nil { - t.Fatal("WriteStamp() = nil, want non-nil error from MkdirAll failure") - } -} diff --git a/internal/skillscheck/state.go b/internal/skillscheck/state.go new file mode 100644 index 000000000..eddab1cf3 --- /dev/null +++ b/internal/skillscheck/state.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package skillscheck + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "path/filepath" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" +) + +const ( + stateFile = "skills-state.json" +) + +var ErrUnreadableState = errors.New("skills state is unreadable") + +type SkillsState struct { + Version string `json:"version"` + OfficialSkills []string `json:"official_skills"` + UpdatedSkills []string `json:"updated_skills"` + AddedOfficialSkills []string `json:"added_official_skills"` + SkippedDeletedSkills []string `json:"skipped_deleted_skills"` + UpdatedAt string `json:"updated_at"` +} + +func statePath() string { + return filepath.Join(core.GetBaseConfigDir(), stateFile) +} + +func ReadState() (*SkillsState, bool, error) { + data, err := vfs.ReadFile(statePath()) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + return nil, false, err + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return nil, false, fmt.Errorf("%w: %v", ErrUnreadableState, err) + } + + var state SkillsState + if err := json.Unmarshal(data, &state); err != nil { + return nil, false, fmt.Errorf("%w: %v", ErrUnreadableState, err) + } + return &state, true, nil +} + +func WriteState(state SkillsState) error { + state.ensureNonNilSlices() + + if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return validate.AtomicWrite(statePath(), append(data, '\n'), 0o644) +} + +func ReadSyncedVersion() (string, bool) { + state, ok, err := ReadState() + if err != nil || !ok || state.Version == "" { + return "", false + } + return state.Version, true +} + +func (s *SkillsState) ensureNonNilSlices() { + if s.OfficialSkills == nil { + s.OfficialSkills = []string{} + } + if s.UpdatedSkills == nil { + s.UpdatedSkills = []string{} + } + if s.AddedOfficialSkills == nil { + s.AddedOfficialSkills = []string{} + } + if s.SkippedDeletedSkills == nil { + s.SkippedDeletedSkills = []string{} + } +} diff --git a/internal/skillscheck/state_test.go b/internal/skillscheck/state_test.go new file mode 100644 index 000000000..d69b74635 --- /dev/null +++ b/internal/skillscheck/state_test.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package skillscheck + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestReadState_Missing(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + state, ok, err := ReadState() + if err != nil { + t.Fatalf("ReadState() err = %v, want nil for missing file", err) + } + if ok { + t.Fatal("ReadState() ok = true, want false for missing file") + } + if state != nil { + t.Fatalf("ReadState() state = %#v, want nil for missing file", state) + } +} + +func TestReadState_Valid(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + want := SkillsState{ + Version: "1.2.3", + OfficialSkills: []string{"lark-doc", "lark-im"}, + UpdatedSkills: []string{"lark-doc"}, + AddedOfficialSkills: []string{"lark-task"}, + SkippedDeletedSkills: []string{"custom-skill"}, + UpdatedAt: "2026-05-18T10:00:00Z", + } + data, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, stateFile), data, 0o644); err != nil { + t.Fatal(err) + } + + got, ok, err := ReadState() + if err != nil { + t.Fatalf("ReadState() err = %v, want nil", err) + } + if !ok { + t.Fatal("ReadState() ok = false, want true") + } + if got == nil { + t.Fatal("ReadState() state = nil, want state") + } + if !reflect.DeepEqual(*got, want) { + t.Fatalf("ReadState() state = %#v, want %#v", *got, want) + } +} + +func TestReadState_CorruptStateUnreadable(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, stateFile), []byte(`{"version":`), 0o644); err != nil { + t.Fatal(err) + } + + state, ok, err := ReadState() + if !errors.Is(err, ErrUnreadableState) { + t.Fatalf("ReadState() err = %v, want ErrUnreadableState", err) + } + if ok { + t.Fatal("ReadState() ok = true, want false") + } + if state != nil { + t.Fatalf("ReadState() state = %#v, want nil", state) + } +} + +func TestWriteState_CreatesDirAndWritesState(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested") + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + + state := SkillsState{ + Version: "1.2.3", + UpdatedAt: "2026-05-18T10:00:00Z", + } + if err := WriteState(state); err != nil { + t.Fatalf("WriteState() err = %v, want nil", err) + } + + raw, err := os.ReadFile(filepath.Join(dir, stateFile)) + if err != nil { + t.Fatal(err) + } + var got SkillsState + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("written state is invalid JSON: %v", err) + } + if got.Version != state.Version { + t.Fatalf("version = %q, want %q", got.Version, state.Version) + } + if got.OfficialSkills == nil { + t.Fatal("official_skills decoded as nil, want empty slice") + } + if got.UpdatedSkills == nil { + t.Fatal("updated_skills decoded as nil, want empty slice") + } + if got.AddedOfficialSkills == nil { + t.Fatal("added_skills decoded as nil, want empty slice") + } + if got.SkippedDeletedSkills == nil { + t.Fatal("skipped_deleted_skills decoded as nil, want empty slice") + } +} + +func TestReadSyncedVersionFromState(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + + if got, ok := ReadSyncedVersion(); ok || got != "" { + t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for missing state", got, ok) + } + if err := WriteState(SkillsState{Version: "1.2.3"}); err != nil { + t.Fatal(err) + } + if got, ok := ReadSyncedVersion(); !ok || got != "1.2.3" { + t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"1.2.3\", true)", got, ok) + } + if err := WriteState(SkillsState{}); err != nil { + t.Fatal(err) + } + if got, ok := ReadSyncedVersion(); ok || got != "" { + t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for empty version", got, ok) + } +} diff --git a/internal/skillscheck/sync.go b/internal/skillscheck/sync.go new file mode 100644 index 000000000..3239131b4 --- /dev/null +++ b/internal/skillscheck/sync.go @@ -0,0 +1,403 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package skillscheck + +import ( + "fmt" + "regexp" + "sort" + "strings" + "time" + + "github.com/larksuite/cli/internal/selfupdate" +) + +var ( + skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`) + ansiPattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`) +) + +type SyncInput struct { + Version string + OfficialSkills []string + LocalSkills []string + PreviousState *SkillsState + StateReadable bool + Force bool +} + +type SyncPlan struct { + Version string + OfficialSkills []string + ToUpdate []string + Added []string + SkippedDeleted []string +} + +func stripANSI(s string) string { + return ansiPattern.ReplaceAllString(s, "") +} + +func ParseSkillsList(text string) []string { + text = stripANSI(text) + lines := strings.Split(text, "\n") + + // Detect format type + hasGlobalSkills := strings.Contains(text, "Global Skills") + hasAvailableSkills := strings.Contains(text, "Available Skills") + + if hasGlobalSkills { + // Format 1: locally installed skills list from "npx -y skills ls -g" + return parseGlobalSkillsList(lines) + } else if hasAvailableSkills { + // Format 2: official skills list from "npx -y skills add ... --list" + return parseOfficialSkillsList(lines) + } + return nil +} + +// parseGlobalSkillsList parses the output of "npx -y skills ls -g" +func parseGlobalSkillsList(lines []string) []string { + seen := map[string]bool{} + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Skip header + if strings.HasPrefix(trimmed, "Global Skills") { + continue + } + + // Skip empty lines + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "Tip:") { + continue + } + + // Skip indented lines (Agents: ...) + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + continue + } + + // Extract skill name, format is typically "skill-name /path/to/skill" + parts := strings.Fields(trimmed) + if len(parts) == 0 { + continue + } + + candidate := parts[0] + + // Validate and add + if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") { + continue + } + if !skillNamePattern.MatchString(candidate) { + continue + } + if at := strings.Index(candidate, "@"); at > 0 { + candidate = candidate[:at] + } + seen[candidate] = true + } + + return sortedKeys(seen) +} + +// parseOfficialSkillsList parses the output of "npx -y skills add ... --list" +func parseOfficialSkillsList(lines []string) []string { + seen := map[string]bool{} + inAvailableSection := false + + for _, line := range lines { + // Check if we've reached the "Available Skills" section + if strings.Contains(line, "Available Skills") { + inAvailableSection = true + continue + } + + if !inAvailableSection { + continue + } + + // Process lines containing "│", e.g. " │ lark-approval " + if strings.Contains(line, "│") { + // Remove all "│" characters and spaces, extract the first valid token in order + parts := strings.FieldsFunc(line, func(r rune) bool { + return r == '│' || r == ' ' + }) + + if len(parts) > 0 { + candidate := parts[0] + // Check if it's a valid official skill name + if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) { + seen[candidate] = true + } + } + } + } + + return sortedKeys(seen) +} + +func PlanSync(input SyncInput) SyncPlan { + official := uniqueSorted(input.OfficialSkills) + if input.Force { + return SyncPlan{ + Version: input.Version, + OfficialSkills: official, + ToUpdate: official, + Added: []string{}, + SkippedDeleted: []string{}, + } + } + + officialSet := toSet(official) + installedOfficial := intersection(input.LocalSkills, officialSet) + + previousOfficial := []string{} + if input.StateReadable && input.PreviousState != nil { + previousOfficial = input.PreviousState.OfficialSkills + } + previousSet := toSet(previousOfficial) + + newAddedOfficial := []string{} + for _, skill := range official { + if !previousSet[skill] { + newAddedOfficial = append(newAddedOfficial, skill) + } + } + + updateSet := toSet(installedOfficial) + for _, skill := range newAddedOfficial { + updateSet[skill] = true + } + toUpdate := sortedKeys(updateSet) + updateSet = toSet(toUpdate) + + skipped := []string{} + for _, skill := range official { + if !updateSet[skill] { + skipped = append(skipped, skill) + } + } + + return SyncPlan{ + Version: input.Version, + OfficialSkills: official, + ToUpdate: toUpdate, + Added: uniqueSorted(newAddedOfficial), + SkippedDeleted: skipped, + } +} + +type SkillsRunner interface { + ListOfficialSkills() *selfupdate.NpmResult + ListGlobalSkills() *selfupdate.NpmResult + InstallSkill(nameList []string) *selfupdate.NpmResult + InstallAllSkills() *selfupdate.NpmResult +} + +type SyncOptions struct { + Version string + Force bool + Runner SkillsRunner + Now func() time.Time +} + +type SyncResult struct { + Action string + Official []string + Updated []string + Added []string + SkippedDeleted []string + Failed []string + Err error + Detail string + Force bool +} + +func SyncSkills(opts SyncOptions) *SyncResult { + if opts.Now == nil { + opts.Now = time.Now + } + if opts.Runner == nil { + return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")} + } + + // --- Step 1: List official skills --- + officialResult := opts.Runner.ListOfficialSkills() + if officialResult == nil || officialResult.Err != nil { + return fallbackFullInstall(opts, resultDetail(officialResult), nil) + } + official := ParseSkillsList(officialResult.Stdout.String()) + + if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" { + return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil) + } + + // --- Step 2: List local (installed) skills --- + local := []string{} + localResult := opts.Runner.ListGlobalSkills() + if localResult != nil && localResult.Err == nil { + local = ParseSkillsList(localResult.Stdout.String()) + } + + // --- Step 3: Read previous state --- + previous, readable, err := ReadState() + if err != nil { + readable = false + previous = nil + } + + plan := PlanSync(SyncInput{ + Version: opts.Version, + OfficialSkills: official, + LocalSkills: local, + PreviousState: previous, + StateReadable: readable, + Force: opts.Force, + }) + + result := &SyncResult{ + Action: "synced", + Official: plan.OfficialSkills, + Updated: plan.ToUpdate, + Added: plan.Added, + SkippedDeleted: plan.SkippedDeleted, + Force: opts.Force, + } + + if len(plan.ToUpdate) == 0 { + return fallbackFullInstall(opts, "toUpdate skills empty fallback", official) + } + + if len(plan.ToUpdate) > 0 { + installResult := opts.Runner.InstallSkill(plan.ToUpdate) + if installResult == nil || installResult.Err != nil { + return fallbackFullInstall(opts, resultDetail(installResult), official) + } + } + + state := SkillsState{ + Version: opts.Version, + OfficialSkills: plan.OfficialSkills, + UpdatedSkills: plan.ToUpdate, + AddedOfficialSkills: plan.Added, + SkippedDeletedSkills: plan.SkippedDeleted, + UpdatedAt: opts.Now().UTC().Format(time.RFC3339), + } + if err := WriteState(state); err != nil { + result.Action = "failed" + result.Err = fmt.Errorf("skills synced but state not written: %w", err) + return result + } + + return result +} + +// fallbackFullInstall performs a full skills install (npx -y skills add -g -y) +// when incremental sync is not possible. On success it writes a state file so that +// subsequent syncs can use incremental mode. When official is non-nil the state +// records the full official list; otherwise a minimal state (version only) is +// written to break the fallback loop. +func fallbackFullInstall(opts SyncOptions, reason string, official []string) *SyncResult { + installResult := opts.Runner.InstallAllSkills() + if installResult == nil { + return &SyncResult{ + Action: "fallback_failed", + Err: fmt.Errorf("full skills install failed: empty result (reason: %s)", reason), + Detail: reason, + Force: opts.Force, + } + } + if installResult.Err != nil { + return &SyncResult{ + Action: "fallback_failed", + Err: fmt.Errorf("full skills install failed: %w (reason: %s)", installResult.Err, reason), + Detail: reason + "\n" + resultDetail(installResult), + Force: opts.Force, + } + } + + state := SkillsState{ + Version: opts.Version, + OfficialSkills: official, + UpdatedSkills: official, + AddedOfficialSkills: official, + SkippedDeletedSkills: []string{}, + UpdatedAt: opts.Now().UTC().Format(time.RFC3339), + } + if writeErr := WriteState(state); writeErr != nil { + return &SyncResult{ + Action: "fallback_synced", + Official: official, + Updated: official, + Added: official, + SkippedDeleted: []string{}, + Detail: reason + "\nstate write failed: " + writeErr.Error(), + Force: opts.Force, + } + } + + return &SyncResult{ + Action: "fallback_synced", + Official: official, + Updated: official, + Added: official, + SkippedDeleted: []string{}, + Detail: reason, + Force: opts.Force, + } +} + +func resultDetail(result *selfupdate.NpmResult) string { + if result == nil { + return "" + } + parts := []string{} + if output := strings.TrimSpace(result.CombinedOutput()); output != "" { + parts = append(parts, output) + } + if result.Err != nil { + parts = append(parts, result.Err.Error()) + } + return strings.Join(parts, "\n") +} + +func uniqueSorted(values []string) []string { + return sortedKeys(toSet(values)) +} + +func toSet(values []string) map[string]bool { + out := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + out[value] = true + } + } + return out +} + +// result = { x | x ∈ values ∧ x ∈ allowed } +func intersection(values []string, allowed map[string]bool) []string { + out := map[string]bool{} + for _, value := range values { + if allowed[value] { + out[value] = true + } + } + return sortedKeys(out) +} + +func sortedKeys(values map[string]bool) []string { + out := make([]string, 0, len(values)) + for value := range values { + out = append(out, value) + } + sort.Strings(out) + return out +} diff --git a/internal/skillscheck/sync_test.go b/internal/skillscheck/sync_test.go new file mode 100644 index 000000000..059bc656f --- /dev/null +++ b/internal/skillscheck/sync_test.go @@ -0,0 +1,550 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package skillscheck + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/selfupdate" +) + +func TestParseSkillsListIgnoresUnsupportedFormat(t *testing.T) { + input := `Installed skills: +- lark-calendar +- lark-mail +lark-im +custom-skill +lark-base@1.0.0 +lark-cli-harness:dev@0.1.0 +` + got := ParseSkillsList(input) + if len(got) != 0 { + t.Fatalf("ParseSkillsList() = %#v, want empty result for unsupported format", got) + } +} + +func TestParseGlobalSkillsList(t *testing.T) { + input := `Global Skills + +lark-approval ~/.agents/skills/lark-approval + Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more +lark-attendance ~/.agents/skills/lark-attendance + Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more +lark-base ~/.agents/skills/lark-base + Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more +lark-calendar ~/.agents/skills/lark-calendar + Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more +dogfood ~/.hermes/skills/dogfood + Agents: Hermes Agent +yuanbao ~/.hermes/skills/yuanbao + Agents: Hermes Agent +` + got := ParseSkillsList(input) + want := []string{"dogfood", "lark-approval", "lark-attendance", "lark-base", "lark-calendar", "yuanbao"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ParseSkillsList() (Global Skills) = %#v, want %#v", got, want) + } +} + +func TestParseGlobalSkillsListWithANSI(t *testing.T) { + input := "\x1b[1mGlobal Skills\x1b[0m\n\n" + + "\x1b[36mlark-calendar\x1b[0m \x1b[38;5;102m~/.agents/skills/lark-calendar\x1b[0m\n" + + " \x1b[38;5;102mAgents:\x1b[0m TRAE CN, TRAE +3 more\n" + + "\x1b[36mdogfood\x1b[0m \x1b[38;5;102m~/.hermes/skills/dogfood\x1b[0m\n" + + " \x1b[38;5;102mAgents:\x1b[0m Hermes Agent\n" + + "\nTip: Use the -y flag to run in non-interactive mode (for CI and AI agents).\n" + got := ParseSkillsList(input) + want := []string{"dogfood", "lark-calendar"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ParseSkillsList() (ANSI Global Skills) = %#v, want %#v", got, want) + } +} + +func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) { + previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}} + got := PlanSync(SyncInput{ + Version: "1.0.33", + OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"}, + LocalSkills: []string{"lark-calendar", "lark-custom"}, + PreviousState: previous, + StateReadable: true, + Force: false, + }) + + assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-new"}) + assertStrings(t, got.Added, []string{"lark-new"}) + assertStrings(t, got.SkippedDeleted, []string{"lark-mail"}) +} + +func TestPlanNormal_MissingStateInstallsAllOfficial(t *testing.T) { + got := PlanSync(SyncInput{ + Version: "1.0.33", + OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"}, + LocalSkills: []string{"lark-calendar"}, + StateReadable: false, + Force: false, + }) + + assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"}) + assertStrings(t, got.Added, []string{"lark-calendar", "lark-mail", "lark-new"}) + assertStrings(t, got.SkippedDeleted, []string{}) +} + +func TestPlanForceRestoresAllOfficial(t *testing.T) { + got := PlanSync(SyncInput{ + Version: "1.0.33", + OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"}, + LocalSkills: []string{"lark-calendar"}, + PreviousState: &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}, + StateReadable: true, + Force: true, + }) + + assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"}) + assertStrings(t, got.Added, []string{}) + assertStrings(t, got.SkippedDeleted, []string{}) +} + +type fakeSkillsRunner struct { + officialOut string + globalOut string + officialErr error + globalErr error + installErr error + installAllErr error + installed [][]string + installedAll int +} + +func officialSkillsOutput(names ...string) string { + var b strings.Builder + b.WriteString("Available Skills\n") + for _, name := range names { + b.WriteString("│ ") + b.WriteString(name) + b.WriteString("\n") + } + return b.String() +} + +func globalSkillsOutput(names ...string) string { + var b strings.Builder + b.WriteString("Global Skills\n\n") + for _, name := range names { + b.WriteString(name) + b.WriteString(" ~/.agents/skills/") + b.WriteString(name) + b.WriteString("\n Agents: Claude Code\n") + } + return b.String() +} + +func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + r.Stdout.WriteString(f.officialOut) + r.Err = f.officialErr + return r +} + +func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + r.Stdout.WriteString(f.globalOut) + r.Err = f.globalErr + return r +} + +func (f *fakeSkillsRunner) InstallSkill(nameList []string) *selfupdate.NpmResult { + f.installed = append(f.installed, nameList) + r := &selfupdate.NpmResult{} + r.Err = f.installErr + return r +} + +func (f *fakeSkillsRunner) InstallAllSkills() *selfupdate.NpmResult { + f.installedAll++ + r := &selfupdate.NpmResult{} + r.Err = f.installAllErr + return r +} + +func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + if err := WriteState(SkillsState{ + Version: "1.0.30", + OfficialSkills: []string{"lark-calendar", "lark-mail"}, + UpdatedAt: "2026-05-18T00:00:00Z", + }); err != nil { + t.Fatal(err) + } + + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"), + globalOut: globalSkillsOutput("lark-calendar", "lark-custom"), + } + result := SyncSkills(SyncOptions{ + Version: "1.0.33", + Runner: runner, + Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) }, + }) + + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v, want nil", result.Err) + } + assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"}) + + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail", "lark-new"}) + assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-new"}) + assertStrings(t, state.AddedOfficialSkills, []string{"lark-new"}) + assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"}) + if _, err := os.Stat(filepath.Join(dir, "skills.stamp")); !os.IsNotExist(err) { + t.Fatalf("skills.stamp exists or stat failed with unexpected err: %v", err) + } +} + +func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialErr: fmt.Errorf("list failed"), + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + if runner.installedAll != 1 { + t.Fatalf("installedAll = %d, want 1", runner.installedAll) + } + if len(runner.installed) != 0 { + t.Fatalf("installed = %#v, want no incremental installs", runner.installed) + } + + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.33" { + t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33") + } + assertStrings(t, state.OfficialSkills, []string{}) +} + +func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialErr: fmt.Errorf("list failed"), + installAllErr: fmt.Errorf("full install failed"), + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_failed" { + t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action) + } + if result.Err == nil { + t.Fatalf("SyncSkills() err = nil, want error") + } + if !strings.Contains(result.Err.Error(), "full skills install failed") { + t.Fatalf("SyncSkills() err = %v, want full install failure", result.Err) + } +} + +func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalErr: fmt.Errorf("global list failed"), + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err) + } + if result.Action != "synced" { + t.Fatalf("SyncSkills() action = %q, want synced", result.Action) + } + assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, result.SkippedDeleted, []string{}) +} + +func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalOut: "Some unrecognized output format\n", + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err) + } + if result.Action != "synced" { + t.Fatalf("SyncSkills() action = %q, want synced", result.Action) + } + assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, result.SkippedDeleted, []string{}) + if runner.installedAll != 0 { + t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll) + } + if len(runner.installed) != 1 { + t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed)) + } +} + +func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + if err := WriteState(SkillsState{ + Version: "1.0.30", + OfficialSkills: []string{"lark-calendar", "lark-mail"}, + UpdatedAt: "2026-05-18T00:00:00Z", + }); err != nil { + t.Fatal(err) + } + + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput(), + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + if len(runner.installed) != 0 { + t.Fatalf("installed = %#v, want no incremental installs", runner.installed) + } + if runner.installedAll != 1 { + t.Fatalf("installedAll = %d, want 1 (fallback triggered)", runner.installedAll) + } + assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, result.SkippedDeleted, []string{}) +} + +func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + installErr: fmt.Errorf("incremental boom"), + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + if len(runner.installed) != 1 { + t.Fatalf("installed = %d calls, want 1", len(runner.installed)) + } + if runner.installedAll != 1 { + t.Fatalf("installedAll = %d, want 1 (fallback triggered)", runner.installedAll) + } + + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.33" { + t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33") + } + assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail"}) +} + +func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + installErr: fmt.Errorf("incremental boom"), + installAllErr: fmt.Errorf("full install boom"), + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_failed" { + t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action) + } + if result.Err == nil { + t.Fatalf("SyncSkills() err = nil, want error") + } + if !strings.Contains(result.Detail, "incremental boom") { + t.Fatalf("SyncSkills() detail = %q, want incremental error text", result.Detail) + } + if !strings.Contains(result.Err.Error(), "full skills install failed") { + t.Fatalf("SyncSkills() err = %v, want full install failure", result.Err) + } +} + +func TestSyncSkills_NilRunnerFails(t *testing.T) { + result := SyncSkills(SyncOptions{Version: "1.0.33", Now: time.Now}) + if result.Err == nil || !strings.Contains(result.Err.Error(), "skills runner is nil") { + t.Fatalf("SyncSkills() err = %v, want nil runner failure", result.Err) + } +} + +func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: "Some unrecognized output format\n", + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + if runner.installedAll != 1 { + t.Fatalf("installedAll = %d, want 1", runner.installedAll) + } +} + +func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: "Some unrecognized output format\n", + installAllErr: fmt.Errorf("full install failed"), + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_failed" { + t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action) + } + if result.Err == nil { + t.Fatalf("SyncSkills() err = nil, want error") + } +} + +func assertStrings(t *testing.T, got, want []string) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } +} + +func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: "Some unrecognized output format\n", + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.33" { + t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33") + } + assertStrings(t, state.OfficialSkills, []string{}) + assertStrings(t, state.UpdatedSkills, []string{}) + assertStrings(t, state.AddedOfficialSkills, []string{}) +} + +func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + installErr: fmt.Errorf("incremental boom"), + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, state.AddedOfficialSkills, []string{"lark-calendar", "lark-mail"}) +} + +func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + installErr: fmt.Errorf("incremental boom"), + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, result.SkippedDeleted, []string{}) + if !strings.Contains(result.Detail, "incremental boom") { + t.Fatalf("SyncSkills() detail = %q, want incremental error text", result.Detail) + } +} + +func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialErr: fmt.Errorf("list failed"), + installAllErr: nil, + } + + result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result1.Action != "fallback_synced" { + t.Fatalf("first sync: action = %q, want fallback_synced", result1.Action) + } + + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() after first sync = (_, %v, %v), want readable", readable, err) + } + if state.Version != "1.0.33" { + t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33") + } + + runner2 := &fakeSkillsRunner{ + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + } + result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now}) + if result2.Action != "synced" { + t.Fatalf("second sync: action = %q, want synced (no fallback loop)", result2.Action) + } + if runner2.installedAll != 0 { + t.Fatalf("second sync: installedAll = %d, want 0 (incremental, not fallback)", runner2.installedAll) + } +} diff --git a/internal/transport/config.go b/internal/transport/config.go new file mode 100644 index 000000000..009e5f2c8 --- /dev/null +++ b/internal/transport/config.go @@ -0,0 +1,243 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package transport owns how the CLI assembles its outbound HTTP transport: the +// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY +// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode. +// +// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback +// proxy, optionally trusting an extra root CA PEM bundle for TLS-inspection +// proxies, and fails closed on misconfiguration. Environment variables override +// matching values from proxy_config.json. +package transport + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "github.com/larksuite/cli/internal/binding" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/vfs" +) + +// ConfigFileName is the fixed config file name under core.GetConfigDir(). +const ( + ConfigFileName = "proxy_config.json" +) + +// Config is the on-disk config format. Keys intentionally mirror env var names. +type Config struct { + // Enable turns on proxy plugin transport handling. + Enable bool `json:"LARKSUITE_CLI_PROXY_ENABLE"` + + // Proxy is the fixed HTTP proxy address used for all outbound requests. + Proxy string `json:"LARKSUITE_CLI_PROXY_ADDRESS"` + + // CAPath points to an extra PEM bundle trusted for proxy TLS interception. + CAPath string `json:"LARKSUITE_CLI_CA_PATH"` +} + +// Path returns the absolute path to the proxy plugin config file. +func Path() string { + return filepath.Join(core.GetConfigDir(), ConfigFileName) +} + +// loadOnce guards one-time proxy config loading for process-wide transport reuse. +var loadOnce sync.Once + +// loadCfg stores the cached proxy config after the first successful Load call. +var loadCfg *Config + +// loadErr stores the cached Load error observed during the first load attempt. +var loadErr error + +// Load reads ~/.lark-cli/proxy_config.json once and caches the parsed result. +// Environment variables (CliProxyEnable/CliProxyAddress/CliCAPath) take precedence over config file values. +// +// Returns (nil, nil) only when: +// - the config file does not exist AND +// - none of the proxy-related env vars are present. +func Load() (*Config, error) { + loadOnce.Do(func() { + // Start from env-only config if any proxy env var is present. + cfg, hasEnv, err := loadFromEnv() + if err != nil { + loadErr = err + return + } + + p := Path() + if _, err := vfs.Stat(p); err != nil { + if errors.Is(err, os.ErrNotExist) { + // No file: return env-only config (if any), else nil. + if hasEnv { + loadCfg = cfg + } else { + loadCfg = nil + } + loadErr = nil + return + } + loadErr = fmt.Errorf("failed to stat proxy plugin config %q: %w", p, err) + return + } + // Security hardening: this config dictates where ALL outbound CLI traffic + // egresses and which extra CA is trusted, so a file another local user or + // process can tamper with (symlink, foreign owner, group/world-writable) + // could redirect credential traffic. Audit it the same way the CA file is. + safePath, err := binding.AssertSecurePath(binding.AuditParams{ + TargetPath: p, + Label: ConfigFileName, + AllowReadableByOthers: true, // config is not a secret; only writability/owner/symlink matter + }) + if err != nil { + loadErr = fmt.Errorf("unsafe proxy plugin config %q: %w", p, err) + return + } + b, err := vfs.ReadFile(safePath) + if err != nil { + loadErr = fmt.Errorf("failed to read proxy plugin config %q: %w", p, err) + return + } + var fileCfg Config + if err := json.Unmarshal(b, &fileCfg); err != nil { + loadErr = fmt.Errorf("invalid proxy plugin config %q: %w", p, err) + return + } + + // Merge: file base + env overrides. + if cfg == nil { + cfg = &fileCfg + } else { + *cfg = fileCfg + applyEnvOverrides(cfg) + } + loadCfg = cfg + }) + return loadCfg, loadErr +} + +// Enabled reports whether proxy plugin mode is enabled. +func (c *Config) Enabled() bool { return c != nil && c.Enable } + +// loadFromEnv builds a config from proxy-related environment variables only. +// It reports whether any proxy-related environment variable was present. +func loadFromEnv() (*Config, bool, error) { + _, hasEnable := os.LookupEnv(envvars.CliProxyEnable) + _, hasProxy := os.LookupEnv(envvars.CliProxyAddress) + _, hasCA := os.LookupEnv(envvars.CliCAPath) + hasAny := hasEnable || hasProxy || hasCA + if !hasAny { + return nil, false, nil + } + cfg := &Config{} + if err := applyEnvOverrides(cfg); err != nil { + return nil, true, err + } + return cfg, true, nil +} + +// applyEnvOverrides copies proxy-related environment variable values into cfg. +func applyEnvOverrides(cfg *Config) error { + if v, ok := os.LookupEnv(envvars.CliProxyEnable); ok { + b, err := parseBoolEnv(envvars.CliProxyEnable, v) + if err != nil { + return err + } + cfg.Enable = b + } + if v, ok := os.LookupEnv(envvars.CliProxyAddress); ok { + cfg.Proxy = v + } + if v, ok := os.LookupEnv(envvars.CliCAPath); ok { + cfg.CAPath = v + } + return nil +} + +// parseBoolEnv accepts common boolean spellings used in environment variables. +func parseBoolEnv(name, raw string) (bool, error) { + s := strings.TrimSpace(strings.ToLower(raw)) + if s == "" { + // Treat empty as false when explicitly present. + return false, nil + } + switch s { + case "1", "true", "on", "yes", "y": + return true, nil + case "0", "false", "off", "no", "n": + return false, nil + } + if b, err := strconv.ParseBool(s); err == nil { + return b, nil + } + return false, fmt.Errorf("invalid %s %q (want true/false/1/0)", name, raw) +} + +// proxyURL validates the fixed configured proxy configuration and returns its URL. +func (c *Config) proxyURL() (*url.URL, error) { + raw := strings.TrimSpace(c.Proxy) + if raw == "" { + return nil, fmt.Errorf("%s is empty", envvars.CliProxyAddress) + } + redacted := redactProxyURL(raw) + u, err := url.Parse(raw) + if err != nil { + // Do not wrap the raw url.Parse error: its string embeds the original + // URL, which can contain userinfo (user:password). Return a redacted, + // generic message instead. + return nil, fmt.Errorf("invalid %s %q: malformed URL", envvars.CliProxyAddress, redacted) + } + if u.Scheme != "http" { + return nil, fmt.Errorf("invalid %s %q: scheme must be http", envvars.CliProxyAddress, redacted) + } + if u.Host == "" { + return nil, fmt.Errorf("invalid %s %q: missing host", envvars.CliProxyAddress, redacted) + } + // Security hardening: only allow a loopback proxy. This prevents accidental + // cross-machine proxying of credentials/traffic. + if u.Hostname() != "127.0.0.1" { + return nil, fmt.Errorf("invalid %s %q: host must be 127.0.0.1", envvars.CliProxyAddress, redacted) + } + if u.Port() == "" { + return nil, fmt.Errorf("invalid %s %q: explicit port is required", envvars.CliProxyAddress, redacted) + } + if u.Path != "" { + return nil, fmt.Errorf("invalid %s %q: path is not allowed", envvars.CliProxyAddress, redacted) + } + if u.RawQuery != "" { + return nil, fmt.Errorf("invalid %s %q: query is not allowed", envvars.CliProxyAddress, redacted) + } + if u.Fragment != "" { + return nil, fmt.Errorf("invalid %s %q: fragment is not allowed", envvars.CliProxyAddress, redacted) + } + return u, nil +} + +// ApplyToTransport clones base and applies proxy plugin settings to the clone. +// Caller owns the returned *http.Transport. +func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) { + if base == nil { + base = http.DefaultTransport.(*http.Transport) + } + u, err := c.proxyURL() + if err != nil { + return nil, err + } + + t := base.Clone() + t.Proxy = http.ProxyURL(u) // fixed proxy overrides environment proxy vars + if err := applyExtraRootCA(t, c.CAPath); err != nil { + return nil, err + } + return t, nil +} diff --git a/internal/transport/config_test.go b/internal/transport/config_test.go new file mode 100644 index 000000000..24159950a --- /dev/null +++ b/internal/transport/config_test.go @@ -0,0 +1,372 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package transport + +import ( + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + "github.com/larksuite/cli/internal/envvars" +) + +// unsetEnv clears key for the duration of the test and restores its original value. +func unsetEnv(t *testing.T, key string) { + t.Helper() + old, had := os.LookupEnv(key) + _ = os.Unsetenv(key) + t.Cleanup(func() { + if had { + _ = os.Setenv(key, old) + } else { + _ = os.Unsetenv(key) + } + }) +} + +// unsetProxyPluginEnv clears proxy-related environment variables for deterministic tests. +func unsetProxyPluginEnv(t *testing.T) { + t.Helper() + unsetEnv(t, envvars.CliProxyEnable) + unsetEnv(t, envvars.CliProxyAddress) + unsetEnv(t, envvars.CliCAPath) +} + +// writeFile creates parent directories and writes test data for fixtures. +func writeFile(t *testing.T, path string, data []byte, perm os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, data, perm); err != nil { + t.Fatalf("WriteFile: %v", err) + } +} + +// TestLoad_MissingFileReturnsNil verifies that Load reports no config when no file +// or proxy environment overrides exist. +func TestLoad_MissingFileReturnsNil(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + unsetProxyPluginEnv(t) + // TestLoad_MissingFileReturnsNil must reset loadOnce, loadCfg, and loadErr + // because multiple tests in this package share the package-level Load() + // cache via sync.Once. + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg != nil { + t.Fatalf("Load() = %#v, want nil (missing file)", cfg) + } +} + +// TestApplyToTransport_SetsProxy verifies that a valid proxy config installs a fixed proxy. +func TestApplyToTransport_SetsProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + unsetProxyPluginEnv(t) + + cfgPath := Path() + writeFile(t, cfgPath, []byte(`{ + "LARKSUITE_CLI_PROXY_ENABLE": true, + "LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128", + "LARKSUITE_CLI_CA_PATH": "" +}`), 0600) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg == nil || !cfg.Enabled() { + t.Fatalf("cfg.Enabled() = %v, want true", cfg) + } + + base := http.DefaultTransport.(*http.Transport) + tr, err := cfg.ApplyToTransport(base) + if err != nil { + t.Fatalf("ApplyToTransport() error = %v", err) + } + if tr.Proxy == nil { + t.Fatal("Proxy func is nil, want fixed proxy") + } + u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err != nil { + t.Fatalf("Proxy() error = %v", err) + } + if u == nil || u.String() != "http://127.0.0.1:3128" { + t.Fatalf("Proxy() = %v, want http://127.0.0.1:3128", u) + } +} + +// TestLoad_RejectsNonLoopbackProxy verifies that proxy mode rejects non-loopback proxies. +func TestLoad_RejectsNonLoopbackProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + unsetProxyPluginEnv(t) + + cfgPath := Path() + writeFile(t, cfgPath, []byte(`{ + "LARKSUITE_CLI_PROXY_ENABLE": true, + "LARKSUITE_CLI_PROXY_ADDRESS": "http://10.0.0.1:3128", + "LARKSUITE_CLI_CA_PATH": "" +}`), 0600) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg == nil || !cfg.Enabled() { + t.Fatalf("cfg.Enabled() = %v, want true", cfg) + } + _, err = cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport)) + if err == nil { + t.Fatal("ApplyToTransport() error = nil, want invalid proxy host error") + } +} + +// TestConfig_ProxyURLRejectsUnsupportedParts verifies the configured proxy validator +// rejects URLs with missing ports, paths, queries, and fragments. +func TestConfig_ProxyURLRejectsUnsupportedParts(t *testing.T) { + cases := []struct { + name string + raw string + want string + }{ + { + name: "missing explicit port", + raw: "http://127.0.0.1", + want: "explicit port is required", + }, + { + name: "trailing slash path", + raw: "http://127.0.0.1:3128/", + want: "path is not allowed", + }, + { + name: "query string", + raw: "http://127.0.0.1:3128?foo=bar", + want: "query is not allowed", + }, + { + name: "fragment", + raw: "http://127.0.0.1:3128#frag", + want: "fragment is not allowed", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + _, err := (&Config{Proxy: tt.raw}).proxyURL() + if err == nil { + t.Fatalf("proxyURL() error = nil, want substring %q", tt.want) + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("proxyURL() error = %q, want substring %q", err, tt.want) + } + }) + } +} + +// TestLoad_EnvOnlyConfig verifies that proxy settings can come entirely from environment variables. +func TestLoad_EnvOnlyConfig(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + + t.Setenv(envvars.CliProxyEnable, "true") + t.Setenv(envvars.CliProxyAddress, "http://127.0.0.1:7777") + t.Setenv(envvars.CliCAPath, "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg == nil || !cfg.Enabled() { + t.Fatalf("cfg.Enabled() = %v, want true", cfg) + } + tr, err := cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport)) + if err != nil { + t.Fatalf("ApplyToTransport() error = %v", err) + } + u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err != nil { + t.Fatalf("Proxy() error = %v", err) + } + if u == nil || u.String() != "http://127.0.0.1:7777" { + t.Fatalf("Proxy() = %v, want http://127.0.0.1:7777", u) + } +} + +// TestLoad_EnvOverridesFile verifies that proxy environment variables override file values. +func TestLoad_EnvOverridesFile(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + + // File enables with one proxy. + cfgPath := Path() + writeFile(t, cfgPath, []byte(`{ + "LARKSUITE_CLI_PROXY_ENABLE": true, + "LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128", + "LARKSUITE_CLI_CA_PATH": "" +}`), 0600) + + // Env overrides: disable + different proxy (should be irrelevant once disabled). + t.Setenv(envvars.CliProxyEnable, "false") + t.Setenv(envvars.CliProxyAddress, "http://127.0.0.1:9999") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg == nil { + t.Fatalf("Load() = nil, want non-nil (file exists)") + } + if cfg.Enabled() { + t.Fatalf("cfg.Enabled() = true, want false (env override)") + } +} + +// TestConfig_ProxyURLMalformedDoesNotLeakUserinfo verifies that a malformed proxy +// URL containing credentials does not leak those credentials in the error string. +// url.Parse error strings embed the original URL, so wrapping them with %w would +// expose user:password. +func TestConfig_ProxyURLMalformedDoesNotLeakUserinfo(t *testing.T) { + // Invalid percent-encoding in host makes url.Parse fail while userinfo is present. + raw := "http://user:s3cret@%zz" + _, err := (&Config{Proxy: raw}).proxyURL() + if err == nil { + t.Fatal("proxyURL() error = nil, want malformed URL error") + } + if strings.Contains(err.Error(), "s3cret") { + t.Fatalf("proxyURL() error leaks password: %q", err) + } + if strings.Contains(err.Error(), "user:") { + t.Fatalf("proxyURL() error leaks username: %q", err) + } + if !strings.Contains(err.Error(), "malformed URL") { + t.Fatalf("proxyURL() error = %q, want it to mention malformed URL", err) + } + // The redacted form should still be present for diagnostics. + if !strings.Contains(err.Error(), "***") { + t.Fatalf("proxyURL() error = %q, want redacted userinfo marker", err) + } +} + +// resetLoadState resets the package-level Load() cache for deterministic tests. +func resetLoadState() { + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil +} + +// TestLoad_RejectsWorldWritableConfig verifies that a world-writable proxy config +// is rejected rather than silently trusted (it could be tampered with by other +// local processes to redirect credential traffic). +func TestLoad_RejectsWorldWritableConfig(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission semantics") + } + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + resetLoadState() + unsetProxyPluginEnv(t) + + p := Path() + writeFile(t, p, []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600) + // Chmod (not WriteFile perm) so umask cannot strip the world-writable bit. + if err := os.Chmod(p, 0o666); err != nil { + t.Fatalf("Chmod: %v", err) + } + + _, err := Load() + if err == nil { + t.Fatal("Load() error = nil, want unsafe-config error for world-writable file") + } + if !strings.Contains(err.Error(), "world-writable") { + t.Fatalf("Load() error = %q, want world-writable rejection", err) + } +} + +// TestLoad_RejectsGroupWritableConfig verifies group-writable configs are rejected. +func TestLoad_RejectsGroupWritableConfig(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission semantics") + } + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + resetLoadState() + unsetProxyPluginEnv(t) + + p := Path() + writeFile(t, p, []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600) + if err := os.Chmod(p, 0o660); err != nil { + t.Fatalf("Chmod: %v", err) + } + + _, err := Load() + if err == nil { + t.Fatal("Load() error = nil, want unsafe-config error for group-writable file") + } + if !strings.Contains(err.Error(), "group-writable") { + t.Fatalf("Load() error = %q, want group-writable rejection", err) + } +} + +// TestLoad_RejectsSymlinkConfig verifies that a symlinked proxy config is rejected, +// preventing redirection of the trusted config path to an attacker-controlled file. +func TestLoad_RejectsSymlinkConfig(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is privileged on Windows") + } + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + resetLoadState() + unsetProxyPluginEnv(t) + + // Real file lives elsewhere; the config path is a symlink to it. + real := filepath.Join(dir, "real_proxy_config.json") + writeFile(t, real, []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600) + if err := os.Symlink(real, Path()); err != nil { + t.Fatalf("Symlink: %v", err) + } + + _, err := Load() + if err == nil { + t.Fatal("Load() error = nil, want unsafe-config error for symlinked file") + } + if !strings.Contains(err.Error(), "symlink") { + t.Fatalf("Load() error = %q, want symlink rejection", err) + } +} + +// TestLoad_AcceptsSecureConfig verifies the audit does not break the normal case: +// an owner-only 0600 config still loads. +func TestLoad_AcceptsSecureConfig(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + resetLoadState() + unsetProxyPluginEnv(t) + + writeFile(t, Path(), []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v, want nil for secure 0600 config", err) + } + if cfg == nil || !cfg.Enabled() { + t.Fatalf("cfg.Enabled() = %v, want true", cfg) + } +} diff --git a/internal/transport/shared.go b/internal/transport/shared.go new file mode 100644 index 000000000..9fb7041d5 --- /dev/null +++ b/internal/transport/shared.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package transport + +import ( + "net/http" + "os" + "sync" + "time" +) + +// Shared returns the base http.RoundTripper for all CLI HTTP clients. +// +// Precedence (highest first): +// 1. proxy-plugin mode — force traffic through a fixed loopback proxy; +// FAIL-CLOSED when the plugin config exists but is invalid. +// 2. LARK_CLI_NO_PROXY — direct egress, proxy disabled. +// 3. http.DefaultTransport — the stdlib process-wide singleton (honors +// HTTP(S)_PROXY), so every client shares one connection pool / TLS cache. +// +// The returned RoundTripper MUST NOT be mutated. Callers that need a customized +// transport should assert to *http.Transport and Clone() it. A shared base is +// required so persistConn read/write goroutines are reused; cloning per call +// leaks them until IdleConnTimeout (~90s) fires. +func Shared() http.RoundTripper { + // Proxy-plugin mode overrides everything, INCLUDING LARK_CLI_NO_PROXY. When + // the plugin config exists but is invalid, pluginTransport returns a + // fail-closed transport with ok=true and we return it here — we MUST NOT + // fall through to the NO_PROXY / DefaultTransport direct-egress paths below. + if t, ok := pluginTransport(); ok { + return t + } + if os.Getenv(EnvNoProxy) != "" { + return noProxyTransport() + } + return http.DefaultTransport +} + +// Fallback returns a shared *http.Transport. It is a thin wrapper over Shared +// retained so modules already on the leak-free singleton path (internal/auth, +// internal/cmdutil transport decorators) do not have to migrate. New code +// should prefer Shared and treat the base as an http.RoundTripper. +// +// Fail-closed invariant: pluginTransport always expresses its blocked transport +// as a concrete *http.Transport (see failClosedTransport), so the assertion +// below preserves the block. The noProxyTransport() fallback is therefore only +// reached when no proxy plugin is configured and some external code replaced +// http.DefaultTransport with a non-*http.Transport — a case with no fail-closed +// intent, where a proxy-disabled transport is acceptable. +func Fallback() *http.Transport { + if t, ok := Shared().(*http.Transport); ok { + return t + } + return noProxyTransport() +} + +// NewHTTPClient returns an *http.Client whose Transport is the shared, +// proxy-plugin-aware base (see Shared). Prefer this over a bare &http.Client{} +// for outbound requests: a bare client falls back to http.DefaultTransport and +// therefore silently bypasses proxy plugin mode (fixed proxy + trusted CA, or +// fail-closed), creating an audit blind spot. +// +// A zero timeout means no client-level timeout (callers relying on context +// deadlines pass 0). +func NewHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Transport: Shared(), + Timeout: timeout, + } +} + +// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, lazily +// built the first time LARK_CLI_NO_PROXY is observed set. +var noProxyTransport = sync.OnceValue(func() *http.Transport { + def, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return &http.Transport{} + } + t := def.Clone() + t.Proxy = nil + return t +}) diff --git a/internal/transport/shared_test.go b/internal/transport/shared_test.go new file mode 100644 index 000000000..fe960034d --- /dev/null +++ b/internal/transport/shared_test.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package transport + +import ( + "net/http" + "net/url" + "testing" + "time" +) + +// TestShared_DefaultReturnsStdlibSingleton verifies the default shared transport. +func TestShared_DefaultReturnsStdlibSingleton(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + t.Setenv(EnvNoProxy, "") + if Shared() != http.DefaultTransport { + t.Error("Shared should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset") + } +} + +// TestShared_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport. +func TestShared_NoProxyReturnsClone(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + t.Setenv(EnvNoProxy, "1") + tr := Shared() + if tr == http.DefaultTransport { + t.Fatal("Shared should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set") + } + ht, ok := tr.(*http.Transport) + if !ok { + t.Fatalf("expected *http.Transport, got %T", tr) + } + if ht.Proxy != nil { + t.Error("no-proxy transport should have Proxy == nil") + } +} + +// TestShared_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport. +func TestShared_NoProxyIsCachedSingleton(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + t.Setenv(EnvNoProxy, "1") + if Shared() != Shared() { + t.Error("repeated Shared calls with LARK_CLI_NO_PROXY set must return the same instance") + } +} + +// TestShared_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib +// transport after unsetting LARK_CLI_NO_PROXY. +func TestShared_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + // Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating + // the no-proxy singleton), then unsets it. Subsequent calls must return + // http.DefaultTransport, NOT the cached no-proxy clone. + t.Setenv(EnvNoProxy, "1") + if Shared() == http.DefaultTransport { + t.Fatal("precondition: first call with env set should not return DefaultTransport") + } + + t.Setenv(EnvNoProxy, "") + if after := Shared(); after != http.DefaultTransport { + t.Errorf("after unsetting LARK_CLI_NO_PROXY, Shared must return http.DefaultTransport, got %T", after) + } +} + +// TestShared_NoProxyOverridesSystemProxy verifies that LARK_CLI_NO_PROXY disables system proxies. +func TestShared_NoProxyOverridesSystemProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888") + t.Setenv(EnvNoProxy, "1") + + ht, ok := Shared().(*http.Transport) + if !ok { + t.Fatalf("expected *http.Transport, got %T", Shared()) + } + if ht.Proxy != nil { + t.Error("LARK_CLI_NO_PROXY should override system proxy settings") + } +} + +// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware +// transport (instead of a bare client that bypasses proxy plugin mode). +func TestNewHTTPClient(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + t.Setenv(EnvNoProxy, "") + + c := NewHTTPClient(7 * time.Second) + if c.Transport == nil { + t.Fatal("NewHTTPClient transport is nil; want shared transport") + } + if c.Transport != Shared() { + t.Errorf("NewHTTPClient transport = %v, want Shared()", c.Transport) + } + if c.Timeout != 7*time.Second { + t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout) + } +} + +// TestShared_PluginOverridesNoProxy locks the contract that proxy-plugin mode wins +// over LARK_CLI_NO_PROXY: even with NO_PROXY set, an enabled plugin forces the proxy. +func TestShared_PluginOverridesNoProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + t.Setenv(EnvNoProxy, "1") // NO_PROXY set, but the plugin must win + resetProxyPluginState() + + writeFile(t, Path(), []byte(`{ + "LARKSUITE_CLI_PROXY_ENABLE": true, + "LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128" +}`), 0600) + + tr, ok := Shared().(*http.Transport) + if !ok { + t.Fatalf("Shared() = %T, want proxy *http.Transport, not the NO_PROXY clone", tr) + } + u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err != nil || u == nil || u.String() != "http://127.0.0.1:3128" { + t.Fatalf("Proxy() = %v, %v; plugin must override NO_PROXY with the fixed proxy", u, err) + } +} + +// TestShared_MalformedConfigFailsClosedEvenWithNoProxy locks the most dangerous +// invariant of the fold: a malformed proxy_config.json must FAIL CLOSED, never +// fall through to direct egress — not even to the LARK_CLI_NO_PROXY clone. +func TestShared_MalformedConfigFailsClosedEvenWithNoProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + t.Setenv(EnvNoProxy, "1") + resetProxyPluginState() + + writeFile(t, Path(), []byte(`{`), 0600) // malformed + + rt := Shared() + if rt == http.DefaultTransport { + t.Fatal("malformed config returned http.DefaultTransport — fail OPEN") + } + if rt == noProxyTransport() { + t.Fatal("malformed config fell through to the NO_PROXY direct-egress clone — fail OPEN") + } + resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err == nil { + t.Fatalf("RoundTrip() err = nil (resp=%v); malformed config must fail closed", resp) + } +} diff --git a/internal/transport/tls_ca.go b/internal/transport/tls_ca.go new file mode 100644 index 000000000..079cb6f1d --- /dev/null +++ b/internal/transport/tls_ca.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package transport + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/binding" + "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/vfs" +) + +// applyExtraRootCA augments t with an additional PEM bundle used for configured proxy +// TLS interception. +func applyExtraRootCA(t *http.Transport, caPath string) error { + caPath = strings.TrimSpace(caPath) + if caPath == "" { + return nil + } + if !filepath.IsAbs(caPath) { + return fmt.Errorf("invalid %s %q: must be an absolute path to a PEM file", envvars.CliCAPath, caPath) + } + safeCAPath, err := binding.AssertSecurePath(binding.AuditParams{ + TargetPath: caPath, + Label: envvars.CliCAPath, + AllowReadableByOthers: true, + }) + if err != nil { + return fmt.Errorf("unsafe %s %q: %w", envvars.CliCAPath, caPath, err) + } + pemBytes, err := vfs.ReadFile(safeCAPath) + if err != nil { + return fmt.Errorf("failed to read %s %q: %w", envvars.CliCAPath, caPath, err) + } + + // Augment the system trust store. Do NOT silently discard a SystemCertPool + // error: falling back to an empty pool would make this transport trust ONLY + // the extra CA (dropping all system roots), which narrows trust unexpectedly + // and could break TLS to legitimate endpoints. Fail closed instead. + pool, err := x509.SystemCertPool() + if err != nil { + return fmt.Errorf("failed to load system cert pool for %s: %w", envvars.CliCAPath, err) + } + if pool == nil { + pool = x509.NewCertPool() + } + if ok := pool.AppendCertsFromPEM(pemBytes); !ok { + return fmt.Errorf("invalid %s %q: no certificates parsed from PEM", envvars.CliCAPath, caPath) + } + + if t.TLSClientConfig == nil { + t.TLSClientConfig = &tls.Config{} + } else { + // Clone to avoid mutating shared config from the base transport. + t.TLSClientConfig = t.TLSClientConfig.Clone() + } + if t.TLSClientConfig.MinVersion == 0 || t.TLSClientConfig.MinVersion < tls.VersionTLS12 { + t.TLSClientConfig.MinVersion = tls.VersionTLS12 + } + t.TLSClientConfig.RootCAs = pool + return nil +} diff --git a/internal/transport/tls_ca_test.go b/internal/transport/tls_ca_test.go new file mode 100644 index 000000000..9fd1b9e7e --- /dev/null +++ b/internal/transport/tls_ca_test.go @@ -0,0 +1,173 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package transport + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// mustCreateTestCertPEM generates a short-lived self-signed CA certificate for tests. +func mustCreateTestCertPEM(t *testing.T) []byte { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("GenerateKey() error = %v", err) + } + + der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "proxyplugin-test-ca", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + BasicConstraintsValid: true, + }, &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "proxyplugin-test-ca", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + BasicConstraintsValid: true, + }, &key.PublicKey, key) + if err != nil { + t.Fatalf("CreateCertificate() error = %v", err) + } + + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +// TestApplyExtraRootCA_EmptyPathIsNoop verifies that an empty CA path leaves the transport unchanged. +func TestApplyExtraRootCA_EmptyPathIsNoop(t *testing.T) { + tr := &http.Transport{} + + if err := applyExtraRootCA(tr, " "); err != nil { + t.Fatalf("applyExtraRootCA() error = %v", err) + } + if tr.TLSClientConfig != nil { + t.Fatalf("TLSClientConfig = %#v, want nil", tr.TLSClientConfig) + } +} + +// TestApplyExtraRootCA_RejectsRelativePath verifies that CA paths must be absolute. +func TestApplyExtraRootCA_RejectsRelativePath(t *testing.T) { + tr := &http.Transport{} + + err := applyExtraRootCA(tr, "ca.pem") + if err == nil || !strings.Contains(err.Error(), "must be an absolute path") { + t.Fatalf("applyExtraRootCA() error = %v, want absolute-path error", err) + } +} + +// TestApplyExtraRootCA_RejectsMissingFile verifies missing PEM bundles fail before file reads. +func TestApplyExtraRootCA_RejectsMissingFile(t *testing.T) { + tr := &http.Transport{} + + err := applyExtraRootCA(tr, filepath.Join(t.TempDir(), "missing.pem")) + if err == nil || !strings.Contains(err.Error(), "unsafe") { + t.Fatalf("applyExtraRootCA() error = %v, want unsafe path error", err) + } +} + +// TestApplyExtraRootCA_RejectsInvalidPEM verifies validation of malformed PEM bundles. +func TestApplyExtraRootCA_RejectsInvalidPEM(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "invalid.pem") + writeFile(t, caPath, []byte("not a pem"), 0600) + + tr := &http.Transport{} + err := applyExtraRootCA(tr, caPath) + if err == nil || !strings.Contains(err.Error(), "no certificates parsed from PEM") { + t.Fatalf("applyExtraRootCA() error = %v, want invalid PEM error", err) + } +} + +// TestApplyExtraRootCA_RejectsInsecureCAPath verifies CA paths are safety-checked +// before reading the configured file. +func TestApplyExtraRootCA_RejectsInsecureCAPath(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "ca.pem") + writeFile(t, caPath, mustCreateTestCertPEM(t), 0600) + if err := os.Chmod(caPath, 0666); err != nil { + t.Fatalf("Chmod() error = %v", err) + } + + tr := &http.Transport{} + err := applyExtraRootCA(tr, caPath) + if err == nil || !strings.Contains(err.Error(), "unsafe") { + t.Fatalf("applyExtraRootCA() error = %v, want unsafe path error", err) + } + if tr.TLSClientConfig != nil { + t.Fatalf("TLSClientConfig = %#v, want nil", tr.TLSClientConfig) + } +} + +// TestApplyExtraRootCA_SetsTLSConfigWhenMissing verifies initialization of TLSClientConfig when absent. +func TestApplyExtraRootCA_SetsTLSConfigWhenMissing(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "ca.pem") + writeFile(t, caPath, mustCreateTestCertPEM(t), 0600) + + tr := &http.Transport{} + if err := applyExtraRootCA(tr, caPath); err != nil { + t.Fatalf("applyExtraRootCA() error = %v", err) + } + if tr.TLSClientConfig == nil { + t.Fatal("TLSClientConfig = nil, want initialized config") + } + if tr.TLSClientConfig.RootCAs == nil { + t.Fatal("RootCAs = nil, want cert pool") + } +} + +// TestApplyExtraRootCA_ClonesExistingTLSConfig verifies cloning when the base transport already has TLS settings. +func TestApplyExtraRootCA_ClonesExistingTLSConfig(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "ca.pem") + writeFile(t, caPath, mustCreateTestCertPEM(t), 0600) + + original := &tls.Config{ServerName: "open.feishu.cn"} + tr := &http.Transport{TLSClientConfig: original} + if err := applyExtraRootCA(tr, caPath); err != nil { + t.Fatalf("applyExtraRootCA() error = %v", err) + } + if tr.TLSClientConfig == original { + t.Fatal("TLSClientConfig pointer reused, want clone") + } + if tr.TLSClientConfig.ServerName != original.ServerName { + t.Fatalf("ServerName = %q, want %q", tr.TLSClientConfig.ServerName, original.ServerName) + } + if tr.TLSClientConfig.RootCAs == nil { + t.Fatal("RootCAs = nil, want cert pool") + } +} + +// TestApplyExtraRootCA_PreservesHigherTLSMinVersion verifies that adding a CA +// does not relax an existing stricter TLS version floor. +func TestApplyExtraRootCA_PreservesHigherTLSMinVersion(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "ca.pem") + writeFile(t, caPath, mustCreateTestCertPEM(t), 0600) + + tr := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13}} + if err := applyExtraRootCA(tr, caPath); err != nil { + t.Fatalf("applyExtraRootCA() error = %v", err) + } + if tr.TLSClientConfig.MinVersion != tls.VersionTLS13 { + t.Fatalf("MinVersion = %x, want %x", tr.TLSClientConfig.MinVersion, tls.VersionTLS13) + } +} diff --git a/internal/transport/transport.go b/internal/transport/transport.go new file mode 100644 index 000000000..88b2b1164 --- /dev/null +++ b/internal/transport/transport.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package transport + +import ( + "fmt" + "net/http" + "net/url" + "sync" +) + +// proxyPluginTransport is a fixed-proxy clone of http.DefaultTransport (with optional +// custom root CA), lazily built on first use when proxy plugin mode is enabled. +var proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport) + +// cachedBlockedTransport is a fail-closed transport cached on first use when +// the proxy plugin config exists but is invalid. This avoids cloning +// http.DefaultTransport on every pluginTransport call. +var cachedBlockedTransport = sync.OnceValue(buildBlockedTransport) + +func buildBlockedTransport() http.RoundTripper { + return failClosedTransport(fmt.Errorf("proxy plugin config is invalid: %w", loadErr)) +} + +func buildProxyPluginTransport() http.RoundTripper { + def, ok := http.DefaultTransport.(*http.Transport) + if !ok { + // Cannot clone the stdlib transport. Fail closed with a concrete + // *http.Transport (not a bare RoundTripper) so downcasting callers such + // as Fallback cannot silently degrade this into a + // direct-egress transport. + return failClosedTransport(fmt.Errorf("proxy plugin transport unavailable: http.DefaultTransport is %T, want *http.Transport", http.DefaultTransport)) + } + + cfg, err := Load() + if err != nil { + // Fail closed: config file exists but is malformed/unreadable — do not + // silently fall back to direct egress. + return blockedTransport(def, fmt.Errorf("proxy plugin config is invalid: %w", err)) + } + if cfg == nil || !cfg.Enabled() { + return def + } + t, err := cfg.ApplyToTransport(def) + if err != nil { + // Fail closed: do not silently fall back to direct egress when the + // operator explicitly enabled proxy plugin mode. + return blockedTransport(def, fmt.Errorf("proxy plugin enabled but config is invalid: %w", err)) + } + return t +} + +// pluginTransport returns the proxy plugin transport when proxy plugin mode is +// configured. The bool return is false when the plugin is not configured or not enabled. +func pluginTransport() (http.RoundTripper, bool) { + cfg, err := Load() + if err != nil { + return cachedBlockedTransport(), true + } + if cfg == nil || !cfg.Enabled() { + return nil, false + } + return proxyPluginTransport(), true +} + +// failClosedTransport returns a *http.Transport that always fails RoundTrip with +// err. It clones http.DefaultTransport when possible (preserving dial/timeout +// tuning); otherwise it builds a minimal transport. Returning a concrete +// *http.Transport (rather than a bare RoundTripper) is required so downcasting +// callers such as Fallback cannot silently degrade a fail-closed +// signal into a direct-egress transport. +func failClosedTransport(err error) *http.Transport { + if def, ok := http.DefaultTransport.(*http.Transport); ok { + return blockedTransport(def, err) + } + return &http.Transport{ + Proxy: func(*http.Request) (*url.URL, error) { + return nil, err + }, + } +} + +func blockedTransport(base *http.Transport, err error) *http.Transport { + blocked := base.Clone() + blocked.Proxy = func(*http.Request) (*url.URL, error) { + return nil, err + } + return blocked +} diff --git a/internal/transport/transport_test.go b/internal/transport/transport_test.go new file mode 100644 index 000000000..ad6d8987b --- /dev/null +++ b/internal/transport/transport_test.go @@ -0,0 +1,195 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package transport + +import ( + "io" + "net/http" + "net/url" + "strings" + "sync" + "testing" +) + +func resetProxyPluginState() { + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport) + cachedBlockedTransport = sync.OnceValue(buildBlockedTransport) +} + +func TestPluginTransport_NotConfigured(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + + tr, ok := pluginTransport() + if ok { + t.Fatalf("pluginTransport() ok = true, want false") + } + if tr != nil { + t.Fatalf("pluginTransport() transport = %T, want nil", tr) + } +} + +func TestPluginTransport_EnabledReturnsFixedProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + + cfgPath := Path() + writeFile(t, cfgPath, []byte(`{ + "LARKSUITE_CLI_PROXY_ENABLE": true, + "LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128", + "LARKSUITE_CLI_CA_PATH": "" +}`), 0600) + + rt, ok := pluginTransport() + if !ok { + t.Fatal("pluginTransport() ok = false, want true") + } + tr, ok := rt.(*http.Transport) + if !ok { + t.Fatalf("pluginTransport() = %T, want *http.Transport", rt) + } + u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err != nil { + t.Fatalf("Proxy() error = %v", err) + } + if u == nil || u.String() != "http://127.0.0.1:3128" { + t.Fatalf("Proxy() = %v, want http://127.0.0.1:3128", u) + } +} + +func TestPluginTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + restoreDefaultTransport := replaceDefaultTransport(okRoundTripper{}) + defer restoreDefaultTransport() + + writeFile(t, Path(), []byte(`{`), 0600) + + rt, ok := pluginTransport() + if !ok { + t.Fatal("pluginTransport() ok = false, want true") + } + if rt == http.DefaultTransport { + t.Fatalf("pluginTransport() returned http.DefaultTransport, want fail-closed transport") + } + resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err == nil { + t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp) + } + if resp != nil { + t.Fatalf("RoundTrip() response = %#v, want nil", resp) + } +} + +func TestPluginTransport_InvalidConfigReturnsCachedInstance(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + + writeFile(t, Path(), []byte(`{`), 0600) + + a, ok := pluginTransport() + if !ok { + t.Fatal("pluginTransport() ok = false, want true") + } + b, ok := pluginTransport() + if !ok { + t.Fatal("pluginTransport() ok = false, want true") + } + if a != b { + t.Fatalf("pluginTransport() returned different instances on repeated calls; blocked transport must be cached") + } +} + +func TestBuildProxyPluginTransport_InvalidConfigFailsClosed(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + + writeFile(t, Path(), []byte(`{`), 0600) + + rt := buildProxyPluginTransport() + if rt == http.DefaultTransport { + t.Fatalf("buildProxyPluginTransport() returned http.DefaultTransport, want fail-closed transport") + } + resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err == nil { + t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp) + } + if resp != nil { + t.Fatalf("RoundTrip() response = %#v, want nil", resp) + } +} + +func TestBuildProxyPluginTransport_NonTransportDefaultFailsClosed(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + restoreDefaultTransport := replaceDefaultTransport(okRoundTripper{}) + defer restoreDefaultTransport() + + rt := buildProxyPluginTransport() + if rt == http.DefaultTransport { + t.Fatalf("buildProxyPluginTransport() returned http.DefaultTransport, want fail-closed transport") + } + resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err == nil { + t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp) + } + if resp != nil { + t.Fatalf("RoundTrip() response = %#v, want nil", resp) + } +} + +// TestPluginTransport_InvalidConfigBlockerIsConcreteTransport guards the +// fail-closed invariant that Fallback relies on: even when +// http.DefaultTransport is not an *http.Transport, an invalid proxy config must +// produce a blocked transport that is itself a concrete *http.Transport. If it +// were a bare RoundTripper, Fallback would downcast-fail and +// silently degrade it into a direct-egress transport. +func TestPluginTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + restoreDefaultTransport := replaceDefaultTransport(okRoundTripper{}) + defer restoreDefaultTransport() + + writeFile(t, Path(), []byte(`{`), 0600) + + rt, ok := pluginTransport() + if !ok { + t.Fatal("pluginTransport() ok = false, want true") + } + if _, isTransport := rt.(*http.Transport); !isTransport { + t.Fatalf("pluginTransport() blocked transport = %T, want *http.Transport so Fallback cannot degrade it to direct egress", rt) + } + // Must remain fail-closed. + resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err == nil { + t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp) + } + if resp != nil { + t.Fatalf("RoundTrip() response = %#v, want nil", resp) + } +} + +type okRoundTripper struct{} + +func (okRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(""))}, nil +} + +func replaceDefaultTransport(rt http.RoundTripper) func() { + original := http.DefaultTransport + http.DefaultTransport = rt + return func() { + http.DefaultTransport = original + } +} diff --git a/internal/util/proxy.go b/internal/transport/warn.go similarity index 52% rename from internal/util/proxy.go rename to internal/transport/warn.go index d9e251859..cac050f72 100644 --- a/internal/util/proxy.go +++ b/internal/transport/warn.go @@ -1,18 +1,20 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package util +package transport import ( "fmt" "io" - "net/http" "net/url" "os" "strings" "sync" + + "github.com/larksuite/cli/internal/envvars" ) +// Proxy environment constants control shared transport proxy behavior. const ( // EnvNoProxy disables automatic proxy support when set to any non-empty value. EnvNoProxy = "LARK_CLI_NO_PROXY" @@ -36,8 +38,21 @@ func DetectProxyEnv() (key, value string) { return "", "" } +// proxyWarningOnce ensures proxy environment warnings are emitted at most once. var proxyWarningOnce sync.Once +// proxyPluginStatus reports the configured proxy plugin address, the extra +// trusted CA path (if any), and whether proxy plugin mode is enabled. It is +// indirected through a package variable so tests can simulate plugin-enabled +// mode without the process-global Load() sync.Once cache. +var proxyPluginStatus = func() (addr, caPath string, enabled bool) { + cfg, err := Load() + if err != nil || !cfg.Enabled() { + return "", "", false + } + return cfg.Proxy, cfg.CAPath, true +} + // redactProxyURL masks userinfo (username:password) in a proxy URL. // Handles both scheme-prefixed ("http://user:pass@host") and bare ("user:pass@host") formats. func redactProxyURL(raw string) string { @@ -60,6 +75,22 @@ func redactProxyURL(raw string) string { // are redacted. Safe to call multiple times; only the first call prints. func WarnIfProxied(w io.Writer) { proxyWarningOnce.Do(func() { + // Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see + // Shared), so its warning and disable instructions take precedence. + // Emitting the env-proxy warning here would be misleading: it tells the + // user to set LARK_CLI_NO_PROXY=1, which does NOT disable the plugin proxy. + if addr, caPath, enabled := proxyPluginStatus(); enabled { + fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin enabled: all requests (including credentials) are forced through %s. To disable, set %s=false or remove %s.\n", + redactProxyURL(addr), envvars.CliProxyEnable, Path()) + if strings.TrimSpace(caPath) != "" { + // A custom CA means upstream TLS can be intercepted/inspected by + // the proxy (MITM). Surface it so the operator is aware traffic + // (including Bearer tokens) is decryptable on this host. + fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin trusts a custom CA (%s); TLS to upstreams can be intercepted/inspected by this proxy.\n", + caPath) + } + return + } if os.Getenv(EnvNoProxy) != "" { return } @@ -71,48 +102,3 @@ func WarnIfProxied(w io.Writer) { key, redactProxyURL(val), EnvNoProxy) }) } - -// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, -// lazily built the first time LARK_CLI_NO_PROXY is observed set. -var noProxyTransport = sync.OnceValue(func() *http.Transport { - def, ok := http.DefaultTransport.(*http.Transport) - if !ok { - return &http.Transport{} - } - t := def.Clone() - t.Proxy = nil - return t -}) - -// SharedTransport returns the base http.RoundTripper for CLI HTTP clients. -// -// By default it returns http.DefaultTransport — the stdlib-provided -// process-wide singleton — so every HTTP client in the process shares one -// TCP connection pool, TLS session cache, and HTTP/2 state. When -// LARK_CLI_NO_PROXY is set it returns a separate proxy-disabled singleton -// clone; LARK_CLI_NO_PROXY is checked on every call, but the clone is built -// at most once. -// -// The returned RoundTripper MUST NOT be mutated. Callers that need a -// customized transport should assert to *http.Transport and Clone() it. -// Using a shared base is required so persistConn readLoop/writeLoop -// goroutines are reused; cloning per call leaks them until IdleConnTimeout -// (~90s) fires. -func SharedTransport() http.RoundTripper { - if os.Getenv(EnvNoProxy) != "" { - return noProxyTransport() - } - return http.DefaultTransport -} - -// FallbackTransport returns a shared *http.Transport singleton. It is a -// thin wrapper over SharedTransport retained so modules that were already -// on the leak-free singleton path (internal/auth, internal/cmdutil -// transport decorators) do not have to migrate. New code should prefer -// SharedTransport and treat the base as an http.RoundTripper. -func FallbackTransport() *http.Transport { - if t, ok := SharedTransport().(*http.Transport); ok { - return t - } - return noProxyTransport() -} diff --git a/internal/transport/warn_test.go b/internal/transport/warn_test.go new file mode 100644 index 000000000..13708ca7e --- /dev/null +++ b/internal/transport/warn_test.go @@ -0,0 +1,258 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package transport + +import ( + "bytes" + "strings" + "sync" + "testing" + + "github.com/larksuite/cli/internal/envvars" +) + +// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior. +func TestDetectProxyEnv(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + + // Clear all proxy env vars first + for _, k := range proxyEnvKeys { + t.Setenv(k, "") + } + + key, val := DetectProxyEnv() + if key != "" || val != "" { + t.Errorf("expected no proxy, got %s=%s", key, val) + } + + t.Setenv("HTTPS_PROXY", "http://proxy:8888") + key, val = DetectProxyEnv() + if key != "HTTPS_PROXY" || val != "http://proxy:8888" { + t.Errorf("expected HTTPS_PROXY=http://proxy:8888, got %s=%s", key, val) + } +} + +// TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning. +func TestWarnIfProxied_WithProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + proxyWarningOnce = sync.Once{} + + t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128") + + var buf bytes.Buffer + WarnIfProxied(&buf) + + out := buf.String() + if out == "" { + t.Error("expected warning output when proxy is set") + } + if !bytes.Contains([]byte(out), []byte("HTTPS_PROXY")) { + t.Errorf("warning should mention HTTPS_PROXY, got: %s", out) + } + if !bytes.Contains([]byte(out), []byte(EnvNoProxy)) { + t.Errorf("warning should mention %s, got: %s", EnvNoProxy, out) + } +} + +// TestWarnIfProxied_WithoutProxy verifies that no warning is emitted without proxy settings. +func TestWarnIfProxied_WithoutProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + proxyWarningOnce = sync.Once{} + + for _, k := range proxyEnvKeys { + t.Setenv(k, "") + } + + var buf bytes.Buffer + WarnIfProxied(&buf) + + if buf.Len() != 0 { + t.Errorf("expected no output when no proxy is set, got: %s", buf.String()) + } +} + +// TestWarnIfProxied_SilentWhenDisabled verifies that LARK_CLI_NO_PROXY suppresses warnings. +func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + proxyWarningOnce = sync.Once{} + + t.Setenv("HTTPS_PROXY", "http://proxy:8080") + t.Setenv(EnvNoProxy, "1") + + var buf bytes.Buffer + WarnIfProxied(&buf) + + if buf.Len() != 0 { + t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String()) + } +} + +// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once. +func TestWarnIfProxied_OnlyOnce(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + proxyWarningOnce = sync.Once{} + + t.Setenv("HTTP_PROXY", "http://proxy:1234") + + var buf bytes.Buffer + WarnIfProxied(&buf) + first := buf.String() + + WarnIfProxied(&buf) + second := buf.String() + + if first == "" { + t.Error("expected warning on first call") + } + if second != first { + t.Error("expected no additional output on second call") + } +} + +// TestWarnIfProxied_ProxyPluginEnabled verifies that when proxy plugin mode is +// enabled, the warning describes the plugin proxy and the correct disable method +// (LARKSUITE_CLI_PROXY_ENABLE=false) instead of the misleading LARK_CLI_NO_PROXY +// instruction — even when env proxy and LARK_CLI_NO_PROXY are also set. +func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + proxyWarningOnce = sync.Once{} + + old := proxyPluginStatus + proxyPluginStatus = func() (string, string, bool) { return "http://127.0.0.1:3128", "", true } + t.Cleanup(func() { proxyPluginStatus = old }) + + // Plugin mode overrides these; the warning must still be the plugin one. + t.Setenv("HTTPS_PROXY", "http://corp-proxy:8080") + t.Setenv(EnvNoProxy, "1") + + var buf bytes.Buffer + WarnIfProxied(&buf) + out := buf.String() + + if !strings.Contains(out, "127.0.0.1:3128") { + t.Errorf("warning should mention the plugin proxy address, got: %s", out) + } + if !strings.Contains(out, envvars.CliProxyEnable) { + t.Errorf("warning should mention %s as the disable method, got: %s", envvars.CliProxyEnable, out) + } + if strings.Contains(out, "Set "+EnvNoProxy+"=1") { + t.Errorf("warning must NOT give the misleading %s disable instruction when plugin is enabled, got: %s", EnvNoProxy, out) + } + // No custom CA configured -> no interception warning. + if strings.Contains(out, "custom CA") { + t.Errorf("warning should not mention a custom CA when none is configured, got: %s", out) + } +} + +// TestWarnIfProxied_ProxyPluginCustomCAWarns verifies that when a custom CA is +// trusted, the warning surfaces the TLS-interception capability. +func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + proxyWarningOnce = sync.Once{} + + old := proxyPluginStatus + proxyPluginStatus = func() (string, string, bool) { + return "http://127.0.0.1:3128", "/etc/lark/extra_ca.pem", true + } + t.Cleanup(func() { proxyPluginStatus = old }) + + var buf bytes.Buffer + WarnIfProxied(&buf) + out := buf.String() + + if !strings.Contains(out, "custom CA") { + t.Errorf("warning should mention the custom CA, got: %s", out) + } + if !strings.Contains(out, "/etc/lark/extra_ca.pem") { + t.Errorf("warning should include the CA path, got: %s", out) + } + if !strings.Contains(out, "intercept") { + t.Errorf("warning should mention TLS interception, got: %s", out) + } +} + +// TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials verifies the plugin +// warning never leaks credentials embedded in the configured proxy address. +func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + proxyWarningOnce = sync.Once{} + + old := proxyPluginStatus + proxyPluginStatus = func() (string, string, bool) { return "http://user:s3cret@127.0.0.1:3128", "", true } + t.Cleanup(func() { proxyPluginStatus = old }) + + var buf bytes.Buffer + WarnIfProxied(&buf) + out := buf.String() + + if strings.Contains(out, "s3cret") { + t.Errorf("plugin warning leaked password, got: %s", out) + } + if strings.Contains(out, "user:") { + t.Errorf("plugin warning leaked username, got: %s", out) + } + if !strings.Contains(out, "***@127.0.0.1:3128") { + t.Errorf("plugin warning should contain redacted proxy URL, got: %s", out) + } +} + +// TestRedactProxyURL verifies redaction of proxy credentials across supported formats. +func TestRedactProxyURL(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"http://proxy:8080", "http://proxy:8080"}, + {"http://user:pass@proxy:8080", "http://***@proxy:8080/"}, + {"http://user:p%40ss@proxy:8080/path", "http://***@proxy:8080/path"}, + {"http://user@proxy:8080", "http://***@proxy:8080/"}, + {"socks5://admin:secret@10.0.0.1:1080", "socks5://***@10.0.0.1:1080/"}, + {"user:pass@proxy:8080", "***@proxy:8080"}, + {"admin:s3cret@10.0.0.1:3128", "***@10.0.0.1:3128"}, + {"not-a-url", "not-a-url"}, + {"", ""}, + } + for _, tt := range tests { + got := redactProxyURL(tt.input) + if got != tt.want { + t.Errorf("redactProxyURL(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +// TestWarnIfProxied_RedactsCredentials verifies that warning output never leaks credentials. +func TestWarnIfProxied_RedactsCredentials(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + proxyWarningOnce = sync.Once{} + + t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080") + + var buf bytes.Buffer + WarnIfProxied(&buf) + + out := buf.String() + if bytes.Contains([]byte(out), []byte("s3cret")) { + t.Errorf("warning should not contain proxy password, got: %s", out) + } + if bytes.Contains([]byte(out), []byte("admin")) { + t.Errorf("warning should not contain proxy username, got: %s", out) + } + if !bytes.Contains([]byte(out), []byte("***@proxy:8080")) { + t.Errorf("warning should contain redacted proxy URL, got: %s", out) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index c3509b3d6..31fef085e 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -17,7 +17,7 @@ import ( "time" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" ) @@ -64,7 +64,7 @@ func httpClient() *http.Client { } return &http.Client{ Timeout: fetchTimeout, - Transport: util.SharedTransport(), + Transport: transport.Shared(), } } diff --git a/internal/util/proxy_test.go b/internal/util/proxy_test.go deleted file mode 100644 index f78720963..000000000 --- a/internal/util/proxy_test.go +++ /dev/null @@ -1,204 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package util - -import ( - "bytes" - "net/http" - "sync" - "testing" -) - -func TestDetectProxyEnv(t *testing.T) { - // Clear all proxy env vars first - for _, k := range proxyEnvKeys { - t.Setenv(k, "") - } - - key, val := DetectProxyEnv() - if key != "" || val != "" { - t.Errorf("expected no proxy, got %s=%s", key, val) - } - - t.Setenv("HTTPS_PROXY", "http://proxy:8888") - key, val = DetectProxyEnv() - if key != "HTTPS_PROXY" || val != "http://proxy:8888" { - t.Errorf("expected HTTPS_PROXY=http://proxy:8888, got %s=%s", key, val) - } -} - -func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) { - t.Setenv(EnvNoProxy, "") - tr := SharedTransport() - if tr != http.DefaultTransport { - t.Error("SharedTransport should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset") - } -} - -func TestSharedTransport_NoProxyReturnsClone(t *testing.T) { - t.Setenv(EnvNoProxy, "1") - tr := SharedTransport() - if tr == http.DefaultTransport { - t.Fatal("SharedTransport should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set") - } - ht, ok := tr.(*http.Transport) - if !ok { - t.Fatalf("expected *http.Transport, got %T", tr) - } - if ht.Proxy != nil { - t.Error("no-proxy transport should have Proxy == nil") - } -} - -func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) { - t.Setenv(EnvNoProxy, "1") - a := SharedTransport() - b := SharedTransport() - if a != b { - t.Error("repeated SharedTransport calls with LARK_CLI_NO_PROXY set must return the same instance") - } -} - -func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) { - // Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating - // the no-proxy singleton), then unsets it. Subsequent calls must return - // http.DefaultTransport, NOT the cached no-proxy clone. - t.Setenv(EnvNoProxy, "1") - noProxy := SharedTransport() - if noProxy == http.DefaultTransport { - t.Fatal("precondition: first call with env set should not return DefaultTransport") - } - - t.Setenv(EnvNoProxy, "") - after := SharedTransport() - if after != http.DefaultTransport { - t.Errorf("after unsetting LARK_CLI_NO_PROXY, SharedTransport must return http.DefaultTransport, got %T (%p)", after, after) - } -} - -func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) { - t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888") - t.Setenv(EnvNoProxy, "1") - - ht, ok := SharedTransport().(*http.Transport) - if !ok { - t.Fatalf("expected *http.Transport, got %T", SharedTransport()) - } - if ht.Proxy != nil { - t.Error("LARK_CLI_NO_PROXY should override system proxy settings") - } -} - -func TestWarnIfProxied_WithProxy(t *testing.T) { - // Reset the once guard for this test - proxyWarningOnce = sync.Once{} - - t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128") - - var buf bytes.Buffer - WarnIfProxied(&buf) - - out := buf.String() - if out == "" { - t.Error("expected warning output when proxy is set") - } - if !bytes.Contains([]byte(out), []byte("HTTPS_PROXY")) { - t.Errorf("warning should mention HTTPS_PROXY, got: %s", out) - } - if !bytes.Contains([]byte(out), []byte(EnvNoProxy)) { - t.Errorf("warning should mention %s, got: %s", EnvNoProxy, out) - } -} - -func TestWarnIfProxied_WithoutProxy(t *testing.T) { - proxyWarningOnce = sync.Once{} - - for _, k := range proxyEnvKeys { - t.Setenv(k, "") - } - - var buf bytes.Buffer - WarnIfProxied(&buf) - - if buf.Len() != 0 { - t.Errorf("expected no output when no proxy is set, got: %s", buf.String()) - } -} - -func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { - proxyWarningOnce = sync.Once{} - - t.Setenv("HTTPS_PROXY", "http://proxy:8080") - t.Setenv(EnvNoProxy, "1") - - var buf bytes.Buffer - WarnIfProxied(&buf) - - if buf.Len() != 0 { - t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String()) - } -} - -func TestWarnIfProxied_OnlyOnce(t *testing.T) { - proxyWarningOnce = sync.Once{} - - t.Setenv("HTTP_PROXY", "http://proxy:1234") - - var buf bytes.Buffer - WarnIfProxied(&buf) - first := buf.String() - - WarnIfProxied(&buf) - second := buf.String() - - if first == "" { - t.Error("expected warning on first call") - } - if second != first { - t.Error("expected no additional output on second call") - } -} - -func TestRedactProxyURL(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"http://proxy:8080", "http://proxy:8080"}, - {"http://user:pass@proxy:8080", "http://***@proxy:8080/"}, - {"http://user:p%40ss@proxy:8080/path", "http://***@proxy:8080/path"}, - {"http://user@proxy:8080", "http://***@proxy:8080/"}, - {"socks5://admin:secret@10.0.0.1:1080", "socks5://***@10.0.0.1:1080/"}, - {"user:pass@proxy:8080", "***@proxy:8080"}, - {"admin:s3cret@10.0.0.1:3128", "***@10.0.0.1:3128"}, - {"not-a-url", "not-a-url"}, - {"", ""}, - } - for _, tt := range tests { - got := redactProxyURL(tt.input) - if got != tt.want { - t.Errorf("redactProxyURL(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - -func TestWarnIfProxied_RedactsCredentials(t *testing.T) { - proxyWarningOnce = sync.Once{} - - t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080") - - var buf bytes.Buffer - WarnIfProxied(&buf) - - out := buf.String() - if bytes.Contains([]byte(out), []byte("s3cret")) { - t.Errorf("warning should not contain proxy password, got: %s", out) - } - if bytes.Contains([]byte(out), []byte("admin")) { - t.Errorf("warning should not contain proxy username, got: %s", out) - } - if !bytes.Contains([]byte(out), []byte("***@proxy:8080")) { - t.Errorf("warning should contain redacted proxy URL, got: %s", out) - } -} diff --git a/lint/errscontract/rule_build_api_error_arms.go b/lint/errscontract/rule_build_api_error_arms.go new file mode 100644 index 000000000..282f95568 --- /dev/null +++ b/lint/errscontract/rule_build_api_error_arms.go @@ -0,0 +1,276 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errscontract + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" +) + +// canonicalCategories enumerates every taxonomy Category that BuildAPIError +// must route. Kept in sync with errs/category.go. The lint refuses to +// silently accept the omission of a new Category — when the taxonomy grows, +// either BuildAPIError gets an explicit arm or this list is updated +// consciously (drawing a reviewer's attention). +var canonicalCategories = []string{ + "CategoryValidation", + "CategoryAuthentication", + "CategoryAuthorization", + "CategoryConfig", + "CategoryNetwork", + "CategoryAPI", + "CategoryPolicy", + "CategoryInternal", + "CategoryConfirmation", +} + +// CheckBuildAPIErrorArms enforces that the BuildAPIError switch in +// internal/errclass/classify.go (a) covers every Category in the canonical +// taxonomy and (b) has a `default` arm that fail-closes to an InternalError +// envelope — never returns nil and never falls through to emit an empty +// Problem on the wire. +// +// Scope: only the canonical classify.go file. Other switch statements on +// Category in callers (e.g. UI rendering) intentionally remain free-form. +// +// Returns REJECT violations. +func CheckBuildAPIErrorArms(path, src string) []Violation { + if !isClassifyPath(path) { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return nil + } + + var out []Violation + found := false + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name == nil || fn.Name.Name != "BuildAPIError" || fn.Body == nil { + continue + } + found = true + sw := findCategorySwitch(fn.Body) + if sw == nil { + out = append(out, Violation{ + Rule: "build_api_error_arms", + Action: ActionReject, + File: path, + Line: fset.Position(fn.Pos()).Line, + Message: "BuildAPIError has no Category switch — typed routing is the entire purpose of this function", + Suggestion: "restore the `switch meta.Category { case errs.CategoryX: ...; default: }` " + + "structure", + }) + continue + } + out = append(out, checkSwitchArms(path, fset, sw)...) + } + if !found { + out = append(out, Violation{ + Rule: "build_api_error_arms", + Action: ActionReject, + File: path, + Line: 1, + Message: "BuildAPIError function not found in classify.go — the typed-routing entry point must exist on this file", + Suggestion: "define `func BuildAPIError(resp map[string]any, cc ClassifyContext) error` with the canonical Category switch", + }) + } + return out +} + +// isClassifyPath matches both repo-relative ("internal/errclass/classify.go") +// and slashed paths that contain the same suffix when scanning nested roots. +func isClassifyPath(path string) bool { + p := strings.ReplaceAll(path, "\\", "/") + return p == "internal/errclass/classify.go" || strings.HasSuffix(p, "/internal/errclass/classify.go") +} + +// findCategorySwitch returns the first switch statement inside body whose +// arms reference `errs.Category*` selectors. Returns nil if no such switch +// exists. The shallow scan is sufficient — BuildAPIError contains exactly +// one taxonomy switch in production. +func findCategorySwitch(body *ast.BlockStmt) *ast.SwitchStmt { + var found *ast.SwitchStmt + ast.Inspect(body, func(n ast.Node) bool { + if found != nil { + return false + } + sw, ok := n.(*ast.SwitchStmt) + if !ok || sw.Body == nil { + return true + } + if switchMentionsCategory(sw) { + found = sw + return false + } + return true + }) + return found +} + +// switchMentionsCategory reports whether sw has at least one arm with an +// `errs.Category*` case expression. This is the cheap heuristic that +// identifies the canonical taxonomy switch without depending on type info. +func switchMentionsCategory(sw *ast.SwitchStmt) bool { + for _, stmt := range sw.Body.List { + cc, ok := stmt.(*ast.CaseClause) + if !ok { + continue + } + for _, expr := range cc.List { + if categoryName(expr) != "" { + return true + } + } + } + return false +} + +// categoryName returns the `Category*` selector name (e.g. "CategoryAPI") +// for an `errs.Category*` expression, or "" when expr is not such a +// selector. Also accepts a bare `Category*` ident for an in-package +// switch (rare but possible). +func categoryName(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.SelectorExpr: + if x, ok := t.X.(*ast.Ident); ok && x.Name == "errs" && t.Sel != nil && + strings.HasPrefix(t.Sel.Name, "Category") { + return t.Sel.Name + } + case *ast.Ident: + if strings.HasPrefix(t.Name, "Category") { + return t.Name + } + } + return "" +} + +// checkSwitchArms validates two invariants against the located switch: +// +// 1. Every Category in canonicalCategories appears as a case expression. +// 2. The switch has a default arm whose body returns a non-nil expression +// (i.e. fails closed to InternalError). +func checkSwitchArms(path string, fset *token.FileSet, sw *ast.SwitchStmt) []Violation { + covered := map[string]struct{}{} + var defaultArm *ast.CaseClause + for _, stmt := range sw.Body.List { + cc, ok := stmt.(*ast.CaseClause) + if !ok { + continue + } + if cc.List == nil { + defaultArm = cc + continue + } + for _, expr := range cc.List { + if name := categoryName(expr); name != "" { + covered[name] = struct{}{} + } + } + } + + var out []Violation + for _, cat := range canonicalCategories { + if _, ok := covered[cat]; ok { + continue + } + out = append(out, Violation{ + Rule: "build_api_error_arms", + Action: ActionReject, + File: path, + Line: fset.Position(sw.Pos()).Line, + Message: "BuildAPIError switch is missing explicit arm for errs." + cat, + Suggestion: "add a case clause for errs." + cat + " that routes to the matching typed *Error; " + + "the canonical taxonomy is fixed in errs/category.go and every Category must be handled", + }) + } + + if defaultArm == nil { + out = append(out, Violation{ + Rule: "build_api_error_arms", + Action: ActionReject, + File: path, + Line: fset.Position(sw.Pos()).Line, + Message: "BuildAPIError switch has no default arm — unknown Category would fall through and emit an empty Problem", + Suggestion: "add `default:` that fail-closes to `&errs.InternalError{Problem: ...SubtypeSDKError...}` " + + "so unrecognised Category values cannot produce a wire-invalid envelope", + }) + } else if !defaultReturnsInternalError(defaultArm) { + out = append(out, Violation{ + Rule: "build_api_error_arms", + Action: ActionReject, + File: path, + Line: fset.Position(defaultArm.Pos()).Line, + Message: "BuildAPIError default arm returns nil or has no return — must fail closed to InternalError", + Suggestion: "return `&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, ...}}` " + + "so an unrecognised Category never silently drops the failure", + }) + } + return out +} + +// defaultReturnsInternalError checks the default arm's body has a return +// statement whose first result is *errs.InternalError — either constructed +// via `&errs.InternalError{...}` composite literal or `errs.NewInternalError(...)` +// constructor. Accepts both selector form (`errs.InternalError`) and bare +// identifier (`InternalError`) so unit-test fixtures with a local stub +// package match. The BuildAPIError default arm MUST fail closed to +// InternalError; other typed errors (APIError, etc.) silently drop the +// "unknown Category" signal and are rejected by this rule. +func defaultReturnsInternalError(cc *ast.CaseClause) bool { + for _, stmt := range cc.Body { + ret, ok := stmt.(*ast.ReturnStmt) + if !ok || len(ret.Results) == 0 { + continue + } + if isInternalErrorReturn(ret.Results[0]) { + return true + } + } + return false +} + +func isInternalErrorReturn(expr ast.Expr) bool { + switch e := expr.(type) { + case *ast.UnaryExpr: + // &errs.InternalError{...} or &InternalError{...} + if e.Op != token.AND { + return false + } + if cl, ok := e.X.(*ast.CompositeLit); ok { + return isInternalErrorType(cl.Type) + } + case *ast.CompositeLit: + // errs.InternalError{...} or InternalError{...} (value, rare for errors) + return isInternalErrorType(e.Type) + case *ast.CallExpr: + // errs.NewInternalError(...) or NewInternalError(...) + return isNewInternalErrorCall(e.Fun) + } + return false +} + +func isInternalErrorType(t ast.Expr) bool { + switch x := t.(type) { + case *ast.SelectorExpr: + return x.Sel.Name == "InternalError" + case *ast.Ident: + return x.Name == "InternalError" + } + return false +} + +func isNewInternalErrorCall(fn ast.Expr) bool { + switch x := fn.(type) { + case *ast.SelectorExpr: + return x.Sel.Name == "NewInternalError" + case *ast.Ident: + return x.Name == "NewInternalError" + } + return false +} diff --git a/lint/errscontract/rule_builder_immutable.go b/lint/errscontract/rule_builder_immutable.go new file mode 100644 index 000000000..4591399b4 --- /dev/null +++ b/lint/errscontract/rule_builder_immutable.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errscontract + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" +) + +// CheckBuilderImmutable enforces builder immutability: a `With*` method on +// a typed *Error must not stash a caller-provided slice or map directly +// into a receiver field. The caller can later mutate the slice/map +// (append, delete) and silently corrupt the already-emitted typed envelope. +// +// Required shape — defensive clone: +// +// func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError { +// e.MissingScopes = slices.Clone(scopes) +// return e +// } +// +// Violating shape — raw assignment: +// +// func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError { +// e.MissingScopes = scopes +// return e +// } +// +// Detection strategy (AST-only, no type info): +// - Method name starts with "With" and takes at least one parameter +// - One parameter is a slice (`[]T`), variadic (`...T`), or map (`map[K]V`) +// - The method body contains `e. = ` where +// is exactly the slice/map parameter, with no slices.Clone / maps.Clone +// wrapper. +// +// Scope: errs/ package files (typed builders live there). +// +// Returns REJECT violations. +func CheckBuilderImmutable(path, src string) []Violation { + if !isErrsPackagePath(path) { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return nil + } + + var out []Violation + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil || fn.Body == nil { + continue + } + if fn.Name == nil || !strings.HasPrefix(fn.Name.Name, "With") { + continue + } + // Only fire on methods whose receiver is a typed *Error from this package. + recvType := receiverTypeName(fn.Recv.List[0].Type) + if recvType == "" || !strings.HasSuffix(recvType, "Error") || recvType == "Error" { + continue + } + refParams := collectReferenceTypeParams(fn.Type) + if len(refParams) == 0 { + continue + } + out = append(out, scanBuilderBody(path, fset, fn, refParams)...) + } + return out +} + +// collectReferenceTypeParams returns the names of parameters whose type +// is a slice, variadic, or map (the reference-mutable shapes the rule +// guards). Pointer-to-slice / pointer-to-map are also considered. +func collectReferenceTypeParams(ft *ast.FuncType) map[string]struct{} { + out := map[string]struct{}{} + if ft.Params == nil { + return out + } + for _, field := range ft.Params.List { + if !isReferenceType(field.Type) { + continue + } + for _, n := range field.Names { + if n.Name != "" && n.Name != "_" { + out[n.Name] = struct{}{} + } + } + } + return out +} + +// isReferenceType reports whether expr names a slice, variadic, or map. +// Pointer-to-slice / map are also reference-typed for our purposes. +func isReferenceType(expr ast.Expr) bool { + switch t := expr.(type) { + case *ast.ArrayType: + // nil Len → slice (`[]T`). Fixed-length arrays are value types. + return t.Len == nil + case *ast.MapType: + return true + case *ast.Ellipsis: + return true + case *ast.StarExpr: + return isReferenceType(t.X) + } + return false +} + +// scanBuilderBody walks fn.Body and emits a violation for each +// `recv. = ` assignment whose RHS is a bare reference- +// typed parameter (not wrapped in slices.Clone / maps.Clone). +func scanBuilderBody(path string, fset *token.FileSet, fn *ast.FuncDecl, refParams map[string]struct{}) []Violation { + var out []Violation + recvName := "" + if len(fn.Recv.List[0].Names) > 0 { + recvName = fn.Recv.List[0].Names[0].Name + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + assign, ok := n.(*ast.AssignStmt) + if !ok || (assign.Tok != token.ASSIGN && assign.Tok != token.DEFINE) { + return true + } + if len(assign.Lhs) != 1 || len(assign.Rhs) != 1 { + return true + } + // LHS must be `recv.Field`. + sel, ok := assign.Lhs[0].(*ast.SelectorExpr) + if !ok { + return true + } + if recvName != "" && !isIdent(sel.X, recvName) { + return true + } + // RHS must be a bare reference-typed parameter ident with no + // defensive Clone wrapper. + paramID, ok := assign.Rhs[0].(*ast.Ident) + if !ok { + return true + } + if _, isRef := refParams[paramID.Name]; !isRef { + return true + } + fieldName := "" + if sel.Sel != nil { + fieldName = sel.Sel.Name + } + out = append(out, Violation{ + Rule: "builder_immutable", + Action: ActionReject, + File: path, + Line: fset.Position(assign.Pos()).Line, + Message: fn.Name.Name + " stashes caller-owned " + paramID.Name + " into " + fieldName + " without defensive copy", + Suggestion: "wrap the assignment with slices.Clone / maps.Clone (e.g. `" + + sel.Sel.Name + " = slices.Clone(" + paramID.Name + ")`); raw assignment lets the caller mutate the already-emitted typed envelope", + }) + return true + }) + return out +} diff --git a/lint/errscontract/rule_new_invariants_test.go b/lint/errscontract/rule_new_invariants_test.go new file mode 100644 index 000000000..c82fa3b30 --- /dev/null +++ b/lint/errscontract/rule_new_invariants_test.go @@ -0,0 +1,600 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errscontract + +import ( + "strings" + "testing" +) + +// Tests for the four typed-error invariant rules: +// - CheckNilSafeError +// - CheckUnwrapSymmetry +// - CheckBuilderImmutable +// - CheckBuildAPIErrorArms +// +// Each rule gets a "rejects bad shape" + "accepts compliant shape" pair so +// future regressions reveal themselves immediately. Fixtures use the +// minimal canonical Problem-embedder shape and run through the public +// CheckXxx entry points — no internal helpers are exercised directly. + +// =============================== CheckNilSafeError =========================== + +func TestCheckNilSafeError_FlagsMissingOverride(t *testing.T) { + // BadError embeds Problem by value but defines no own Error() — + // the promoted Error() panics on a typed-nil interface holder. + src := `package errs + +type Problem struct{} +func (p *Problem) Error() string { return "" } + +type BadError struct { + Problem +} +` + v := CheckNilSafeError("errs/types.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + if !strings.Contains(v[0].Message, "BadError") { + t.Errorf("message must name the type: %s", v[0].Message) + } +} + +func TestCheckNilSafeError_FlagsMissingNilGuard(t *testing.T) { + // Error() exists but the first statement is not the nil-receiver guard. + src := `package errs + +type Problem struct{} +func (p *Problem) Error() string { return "" } + +type BadError struct { + Problem +} + +func (e *BadError) Error() string { + return e.Problem.Error() +} +` + v := CheckNilSafeError("errs/types.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "nil-receiver guard") { + t.Errorf("message should call out the missing nil guard: %s", v[0].Message) + } +} + +func TestCheckNilSafeError_AcceptsCompliantOverride(t *testing.T) { + src := `package errs + +type Problem struct{} +func (p *Problem) Error() string { return "" } + +type GoodError struct { + Problem +} + +func (e *GoodError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} +` + v := CheckNilSafeError("errs/types.go", src) + if len(v) != 0 { + t.Errorf("compliant nil-safe Error() must pass, got: %+v", v) + } +} + +func TestCheckNilSafeError_ScopedToErrsPackage(t *testing.T) { + // Same violating fixture outside errs/ — must NOT fire (the typed + // taxonomy is errs/-only). + src := `package custom + +type Problem struct{} +func (p *Problem) Error() string { return "" } + +type BadError struct { + Problem +} +` + v := CheckNilSafeError("internal/custom/x.go", src) + if len(v) != 0 { + t.Errorf("CheckNilSafeError must scope to errs/ only, got: %+v", v) + } +} + +// ============================== CheckUnwrapSymmetry ========================== + +func TestCheckUnwrapSymmetry_FlagsMissingUnwrap(t *testing.T) { + src := `package errs + +type Problem struct{} +func (p *Problem) Error() string { return "" } + +type BadError struct { + Problem + Cause error +} +` + v := CheckUnwrapSymmetry("errs/types.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + if !strings.Contains(v[0].Message, "BadError") { + t.Errorf("message must name the type: %s", v[0].Message) + } +} + +func TestCheckUnwrapSymmetry_FlagsUnwrapWithoutNilGuard(t *testing.T) { + src := `package errs + +type Problem struct{} +func (p *Problem) Error() string { return "" } + +type BadError struct { + Problem + Cause error +} + +func (e *BadError) Unwrap() error { + return e.Cause +} +` + v := CheckUnwrapSymmetry("errs/types.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "nil-receiver guard") { + t.Errorf("message should call out the missing nil guard: %s", v[0].Message) + } +} + +func TestCheckUnwrapSymmetry_AcceptsCompliantUnwrap(t *testing.T) { + src := `package errs + +type Problem struct{} +func (p *Problem) Error() string { return "" } + +type GoodError struct { + Problem + Cause error +} + +func (e *GoodError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} +` + v := CheckUnwrapSymmetry("errs/types.go", src) + if len(v) != 0 { + t.Errorf("compliant Unwrap() must pass, got: %+v", v) + } +} + +// ============================= CheckBuilderImmutable ========================= + +func TestCheckBuilderImmutable_FlagsBareSliceAssignment(t *testing.T) { + src := `package errs + +type Problem struct{} + +type PermissionError struct { + Problem + MissingScopes []string +} + +func (e *PermissionError) WithMissingScopes(scopes []string) *PermissionError { + e.MissingScopes = scopes + return e +} +` + v := CheckBuilderImmutable("errs/types.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + if !strings.Contains(v[0].Message, "WithMissingScopes") || !strings.Contains(v[0].Message, "MissingScopes") { + t.Errorf("message should name the builder and field: %s", v[0].Message) + } +} + +func TestCheckBuilderImmutable_FlagsBareVariadicAssignment(t *testing.T) { + // Variadic slice has the same aliasing hazard. + src := `package errs + +type Problem struct{} + +type PermissionError struct { + Problem + MissingScopes []string +} + +func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError { + e.MissingScopes = scopes + return e +} +` + v := CheckBuilderImmutable("errs/types.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } +} + +func TestCheckBuilderImmutable_FlagsBareMapAssignment(t *testing.T) { + src := `package errs + +type Problem struct{} + +type APIError struct { + Problem + Detail map[string]any +} + +func (e *APIError) WithDetail(detail map[string]any) *APIError { + e.Detail = detail + return e +} +` + v := CheckBuilderImmutable("errs/types.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "WithDetail") { + t.Errorf("message should name the builder: %s", v[0].Message) + } +} + +func TestCheckBuilderImmutable_AcceptsClonedSlice(t *testing.T) { + src := `package errs + +import "slices" + +type Problem struct{} + +type PermissionError struct { + Problem + MissingScopes []string +} + +func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError { + e.MissingScopes = slices.Clone(scopes) + return e +} +` + v := CheckBuilderImmutable("errs/types.go", src) + if len(v) != 0 { + t.Errorf("slices.Clone wrap must pass, got: %+v", v) + } +} + +func TestCheckBuilderImmutable_AcceptsClonedMap(t *testing.T) { + src := `package errs + +import "maps" + +type Problem struct{} + +type APIError struct { + Problem + Detail map[string]any +} + +func (e *APIError) WithDetail(detail map[string]any) *APIError { + e.Detail = maps.Clone(detail) + return e +} +` + v := CheckBuilderImmutable("errs/types.go", src) + if len(v) != 0 { + t.Errorf("maps.Clone wrap must pass, got: %+v", v) + } +} + +func TestCheckBuilderImmutable_IgnoresScalarSetters(t *testing.T) { + // Scalar / string setters are not reference-typed; the rule must not + // false-positive on them. + src := `package errs + +type Problem struct{} + +type ConfigError struct { + Problem + Field string +} + +func (e *ConfigError) WithField(field string) *ConfigError { + e.Field = field + return e +} + +func (e *ConfigError) WithCode(code int) *ConfigError { + e.Code = code + return e +} +` + v := CheckBuilderImmutable("errs/types.go", src) + if len(v) != 0 { + t.Errorf("scalar setters must not fire, got: %+v", v) + } +} + +// ============================ CheckBuildAPIErrorArms ========================= + +func TestCheckBuildAPIErrorArms_FlagsMissingCategory(t *testing.T) { + // Switch is missing the CategoryConfirmation arm. + src := `package errclass + +import "github.com/larksuite/cli/errs" + +type ClassifyContext struct{} + +func BuildAPIError(resp map[string]any, cc ClassifyContext) error { + var cat errs.Category + switch cat { + case errs.CategoryValidation: + return &errs.ValidationError{} + case errs.CategoryAuthentication: + return &errs.AuthenticationError{} + case errs.CategoryAuthorization: + return &errs.PermissionError{} + case errs.CategoryConfig: + return &errs.ConfigError{} + case errs.CategoryNetwork: + return &errs.NetworkError{} + case errs.CategoryAPI: + return &errs.APIError{} + case errs.CategoryPolicy: + return &errs.SecurityPolicyError{} + case errs.CategoryInternal: + return &errs.InternalError{} + default: + return &errs.InternalError{} + } +} +` + v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation (missing CategoryConfirmation), got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "CategoryConfirmation") { + t.Errorf("message should name the missing Category: %s", v[0].Message) + } +} + +func TestCheckBuildAPIErrorArms_FlagsMissingDefault(t *testing.T) { + src := `package errclass + +import "github.com/larksuite/cli/errs" + +type ClassifyContext struct{} + +func BuildAPIError(resp map[string]any, cc ClassifyContext) error { + var cat errs.Category + switch cat { + case errs.CategoryValidation: + return &errs.ValidationError{} + case errs.CategoryAuthentication: + return &errs.AuthenticationError{} + case errs.CategoryAuthorization: + return &errs.PermissionError{} + case errs.CategoryConfig: + return &errs.ConfigError{} + case errs.CategoryNetwork: + return &errs.NetworkError{} + case errs.CategoryAPI: + return &errs.APIError{} + case errs.CategoryPolicy: + return &errs.SecurityPolicyError{} + case errs.CategoryInternal: + return &errs.InternalError{} + case errs.CategoryConfirmation: + return &errs.ConfirmationRequiredError{} + } + return nil +} +` + v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation (missing default arm), got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "no default arm") { + t.Errorf("message should call out the missing default: %s", v[0].Message) + } +} + +func TestCheckBuildAPIErrorArms_FlagsNilReturningDefault(t *testing.T) { + src := `package errclass + +import "github.com/larksuite/cli/errs" + +type ClassifyContext struct{} + +func BuildAPIError(resp map[string]any, cc ClassifyContext) error { + var cat errs.Category + switch cat { + case errs.CategoryValidation: + return &errs.ValidationError{} + case errs.CategoryAuthentication: + return &errs.AuthenticationError{} + case errs.CategoryAuthorization: + return &errs.PermissionError{} + case errs.CategoryConfig: + return &errs.ConfigError{} + case errs.CategoryNetwork: + return &errs.NetworkError{} + case errs.CategoryAPI: + return &errs.APIError{} + case errs.CategoryPolicy: + return &errs.SecurityPolicyError{} + case errs.CategoryInternal: + return &errs.InternalError{} + case errs.CategoryConfirmation: + return &errs.ConfirmationRequiredError{} + default: + return nil + } +} +` + v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation (default returns nil), got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "default arm") { + t.Errorf("message should call out the default arm: %s", v[0].Message) + } +} + +func TestCheckBuildAPIErrorArms_AcceptsCompliantSwitch(t *testing.T) { + src := `package errclass + +import "github.com/larksuite/cli/errs" + +type ClassifyContext struct{} + +func BuildAPIError(resp map[string]any, cc ClassifyContext) error { + var cat errs.Category + switch cat { + case errs.CategoryValidation: + return &errs.ValidationError{} + case errs.CategoryAuthentication: + return &errs.AuthenticationError{} + case errs.CategoryAuthorization: + return &errs.PermissionError{} + case errs.CategoryConfig: + return &errs.ConfigError{} + case errs.CategoryNetwork: + return &errs.NetworkError{} + case errs.CategoryAPI: + return &errs.APIError{} + case errs.CategoryPolicy: + return &errs.SecurityPolicyError{} + case errs.CategoryInternal: + return &errs.InternalError{} + case errs.CategoryConfirmation: + return &errs.ConfirmationRequiredError{} + default: + return &errs.InternalError{} + } +} +` + v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src) + if len(v) != 0 { + t.Errorf("compliant switch must pass, got: %+v", v) + } +} + +func TestCheckBuildAPIErrorArms_RejectsWrongCategoryDefault(t *testing.T) { + src := `package errclass + +import "github.com/larksuite/cli/errs" + +type ClassifyContext struct{} + +func BuildAPIError(resp map[string]any, cc ClassifyContext) error { + var cat errs.Category + switch cat { + case errs.CategoryValidation: + return &errs.ValidationError{} + case errs.CategoryAuthentication: + return &errs.AuthenticationError{} + case errs.CategoryAuthorization: + return &errs.PermissionError{} + case errs.CategoryConfig: + return &errs.ConfigError{} + case errs.CategoryNetwork: + return &errs.NetworkError{} + case errs.CategoryAPI: + return &errs.APIError{} + case errs.CategoryPolicy: + return &errs.SecurityPolicyError{} + case errs.CategoryInternal: + return &errs.InternalError{} + case errs.CategoryConfirmation: + return &errs.ConfirmationRequiredError{} + default: + return &errs.APIError{} + } +} +` + v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation (wrong-type default), got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "InternalError") { + t.Errorf("violation must call out InternalError requirement: %s", v[0].Message) + } +} + +func TestCheckBuildAPIErrorArms_AcceptsNewInternalErrorConstructor(t *testing.T) { + src := `package errclass + +import "github.com/larksuite/cli/errs" + +type ClassifyContext struct{} + +func BuildAPIError(resp map[string]any, cc ClassifyContext) error { + var cat errs.Category + switch cat { + case errs.CategoryValidation: + return &errs.ValidationError{} + case errs.CategoryAuthentication: + return &errs.AuthenticationError{} + case errs.CategoryAuthorization: + return &errs.PermissionError{} + case errs.CategoryConfig: + return &errs.ConfigError{} + case errs.CategoryNetwork: + return &errs.NetworkError{} + case errs.CategoryAPI: + return &errs.APIError{} + case errs.CategoryPolicy: + return &errs.SecurityPolicyError{} + case errs.CategoryInternal: + return &errs.InternalError{} + case errs.CategoryConfirmation: + return &errs.ConfirmationRequiredError{} + default: + return errs.NewInternalError(errs.SubtypeSDKError, "unknown category") + } +} +` + v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src) + if len(v) != 0 { + t.Errorf("constructor form must be accepted, got: %+v", v) + } +} + +func TestCheckBuildAPIErrorArms_ScopedToClassifyFile(t *testing.T) { + // Identical violating shape outside the canonical path — must NOT fire. + src := `package custom + +import "github.com/larksuite/cli/errs" + +func BuildAPIError(resp map[string]any) error { + var cat errs.Category + switch cat { + case errs.CategoryValidation: + return nil + } + return nil +} +` + v := CheckBuildAPIErrorArms("internal/foo/other.go", src) + if len(v) != 0 { + t.Errorf("rule must scope to internal/errclass/classify.go, got: %+v", v) + } +} diff --git a/lint/errscontract/rule_nil_safe_error.go b/lint/errscontract/rule_nil_safe_error.go new file mode 100644 index 000000000..966fadf8a --- /dev/null +++ b/lint/errscontract/rule_nil_safe_error.go @@ -0,0 +1,180 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errscontract + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" +) + +// CheckNilSafeError enforces that every typed *Error struct embedding +// Problem by value defines its own pointer-receiver Error() method whose +// first statement is a nil-receiver guard returning "". +// +// Why: the embedded Problem provides Error() via promotion, but a typed- +// nil interface holder (`var e *XxxError; var err error = e`) bypasses +// the promoted method's receiver guard and panics on err.Error(). +// Each typed wrapper therefore needs its own nil-safe override. +// +// Scope: errs/ package files. Unexported helper structs are skipped — +// they are not part of the public taxonomy. +// +// Returns REJECT violations. +func CheckNilSafeError(path, src string) []Violation { + if !isErrsPackagePath(path) { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return nil + } + + // Collect every exported struct embedding Problem-by-value. + embedders := collectProblemEmbedders(file) + if len(embedders) == 0 { + return nil + } + + // Find all Error() methods defined on those types (pointer or value receiver). + errorMethods := collectMethodsNamed(file, "Error") + + var out []Violation + for name, pos := range embedders { + fn, ok := errorMethods[name] + if !ok { + out = append(out, Violation{ + Rule: "nil_safe_error", + Action: ActionReject, + File: path, + Line: fset.Position(pos).Line, + Message: "typed error " + name + " embeds Problem by value but defines no own Error() — typed-nil holders will panic via promoted method", + Suggestion: "add `func (e *" + name + ") Error() string { if e == nil { return \"\" }; return e.Problem.Error() }` " + + "so an interface holding a typed-nil pointer returns \"\" instead of panicking", + }) + continue + } + if !hasNilReceiverGuard(fn) { + out = append(out, Violation{ + Rule: "nil_safe_error", + Action: ActionReject, + File: path, + Line: fset.Position(fn.Pos()).Line, + Message: "typed error " + name + ".Error() lacks `if e == nil { return \"\" }` nil-receiver guard", + Suggestion: "first statement of " + name + ".Error() must be the nil-receiver guard so typed-nil holders cannot panic", + }) + } + } + return out +} + +// collectProblemEmbedders returns the map of exported *Error struct names +// in the file that embed Problem (by value) → declaration position. +func collectProblemEmbedders(file *ast.File) map[string]token.Pos { + out := map[string]token.Pos{} + ast.Inspect(file, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok { + return true + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + return true + } + name := ts.Name.Name + if !ast.IsExported(name) || !strings.HasSuffix(name, "Error") || name == "Error" { + return true + } + if !embedsProblem(st) { + return true + } + out[name] = ts.Pos() + return true + }) + return out +} + +// collectMethodsNamed returns receiver-type name → FuncDecl for every +// method whose declared name matches methodName. The receiver type may +// be either `T` or `*T`; both forms are recorded under "T". +func collectMethodsNamed(file *ast.File, methodName string) map[string]*ast.FuncDecl { + out := map[string]*ast.FuncDecl{} + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil || len(fn.Recv.List) == 0 { + continue + } + if fn.Name == nil || fn.Name.Name != methodName { + continue + } + recv := receiverTypeName(fn.Recv.List[0].Type) + if recv == "" { + continue + } + out[recv] = fn + } + return out +} + +// receiverTypeName extracts T from a method receiver expression (`T` or `*T`). +func receiverTypeName(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + if id, ok := t.X.(*ast.Ident); ok { + return id.Name + } + } + return "" +} + +// hasNilReceiverGuard reports whether the first statement of fn is the +// canonical `if e == nil { return ... }` guard. The receiver name is read +// from fn.Recv so the check is robust to renamed receivers. +func hasNilReceiverGuard(fn *ast.FuncDecl) bool { + if fn.Body == nil || len(fn.Body.List) == 0 { + return false + } + recvName := "" + if len(fn.Recv.List) > 0 && len(fn.Recv.List[0].Names) > 0 { + recvName = fn.Recv.List[0].Names[0].Name + } + if recvName == "" { + return false + } + ifs, ok := fn.Body.List[0].(*ast.IfStmt) + if !ok { + return false + } + bin, ok := ifs.Cond.(*ast.BinaryExpr) + if !ok || bin.Op != token.EQL { + return false + } + // Accept either `recv == nil` or `nil == recv`. + if !isIdent(bin.X, recvName) || !isIdent(bin.Y, "nil") { + if !isIdent(bin.Y, recvName) || !isIdent(bin.X, "nil") { + return false + } + } + // Body must contain a ReturnStmt (we don't require empty-string return — + // some types return a more specific sentinel; the contract is "return + // without dereferencing the nil receiver"). + if ifs.Body == nil { + return false + } + for _, stmt := range ifs.Body.List { + if _, ok := stmt.(*ast.ReturnStmt); ok { + return true + } + } + return false +} + +func isIdent(expr ast.Expr, name string) bool { + id, ok := expr.(*ast.Ident) + return ok && id.Name == name +} diff --git a/lint/errscontract/rule_no_legacy_envelope_literal.go b/lint/errscontract/rule_no_legacy_envelope_literal.go new file mode 100644 index 000000000..b995b27f2 --- /dev/null +++ b/lint/errscontract/rule_no_legacy_envelope_literal.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errscontract + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" +) + +// migratedEnvelopePaths lists the source-tree prefixes that have been migrated +// to the typed errs.* taxonomy. On these paths, constructing a legacy +// output.ExitError / output.ErrDetail envelope literal directly is forbidden — +// call sites must return a typed errs.* error instead. Future domains opt in by +// appending their path prefix here. +var migratedEnvelopePaths = []string{ + "shortcuts/drive/", +} + +// legacyOutputImportPath is the import path of the package that declares the +// legacy ExitError / ErrDetail envelope types. The rule resolves whatever local +// name (default or alias) this path is bound to in each file, so an aliased +// import cannot bypass the check. +const legacyOutputImportPath = "github.com/larksuite/cli/internal/output" + +// CheckNoLegacyEnvelopeLiteral flags direct construction of legacy +// output.ExitError / output.ErrDetail composite literals on migrated paths. +// forbidigo can ban identifiers but not composite literals, so this AST rule +// covers the gap left after a path is migrated to typed errs.* errors. +// +// Path-scoped to migratedEnvelopePaths (mirrors how CheckProblemEmbed restricts +// by path); skips _test.go fixtures. output.ErrBare(...) is a CallExpr, not a +// CompositeLit, so the predicate exit-signal helper is naturally not flagged. +func CheckNoLegacyEnvelopeLiteral(path, src string) []Violation { + if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return nil + } + // Resolve the local name(s) bound to the legacy output import path. A file + // may bind it as the default `output`, an alias (`legacy "...output"`), or a + // dot-import (qualifier becomes ""), in which case ExitError/ErrDetail appear + // as bare unqualified idents. + localNames, dotImported := resolveLegacyOutputNames(file) + var out []Violation + ast.Inspect(file, func(n ast.Node) bool { + lit, ok := n.(*ast.CompositeLit) + if !ok { + return true + } + if name, ok := legacyEnvelopeTypeName(lit.Type, localNames, dotImported); ok { + out = append(out, Violation{ + Rule: "no_legacy_envelope_literal", + Action: ActionReject, + File: path, + Line: fset.Position(lit.Pos()).Line, + Message: "direct construction of legacy output." + name + " is forbidden on migrated paths; return a typed errs.* error (output.ErrBare remains allowed for predicate exit signals)", + Suggestion: "replace the &output." + name + "{...} literal with a typed errs.* constructor " + + "(e.g. errs.NewValidationError / errs.NewAPIError / errs.NewNetworkError)", + }) + } + return true + }) + return out +} + +// isMigratedEnvelopePath reports whether path falls under any migrated path +// prefix in migratedEnvelopePaths. +func isMigratedEnvelopePath(path string) bool { + p := strings.ReplaceAll(path, "\\", "/") + for _, prefix := range migratedEnvelopePaths { + if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) { + return true + } + } + return false +} + +// resolveLegacyOutputNames walks the file's import declarations and returns the +// set of local names bound to legacyOutputImportPath, plus whether the path was +// dot-imported. Default imports bind the package's own name ("output"); aliased +// imports bind the alias; dot-imports bind names into the file scope. +func resolveLegacyOutputNames(file *ast.File) (map[string]struct{}, bool) { + names := make(map[string]struct{}) + dotImported := false + for _, imp := range file.Imports { + if imp.Path == nil { + continue + } + p := strings.Trim(imp.Path.Value, "`\"") + if p != legacyOutputImportPath { + continue + } + switch { + case imp.Name == nil: + // Default import: local name is the package name "output". + names["output"] = struct{}{} + case imp.Name.Name == ".": + dotImported = true + case imp.Name.Name == "_": + // Blank import cannot reference the types; ignore. + default: + names[imp.Name.Name] = struct{}{} + } + } + return names, dotImported +} + +// legacyEnvelopeTypeName reports whether a composite-literal Type names the +// legacy ExitError / ErrDetail envelope and returns the bare type name. It +// matches a qualified selector (pkg.ExitError) when pkg is one of the resolved +// local names for the legacy output import, and — when the package was +// dot-imported — also matches a bare unqualified ExitError / ErrDetail ident. +func legacyEnvelopeTypeName(expr ast.Expr, localNames map[string]struct{}, dotImported bool) (string, bool) { + if sel, ok := expr.(*ast.SelectorExpr); ok { + x, ok := sel.X.(*ast.Ident) + if !ok || sel.Sel == nil { + return "", false + } + if _, bound := localNames[x.Name]; !bound { + return "", false + } + return matchLegacyEnvelopeName(sel.Sel.Name) + } + if dotImported { + if ident, ok := expr.(*ast.Ident); ok { + return matchLegacyEnvelopeName(ident.Name) + } + } + return "", false +} + +// matchLegacyEnvelopeName returns the name when it is one of the legacy +// envelope type names. +func matchLegacyEnvelopeName(name string) (string, bool) { + switch name { + case "ExitError", "ErrDetail": + return name, true + } + return "", false +} diff --git a/lint/errscontract/rule_no_legacy_runtime_api_call.go b/lint/errscontract/rule_no_legacy_runtime_api_call.go new file mode 100644 index 000000000..517087142 --- /dev/null +++ b/lint/errscontract/rule_no_legacy_runtime_api_call.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errscontract + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" +) + +// CheckNoLegacyRuntimeAPICall flags calls to the runtime's legacy +// auto-classifying API helpers (CallAPI / DoAPIJSON / DoAPIJSONWithLogID) on +// migrated paths. Those helpers route failures through common.HandleApiResult / +// doAPIJSON, which emit a legacy output.ExitError "api_error" envelope and +// downgrade an already-typed network / auth boundary error into an API error. +// forbidigo's errs-typed-only ban does not see them because they are method +// calls, not output.Err* identifiers — this AST rule covers that gap. +// +// Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use +// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into +// typed errs.* errors. +// +// Path-scoped to migratedEnvelopePaths; skips _test.go fixtures. A typed wrapper +// like driveCallAPI is an unqualified call (*ast.Ident), not a selector, so it +// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed: +// they return the raw response for the caller to classify and do not emit a +// legacy envelope themselves. +func CheckNoLegacyRuntimeAPICall(path, src string) []Violation { + if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return nil + } + var out []Violation + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel == nil { + return true + } + if name, ok := matchLegacyRuntimeAPIMethod(sel.Sel.Name); ok { + out = append(out, Violation{ + Rule: "no_legacy_runtime_api_call", + Action: ActionReject, + File: path, + Line: fset.Position(call.Pos()).Line, + Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths", + Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " + + "so failures classify into typed errs.* errors", + }) + } + return true + }) + return out +} + +// matchLegacyRuntimeAPIMethod returns the name when it is one of the runtime's +// legacy auto-classifying API helper methods. +func matchLegacyRuntimeAPIMethod(name string) (string, bool) { + switch name { + case "CallAPI", "DoAPIJSON", "DoAPIJSONWithLogID": + return name, true + } + return "", false +} diff --git a/lint/errscontract/rule_unwrap_symmetry.go b/lint/errscontract/rule_unwrap_symmetry.go new file mode 100644 index 000000000..eb7a72a33 --- /dev/null +++ b/lint/errscontract/rule_unwrap_symmetry.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errscontract + +import ( + "go/parser" + "go/token" +) + +// CheckUnwrapSymmetry enforces that every typed *Error struct embedding +// Problem defines its own nil-safe Unwrap() method, mirroring the Error() +// nil-guard contract enforced by CheckNilSafeError. +// +// Why: typed errors carry a Cause field and downstream callers traverse +// it via errors.Unwrap / errors.Is. A typed-nil holder +// (`var e *XxxError; var err error = e`) would otherwise dispatch through +// the embedded Problem.Unwrap (or panic when none exists), bypassing the +// type's own intent. +// +// Scope: errs/ package files. Unexported helper structs are skipped. +// +// Returns REJECT violations. +func CheckUnwrapSymmetry(path, src string) []Violation { + if !isErrsPackagePath(path) { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return nil + } + + embedders := collectProblemEmbedders(file) + if len(embedders) == 0 { + return nil + } + + unwrapMethods := collectMethodsNamed(file, "Unwrap") + + var out []Violation + for name, pos := range embedders { + fn, ok := unwrapMethods[name] + if !ok { + out = append(out, Violation{ + Rule: "unwrap_symmetry", + Action: ActionReject, + File: path, + Line: fset.Position(pos).Line, + Message: "typed error " + name + " embeds Problem but defines no own Unwrap() — typed-nil holders cannot be safely traversed by errors.Unwrap", + Suggestion: "add `func (e *" + name + ") Unwrap() error { if e == nil { return nil }; return e.Cause }` " + + "so the typed envelope and the standard errors.Is/Unwrap traversal stay in sync", + }) + continue + } + if !hasNilReceiverGuard(fn) { + out = append(out, Violation{ + Rule: "unwrap_symmetry", + Action: ActionReject, + File: path, + Line: fset.Position(fn.Pos()).Line, + Message: "typed error " + name + ".Unwrap() lacks `if e == nil { return nil }` nil-receiver guard", + Suggestion: "first statement of " + name + ".Unwrap() must be the nil-receiver guard so errors.Unwrap on a typed-nil holder returns nil instead of panicking", + }) + } + } + return out +} diff --git a/lint/errscontract/rules_test.go b/lint/errscontract/rules_test.go index ddb77ed28..9beed919d 100644 --- a/lint/errscontract/rules_test.go +++ b/lint/errscontract/rules_test.go @@ -593,3 +593,287 @@ func FooRegisterServiceMapBar(name string, _ interface{}) {} t.Errorf("message must name the offending call: %s", v[0].Message) } } + +// (F) direct legacy output.ExitError / output.ErrDetail literals on migrated +// paths → REJECT; output.ErrBare(...) calls and non-migrated paths pass. + +func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnDrivePath(t *testing.T) { + src := `package drive + +import "github.com/larksuite/cli/internal/output" + +func boom() error { + return &output.ExitError{Code: 1} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + if !strings.Contains(v[0].Message, "ExitError") { + t.Errorf("message should name the legacy type: %s", v[0].Message) + } +} + +func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) { + src := `package drive + +import "github.com/larksuite/cli/internal/output" + +func boom() *output.ErrDetail { + return &output.ErrDetail{Code: 7} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_common.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "ErrDetail") { + t.Errorf("message should name the legacy type: %s", v[0].Message) + } +} + +func TestCheckNoLegacyEnvelopeLiteral_AllowsErrBareCallOnDrivePath(t *testing.T) { + // output.ErrBare(...) is a CallExpr, not a CompositeLit — must NOT fire. + src := `package drive + +import "github.com/larksuite/cli/internal/output" + +func boom() error { + return output.ErrBare(output.ExitAPI) +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src) + if len(v) != 0 { + t.Errorf("ErrBare call should pass, got: %+v", v) + } +} + +func TestCheckNoLegacyEnvelopeLiteral_IgnoresNonMigratedPath(t *testing.T) { + // Same offending literal, but outside the migrated path set → not flagged. + src := `package other + +import "github.com/larksuite/cli/internal/output" + +func boom() error { + return &output.ExitError{Code: 1} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src) + if len(v) != 0 { + t.Errorf("non-migrated path should pass, got: %+v", v) + } +} + +func TestCheckNoLegacyEnvelopeLiteral_SkipsTestFiles(t *testing.T) { + src := `package drive + +import "github.com/larksuite/cli/internal/output" + +func boom() error { + return &output.ExitError{Code: 1} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_test.go", src) + if len(v) != 0 { + t.Errorf("_test.go file should be skipped, got: %+v", v) + } +} + +// TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport pins that an aliased +// import of internal/output cannot bypass the rule: the qualifier is resolved +// from the import declaration, not matched against the literal string "output". +func TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport(t *testing.T) { + src := `package drive + +import legacy "github.com/larksuite/cli/internal/output" + +func boom() error { + return &legacy.ExitError{Code: 1} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation for aliased import, got %d: %+v", len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + if !strings.Contains(v[0].Message, "ExitError") { + t.Errorf("message should name the legacy type: %s", v[0].Message) + } +} + +// TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected guards against a +// regression where resolving by import path accidentally drops the default +// (non-aliased) `output` case. +func TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected(t *testing.T) { + src := `package drive + +import "github.com/larksuite/cli/internal/output" + +func boom() error { + return &output.ExitError{Code: 1} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation for default import, got %d: %+v", len(v), v) + } +} + +// TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed: output.ErrBare is +// a CallExpr, not a composite literal — even under an alias it must not fire. +func TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed(t *testing.T) { + src := `package drive + +import legacy "github.com/larksuite/cli/internal/output" + +func boom() error { + return legacy.ErrBare(legacy.ExitAPI) +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src) + if len(v) != 0 { + t.Errorf("ErrBare call should pass, got: %+v", v) + } +} + +// TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport: a dot-import surfaces +// ExitError / ErrDetail as bare unqualified idents; the rule must still catch +// the composite literal. +func TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport(t *testing.T) { + src := `package drive + +import . "github.com/larksuite/cli/internal/output" + +func boom() error { + return &ExitError{Code: 1} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation for dot-import, got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "ExitError") { + t.Errorf("message should name the legacy type: %s", v[0].Message) + } +} + +// TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses: a same-named +// selector on an unrelated package (not the legacy output import path) must not +// trigger a false positive. +func TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses(t *testing.T) { + src := `package drive + +import "example.com/other/output" + +func boom() error { + return &output.ExitError{Code: 1} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src) + if len(v) != 0 { + t.Errorf("unrelated package selector must not fire, got: %+v", v) + } +} + +func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) { + src := `package drive + +func boom(runtime *common.RuntimeContext) error { + _, err := runtime.CallAPI("POST", "/x", nil, nil) + return err +} +` + v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + if !strings.Contains(v[0].Message, "CallAPI") { + t.Errorf("message should name the legacy method: %s", v[0].Message) + } +} + +func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) { + src := `package drive + +func boom(runtime *common.RuntimeContext) error { + _, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil) + return err +} +` + v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_export.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "DoAPIJSONWithLogID") { + t.Errorf("message should name the legacy method: %s", v[0].Message) + } +} + +func TestCheckNoLegacyRuntimeAPICall_AllowsTypedWrapperCall(t *testing.T) { + // driveCallAPI is an unqualified call (*ast.Ident), not a selector — must NOT fire. + src := `package drive + +func boom(runtime *common.RuntimeContext) error { + _, err := driveCallAPI(runtime, "POST", "/x", nil, nil) + return err +} +` + v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src) + if len(v) != 0 { + t.Errorf("typed wrapper call must not fire, got: %+v", v) + } +} + +func TestCheckNoLegacyRuntimeAPICall_AllowsRawAPIAndDoAPI(t *testing.T) { + // RawAPI / DoAPI return the raw response for the caller to classify and do + // not emit a legacy envelope — they are not banned. + src := `package drive + +func boom(runtime *common.RuntimeContext) error { + _, _ = runtime.RawAPI("POST", "/x", nil, nil) + _, err := runtime.DoAPI(nil) + return err +} +` + v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_api.go", src) + if len(v) != 0 { + t.Errorf("RawAPI / DoAPI must not fire, got: %+v", v) + } +} + +func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) { + src := `package im + +func boom(runtime *common.RuntimeContext) error { + _, err := runtime.CallAPI("POST", "/x", nil, nil) + return err +} +` + v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src) + if len(v) != 0 { + t.Errorf("non-migrated path must not fire, got: %+v", v) + } +} + +func TestCheckNoLegacyRuntimeAPICall_SkipsTestFiles(t *testing.T) { + src := `package drive + +func boom(runtime *common.RuntimeContext) error { + _, err := runtime.CallAPI("POST", "/x", nil, nil) + return err +} +` + v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder_test.go", src) + if len(v) != 0 { + t.Errorf("test files must be skipped, got: %+v", v) + } +} diff --git a/lint/errscontract/runner.go b/lint/errscontract/runner.go index 5d4945751..f28fd32bd 100644 --- a/lint/errscontract/runner.go +++ b/lint/errscontract/runner.go @@ -21,7 +21,14 @@ func RunAllWithNames(path, src string, allowlist, nameset map[string]struct{}) [ // CheckProblemEmbed fires on errs/ files only (caller may also enforce parity // across directory via CheckErrsContract). out = append(out, CheckProblemEmbed(path, src)...) + // The next three rules also scope to errs/ files internally — they + // guard the typed wrappers that live exclusively in this package. + out = append(out, CheckNilSafeError(path, src)...) + out = append(out, CheckUnwrapSymmetry(path, src)...) + out = append(out, CheckBuilderImmutable(path, src)...) } + // CheckBuildAPIErrorArms self-scopes to internal/errclass/classify.go. + out = append(out, CheckBuildAPIErrorArms(path, src)...) out = append(out, CheckNoRegistrar(path, src)...) out = append(out, CheckAdHocSubtype(path, src)...) out = append(out, CheckTypedErrorCompleteness(path, src)...) diff --git a/lint/errscontract/scan.go b/lint/errscontract/scan.go index 2149e45fb..dd4c54821 100644 --- a/lint/errscontract/scan.go +++ b/lint/errscontract/scan.go @@ -106,6 +106,13 @@ func ScanRepo(root string) ([]Violation, error) { all = append(all, CheckNoRegistrar(rel, string(src))...) all = append(all, CheckAdHocSubtype(rel, string(src))...) all = append(all, CheckTypedErrorCompleteness(rel, string(src))...) + all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...) + all = append(all, CheckNoLegacyRuntimeAPICall(rel, string(src))...) + // Typed-error invariants — self-scope to errs/ + classify.go. + all = append(all, CheckNilSafeError(rel, string(src))...) + all = append(all, CheckUnwrapSymmetry(rel, string(src))...) + all = append(all, CheckBuilderImmutable(rel, string(src))...) + all = append(all, CheckBuildAPIErrorArms(rel, string(src))...) if allowlist != nil && !isErrsScope(rel) { // CheckDeclaredSubtype does not fire inside the errs/ package itself — that // package defines the Subtype type and its constructors take diff --git a/lint/errscontract/scan_test.go b/lint/errscontract/scan_test.go index 7ffc63cd0..f51d34534 100644 --- a/lint/errscontract/scan_test.go +++ b/lint/errscontract/scan_test.go @@ -76,10 +76,10 @@ const ( SubtypeMissingScope Subtype = "missing_scope" ) `, - "errs/subtypes_service_task.go": `package errs + "errs/subtypes_extra.go": `package errs const ( - SubtypeTaskInvalidParams Subtype = "task_invalid_params" + SubtypeExtraExample Subtype = "extra_example" ) `, }) @@ -87,12 +87,12 @@ const ( if err != nil { t.Fatalf("LoadSubtypeAllowlists: %v", err) } - for _, v := range []string{"missing_scope", "task_invalid_params"} { + for _, v := range []string{"missing_scope", "extra_example"} { if _, ok := values[v]; !ok { t.Errorf("values missing %q (across-file load broken)", v) } } - for _, n := range []string{"SubtypeMissingScope", "SubtypeTaskInvalidParams"} { + for _, n := range []string{"SubtypeMissingScope", "SubtypeExtraExample"} { if _, ok := names[n]; !ok { t.Errorf("names missing %q (across-file load broken)", n) } diff --git a/package.json b/package.json index 2cd1473e6..8e41f25ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@larksuite/cli", - "version": "1.0.40", + "version": "1.0.46", "description": "The official CLI for Lark/Feishu open platform", "bin": { "lark-cli": "scripts/run.js" diff --git a/scripts/check-skill-wire-vocab.sh b/scripts/check-skill-wire-vocab.sh new file mode 100755 index 000000000..2aaecd0be --- /dev/null +++ b/scripts/check-skill-wire-vocab.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# §12.3: forward rule — any errs/ wire-shape change MUST be paired +# with a skills/ grep sweep in the same PR. +set -euo pipefail +PATTERN='"type"\s*:\s*"(auth_error|api_error|infra_error|missing_scope|command_denied|external_provider)"' +if git grep -E "$PATTERN" skills/ >/dev/null 2>&1; then + echo "[WIRE-VOCAB-DRIFT] skills/ contains legacy wire strings — see spec §12.3" >&2 + git grep -nE "$PATTERN" skills/ >&2 + exit 1 +fi +echo "skill wire-vocab clean." diff --git a/scripts/install.js b/scripts/install.js index 3e643f107..1f967d268 100644 --- a/scripts/install.js +++ b/scripts/install.js @@ -110,6 +110,52 @@ function getMirrorUrls(env) { return urls; } +/** + * Decide from a `curl --version` output whether curl is >= 7.70.0 — the + * release (2020-04-29) that introduced --ssl-revoke-best-effort. Kept pure + * (no I/O) so the version-comparison logic can be unit tested without + * spawning a process. Reads the leading "curl X.Y.Z" token, ignoring the + * trailing "libcurl/X.Y.Z" that may report a different version. + * + * @param {string} versionOutput raw stdout of `curl --version` + * @returns {boolean} true when the parsed version is >= 7.70.0 + */ +function isCurlVersionSupported(versionOutput) { + const match = String(versionOutput).match(/^\s*curl\s+(\d+)\.(\d+)\.(\d+)/i); + if (!match) return false; + const major = parseInt(match[1], 10); + const minor = parseInt(match[2], 10); + return major > 7 || (major === 7 && minor >= 70); +} + +// Memoized probe result. curl's version is invariant for the lifetime of the +// install, while download() runs once per mirror URL — so probe at most once. +let _curlSupportsSslRevokeBestEffort; + +/** + * Detect whether the system curl supports --ssl-revoke-best-effort. Older + * versions (notably the curl 7.55.1 shipped with older Windows 10 builds) + * exit with "unknown option" if the flag is passed. + * + * @returns {boolean} true when curl >= 7.70.0 is available + */ +function curlSupportsSslRevokeBestEffort() { + if (_curlSupportsSslRevokeBestEffort !== undefined) { + return _curlSupportsSslRevokeBestEffort; + } + try { + const output = execFileSync("curl", ["--version"], { + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf8", + timeout: 5000, + }); + _curlSupportsSslRevokeBestEffort = isCurlVersionSupported(output); + } catch (_) { + _curlSupportsSslRevokeBestEffort = false; + } + return _curlSupportsSslRevokeBestEffort; +} + function download(url, destPath) { assertAllowedHost(url); const args = [ @@ -119,8 +165,11 @@ function download(url, destPath) { "--output", destPath, ]; // --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE - // errors when the certificate revocation list server is unreachable - if (isWindows) args.unshift("--ssl-revoke-best-effort"); + // errors when the certificate revocation list server is unreachable. + // Only use it when the system curl is new enough (>= 7.70.0). + if (isWindows && curlSupportsSslRevokeBestEffort()) { + args.unshift("--ssl-revoke-best-effort"); + } args.push(url); execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] }); } @@ -294,4 +343,4 @@ if (require.main === module) { } } -module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls }; +module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, curlSupportsSslRevokeBestEffort, isCurlVersionSupported }; diff --git a/scripts/install.test.js b/scripts/install.test.js index 664cd2337..ad669613e 100644 --- a/scripts/install.test.js +++ b/scripts/install.test.js @@ -9,7 +9,7 @@ const os = require("os"); const crypto = require("crypto"); -const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls } = require("./install.js"); +const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, isCurlVersionSupported } = require("./install.js"); describe("getExpectedChecksum", () => { function makeTmpChecksums(content) { @@ -278,3 +278,55 @@ describe("resolveMirrorUrls", () => { ); }); }); + +describe("isCurlVersionSupported", () => { + // --ssl-revoke-best-effort was introduced in curl 7.70.0; below that the + // flag is unknown and `curl` exits non-zero (see issue #1099). + it("returns false for curl 7.55.1 (older Windows 10, flag unknown)", () => { + assert.equal( + isCurlVersionSupported("curl 7.55.1 (x86_64-pc-win32) libcurl/7.55.1"), + false + ); + }); + + it("returns false for curl 7.69.0 (just below the 7.70.0 threshold)", () => { + assert.equal( + isCurlVersionSupported("curl 7.69.0 (x86_64-pc-win32) libcurl/7.69.0"), + false + ); + }); + + it("returns true for curl 7.70.0 (flag introduced here)", () => { + assert.equal( + isCurlVersionSupported("curl 7.70.0 (x86_64-pc-win32) libcurl/7.70.0"), + true + ); + }); + + it("returns true for a future major (curl 8.x)", () => { + assert.equal( + isCurlVersionSupported("curl 8.5.0 (x86_64-apple-darwin) libcurl/8.5.0"), + true + ); + }); + + it("returns false when no version can be parsed", () => { + assert.equal(isCurlVersionSupported("not a curl version string"), false); + assert.equal(isCurlVersionSupported(""), false); + }); + + it("reads the leading 'curl X.Y.Z', not the trailing libcurl/X.Y.Z", () => { + // Guards the regex against latching onto "libcurl/7.55.1" when the + // curl binary itself is new enough. + assert.equal( + isCurlVersionSupported("curl 8.0.0 (x86_64) libcurl/7.55.1"), + true + ); + }); + + it("does not match a 'libcurl X.Y.Z' token (anchored to leading curl)", () => { + // "libcurl 8.0.0" contains the substring "curl 8.0.0"; the leading + // anchor keeps it from being mistaken for a real curl version line. + assert.equal(isCurlVersionSupported("libcurl 8.0.0"), false); + }); +}); diff --git a/shortcuts/apps/apps_access_scope_get.go b/shortcuts/apps/apps_access_scope_get.go index 5bb5382c4..df1a8cfe2 100644 --- a/shortcuts/apps/apps_access_scope_get.go +++ b/shortcuts/apps/apps_access_scope_get.go @@ -21,7 +21,7 @@ var AppsAccessScopeGet = common.Shortcut{ Command: "+access-scope-get", Description: "Get Miaoda app access scope configuration", Risk: "read", - Scopes: []string{"spark:app.access_scope:read"}, + Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ diff --git a/shortcuts/apps/apps_access_scope_set.go b/shortcuts/apps/apps_access_scope_set.go index 1d41d1e92..f9f474fae 100644 --- a/shortcuts/apps/apps_access_scope_set.go +++ b/shortcuts/apps/apps_access_scope_set.go @@ -27,7 +27,7 @@ var AppsAccessScopeSet = common.Shortcut{ Command: "+access-scope-set", Description: "Set Miaoda app access scope (specific / public / tenant)", Risk: "write", - Scopes: []string{"spark:app.access_scope:write"}, + Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ diff --git a/shortcuts/apps/apps_html_publish.go b/shortcuts/apps/apps_html_publish.go index 18ac87520..64cc5ae79 100644 --- a/shortcuts/apps/apps_html_publish.go +++ b/shortcuts/apps/apps_html_publish.go @@ -21,7 +21,7 @@ var AppsHTMLPublish = common.Shortcut{ Command: "+html-publish", Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)", Risk: "write", - Scopes: []string{"spark:app:publish"}, + Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ diff --git a/shortcuts/base/base_advperm_disable.go b/shortcuts/base/base_advperm_disable.go index 3266d9bca..59266bb19 100644 --- a/shortcuts/base/base_advperm_disable.go +++ b/shortcuts/base/base_advperm_disable.go @@ -25,6 +25,10 @@ var BaseAdvpermDisable = common.Shortcut{ Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, }, + Tips: []string{ + baseHighRiskYesTip, + "Disabling advanced permissions invalidates existing custom roles; confirm the target Base before passing --yes.", + }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { return common.FlagErrorf("--base-token must not be blank") diff --git a/shortcuts/base/base_advperm_enable.go b/shortcuts/base/base_advperm_enable.go index 2f7437ca4..03a248e23 100644 --- a/shortcuts/base/base_advperm_enable.go +++ b/shortcuts/base/base_advperm_enable.go @@ -25,6 +25,9 @@ var BaseAdvpermEnable = common.Shortcut{ Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, }, + Tips: []string{ + "Caller must be a Base admin; enable advanced permissions before creating or updating roles.", + }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { return common.FlagErrorf("--base-token must not be blank") diff --git a/shortcuts/base/base_copy.go b/shortcuts/base/base_copy.go index cb12e0e01..5e2210a85 100644 --- a/shortcuts/base/base_copy.go +++ b/shortcuts/base/base_copy.go @@ -24,6 +24,11 @@ var BaseBaseCopy = common.Shortcut{ {Name: "without-content", Type: "bool", Desc: "copy structure only"}, {Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"}, }, + Tips: []string{ + `Example: lark-cli base +base-copy --base-token --name "Copy of Project Tracker"`, + "Use --without-content when the user wants only structure.", + "If copied as bot, output may include permission_grant; report it so the user knows whether they can open the new Base.", + }, DryRun: dryRunBaseCopy, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeBaseCopy(runtime) diff --git a/shortcuts/base/base_create.go b/shortcuts/base/base_create.go index af4286638..47d5e27ed 100644 --- a/shortcuts/base/base_create.go +++ b/shortcuts/base/base_create.go @@ -22,6 +22,10 @@ var BaseBaseCreate = common.Shortcut{ {Name: "folder-token", Desc: "folder token for destination"}, {Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"}, }, + Tips: []string{ + `Example: lark-cli base +base-create --name "Project Tracker" --time-zone Asia/Shanghai`, + "If created as bot, output may include permission_grant; report it so the user knows whether they can open the new Base.", + }, DryRun: dryRunBaseCreate, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeBaseCreate(runtime) diff --git a/shortcuts/base/base_dashboard_execute_test.go b/shortcuts/base/base_dashboard_execute_test.go index 845446779..62f56d0d9 100644 --- a/shortcuts/base/base_dashboard_execute_test.go +++ b/shortcuts/base/base_dashboard_execute_test.go @@ -268,6 +268,39 @@ func TestBaseDashboardBlockExecuteGet(t *testing.T) { }) } +// TestBaseDashboardBlockExecuteGetData tests the +dashboard-block-get-data command. +func TestBaseDashboardBlockExecuteGetData(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/dashboards/blocks/blk_chart/data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "dimensions": []interface{}{ + map[string]interface{}{"field_name": "文本", "alias": "dim_text"}, + }, + "measures": []interface{}{ + map[string]interface{}{"field_name": "Bitable_Dashboard_Count", "aggregation": "count_all", "alias": "me_count"}, + }, + "main_data": []interface{}{ + map[string]interface{}{ + "dim_text": map[string]interface{}{"value": "A"}, + "me_count": map[string]interface{}{"value": 3}, + }, + }, + }, + }, + }) + if err := runShortcut(t, BaseDashboardBlockGetData, []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_chart"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"dimensions"`) || !strings.Contains(got, `"main_data"`) || !strings.Contains(got, `"dim_text"`) { + t.Fatalf("stdout=%s", got) + } +} + // TestBaseDashboardBlockExecuteCreate tests the +dashboard-block-create command. func TestBaseDashboardBlockExecuteCreate(t *testing.T) { t.Run("with data-config", func(t *testing.T) { @@ -537,6 +570,19 @@ func TestBaseDashboardBlockDryRun_Get(t *testing.T) { } } +// TestBaseDashboardBlockDryRun_GetData tests the +dashboard-block-get-data --dry-run flag. +func TestBaseDashboardBlockDryRun_GetData(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_a", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardBlockGetData, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/blocks/blk_a/data") || !strings.Contains(got, "blk_a") { + t.Fatalf("stdout=%s", got) + } +} + // TestBaseDashboardBlockDryRun_Create tests the +dashboard-block-create --dry-run flag. func TestBaseDashboardBlockDryRun_Create(t *testing.T) { factory, stdout, _ := newExecuteFactory(t) diff --git a/shortcuts/base/base_data_query.go b/shortcuts/base/base_data_query.go index f3724c1b3..22af5c9fb 100644 --- a/shortcuts/base/base_data_query.go +++ b/shortcuts/base/base_data_query.go @@ -20,7 +20,12 @@ var BaseDataQuery = common.Shortcut{ AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), - {Name: "dsl", Desc: "query JSON DSL (LiteQuery Protocol)", Required: true}, + {Name: "dsl", Desc: "query JSON DSL; read lark-base-data-query-guide.md first, then lark-base-data-query.md for the full DSL SSOT", Required: true}, + }, + Tips: []string{ + "Use +data-query for server-side aggregation, grouping, filtering, sorting, and Top N queries.", + "Read lark-base-data-query-guide.md for common fewshots; use lark-base-data-query.md only when the full DSL reference is needed.", + "`dimensions` and `measures` cannot both be empty.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { var dsl map[string]interface{} diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index f25b99ac2..49c230114 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -71,6 +71,29 @@ func TestDryRunRecordOps(t *testing.T) { ) assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age") + filteredListRT := newBaseTestRuntimeWithArrays( + map[string]string{ + "base-token": "app_x", + "table-id": "tbl_1", + "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`, + "sort-json": `[{"field":"Due","desc":true}]`, + }, + nil, + nil, + map[string]int{"limit": 20}, + ) + assertDryRunContains( + t, + dryRunRecordList(ctx, filteredListRT), + "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", + "limit=20", + "filter=%7B", + "Status", + "Todo", + "sort=%5B", + "Due", + ) + commaFieldRT := newBaseTestRuntimeWithArrays( map[string]string{"base-token": "app_x", "table-id": "tbl_1"}, map[string][]string{"field-id": {"A,B", "C"}}, @@ -99,6 +122,33 @@ func TestDryRunRecordOps(t *testing.T) { `"limit":500`, ) + searchFlagRT := newBaseTestRuntimeWithArrays( + map[string]string{ + "base-token": "app_x", + "table-id": "tbl_1", + "keyword": "Alice", + "view-id": "viw_1", + "filter-json": `{"logic":"and","conditions":[["Status","!=","Done"]]}`, + "sort-json": `[{"field":"Updated At","desc":true}]`, + }, + map[string][]string{ + "search-field": {"Name"}, + "field-id": {"Name", "Status"}, + }, + nil, + map[string]int{"limit": 20}, + ) + assertDryRunContains( + t, + dryRunRecordSearch(ctx, searchFlagRT), + "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/search", + `"keyword":"Alice"`, + `"search_fields":["Name"]`, + `"select_fields":["Name","Status"]`, + `"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`, + `"sort":[{"desc":true,"field":"Updated At"}]`, + ) + upsertCreateRT := newBaseTestRuntime( map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`}, nil, nil, diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index d21984000..e1ed7ee23 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -11,6 +11,7 @@ import ( "image" "image/color" "image/png" + "net/http" "net/url" "os" "path/filepath" @@ -514,7 +515,7 @@ func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) { if !strings.Contains(err.Error(), "--json must be a JSON object") { t.Fatalf("err=%v", err) } - if !strings.Contains(err.Error(), "lark-base skill") { + if !strings.Contains(err.Error(), "match the documented shape") { t.Fatalf("err=%v", err) } if strings.Contains(err.Error(), "array") { @@ -973,7 +974,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { "+record-search", "--base-token", "app_x", "--table-id", "tbl_x", - "--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`, + "--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"filter":{"logic":"and","conditions":[["Status","!=","Done"]]},"sort":{"sort_config":[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]},"offset":0,"limit":2}`, "--format", "json", }, factory, @@ -989,12 +990,121 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { !strings.Contains(body, `"keyword":"Created"`) || !strings.Contains(body, `"search_fields":["Title","fld_owner"]`) || !strings.Contains(body, `"select_fields":["Title","fld_owner"]`) || + !strings.Contains(body, `"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`) || + !strings.Contains(body, `"sort":[{"desc":true,"field":"Updated At"},{"desc":false,"field":"Title"}]`) || !strings.Contains(body, `"offset":0`) || !strings.Contains(body, `"limit":2`) { t.Fatalf("captured body=%s", body) } }) + t.Run("search with flag filter sort and projection", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + searchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "fields": []interface{}{"Title", "Status"}, + "field_id_list": []interface{}{"fld_title", "fld_status"}, + "record_id_list": []interface{}{"rec_1"}, + "data": []interface{}{[]interface{}{"Created by AI", "Todo"}}, + "has_more": false, + }, + }, + } + reg.Register(searchStub) + if err := runShortcut( + t, + BaseRecordSearch, + []string{ + "+record-search", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--keyword", "Created", + "--search-field", "Title", + "--field-id", "Title", + "--field-id", "Status", + "--filter-json", `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`, + "--sort-json", `[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]`, + "--limit", "20", + "--format", "json", + }, + factory, + stdout, + ); err != nil { + t.Fatalf("err=%v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil { + t.Fatalf("captured body json err=%v body=%s", err, string(searchStub.CapturedBody)) + } + if body["keyword"] != "Created" || body["limit"].(float64) != 20 { + t.Fatalf("captured body=%#v", body) + } + filter := body["filter"].(map[string]interface{}) + if filter["logic"] != "and" { + t.Fatalf("filter=%#v", filter) + } + conditions := filter["conditions"].([]interface{}) + if len(conditions) != 2 { + t.Fatalf("conditions=%#v", conditions) + } + sortConfig := body["sort"].([]interface{}) + if len(sortConfig) != 2 { + t.Fatalf("sort=%#v", sortConfig) + } + firstSort := sortConfig[0].(map[string]interface{}) + if firstSort["field"] != "Updated At" || firstSort["desc"] != true { + t.Fatalf("sort=%#v", sortConfig) + } + }) + + t.Run("search with filter json file", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + tmp := t.TempDir() + withBaseWorkingDir(t, tmp) + if err := os.WriteFile(filepath.Join(tmp, "filter.json"), []byte(`{"logic":"or","conditions":[["Status","==","Todo"]]}`), 0600); err != nil { + t.Fatalf("write filter err=%v", err) + } + searchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "fields": []interface{}{"Title"}, + "record_id_list": []interface{}{"rec_1"}, + "data": []interface{}{[]interface{}{"A"}}, + "has_more": false, + }, + }, + } + reg.Register(searchStub) + if err := runShortcut( + t, + BaseRecordSearch, + []string{ + "+record-search", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--keyword", "A", + "--search-field", "Title", + "--filter-json", "@filter.json", + "--format", "json", + }, + factory, + stdout, + ); err != nil { + t.Fatalf("err=%v", err) + } + body := string(searchStub.CapturedBody) + if !strings.Contains(body, `"filter":{"conditions":[["Status","==","Todo"]],"logic":"or"}`) { + t.Fatalf("captured body=%s", body) + } + }) + t.Run("search markdown format", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -2200,7 +2310,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { } }) - t.Run("download reports progress when later attachment fails", func(t *testing.T) { + t.Run("download reports progress and log_id when later attachment fails", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -2228,8 +2338,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/drive/v1/medias/box_b/download", - Status: 500, + Status: 403, RawBody: []byte("server error"), + Headers: http.Header{"X-Tt-Logid": []string{"202605270001"}}, }) tmpDir := t.TempDir() @@ -2258,6 +2369,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" { t.Fatalf("detail=%#v", exitErr.Detail.Detail) } + if detail["log_id"] != "202605270001" { + t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail) + } if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil { t.Fatalf("expected first file to remain: %v", err) } diff --git a/shortcuts/base/base_form_create.go b/shortcuts/base/base_form_create.go index 978d24463..238892aa7 100644 --- a/shortcuts/base/base_form_create.go +++ b/shortcuts/base/base_form_create.go @@ -25,6 +25,9 @@ var BaseFormCreate = common.Shortcut{ {Name: "name", Desc: "form name", Required: true}, {Name: "description", Desc: `form description (plain text or markdown link like [text](https://example.com))`}, }, + Tips: []string{ + "Record the returned form_id; form question create/list/update/delete commands need it.", + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms"). diff --git a/shortcuts/base/base_form_delete.go b/shortcuts/base/base_form_delete.go index f6be4691e..75735ba70 100644 --- a/shortcuts/base/base_form_delete.go +++ b/shortcuts/base/base_form_delete.go @@ -22,6 +22,10 @@ var BaseFormDelete = common.Shortcut{ {Name: "table-id", Desc: "table ID", Required: true}, {Name: "form-id", Desc: "form ID", Required: true}, }, + Tips: []string{ + "Use +form-list or +form-get first when the form target is ambiguous.", + baseHighRiskYesTip, + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id"). diff --git a/shortcuts/base/base_form_list.go b/shortcuts/base/base_form_list.go index 07ead6e5b..34f5bfe0f 100644 --- a/shortcuts/base/base_form_list.go +++ b/shortcuts/base/base_form_list.go @@ -23,7 +23,7 @@ var BaseFormsList = common.Shortcut{ Flags: []common.Flag{ {Name: "base-token", Desc: "Base token (base_token)", Required: true}, {Name: "table-id", Desc: "table ID", Required: true}, - {Name: "page-size", Type: "int", Default: "100", Desc: "page size per request (max 100)"}, + {Name: "page-size", Type: "int", Default: "100", Desc: "page size per request, max 100"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). diff --git a/shortcuts/base/base_form_questions_delete.go b/shortcuts/base/base_form_questions_delete.go index 4a3b03873..8b1472220 100644 --- a/shortcuts/base/base_form_questions_delete.go +++ b/shortcuts/base/base_form_questions_delete.go @@ -25,6 +25,9 @@ var BaseFormQuestionsDelete = common.Shortcut{ {Name: "form-id", Desc: "form ID", Required: true}, {Name: "question-ids", Desc: `JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`, Required: true}, }, + Tips: []string{ + baseHighRiskYesTip, + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). diff --git a/shortcuts/base/base_form_questions_list.go b/shortcuts/base/base_form_questions_list.go index 050d3c358..3384fd3d9 100644 --- a/shortcuts/base/base_form_questions_list.go +++ b/shortcuts/base/base_form_questions_list.go @@ -25,6 +25,9 @@ var BaseFormQuestionsList = common.Shortcut{ {Name: "table-id", Desc: "table ID", Required: true}, {Name: "form-id", Desc: "form ID", Required: true}, }, + Tips: []string{ + "Use returned question id values for +form-questions-update and +form-questions-delete.", + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). diff --git a/shortcuts/base/base_get.go b/shortcuts/base/base_get.go index 48e7080ea..6a869ff4a 100644 --- a/shortcuts/base/base_get.go +++ b/shortcuts/base/base_get.go @@ -17,7 +17,10 @@ var BaseBaseGet = common.Shortcut{ Scopes: []string{"base:app:read"}, AuthTypes: authTypes(), Flags: []common.Flag{baseTokenFlag(true)}, - DryRun: dryRunBaseGet, + Tips: []string{ + "Use a real Base token; workspace tokens and wiki tokens are not accepted by this command.", + }, + DryRun: dryRunBaseGet, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeBaseGet(runtime) }, diff --git a/shortcuts/base/base_role_create.go b/shortcuts/base/base_role_create.go index 18b4cb2dc..cf887ece5 100644 --- a/shortcuts/base/base_role_create.go +++ b/shortcuts/base/base_role_create.go @@ -25,7 +25,12 @@ var BaseRoleCreate = common.Shortcut{ AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, - {Name: "json", Desc: `body JSON (AdvPermBaseRoleConfig), e.g. {"role_name":"Reviewer","role_type":"custom_role","table_rule_map":{...}}`, Required: true}, + {Name: "json", Desc: "role config JSON; read lark-base-role-guide.md and role-config.md before constructing permissions", Required: true}, + }, + Tips: []string{ + "Requires advanced permissions to be enabled and the caller to be a Base admin.", + "Use lark-base-role-guide.md as the entry guide and role-config.md as the role permission JSON SSOT.", + "Create supports custom_role only.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { diff --git a/shortcuts/base/base_role_delete.go b/shortcuts/base/base_role_delete.go index 0c5627fc3..9d7f14c4b 100644 --- a/shortcuts/base/base_role_delete.go +++ b/shortcuts/base/base_role_delete.go @@ -26,6 +26,12 @@ var BaseRoleDelete = common.Shortcut{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true}, }, + Tips: []string{ + baseHighRiskYesTip, + "Requires advanced permissions to be enabled and the caller to be a Base admin.", + "Only custom roles can be deleted; system roles cannot be deleted.", + "Use +role-get first if the role target is ambiguous, then pass --yes to confirm deletion.", + }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { return common.FlagErrorf("--base-token must not be blank") diff --git a/shortcuts/base/base_role_get.go b/shortcuts/base/base_role_get.go index ada1c9940..626b7f925 100644 --- a/shortcuts/base/base_role_get.go +++ b/shortcuts/base/base_role_get.go @@ -27,6 +27,10 @@ var BaseRoleGet = common.Shortcut{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true}, }, + Tips: []string{ + "Requires advanced permissions to be enabled and the caller to be a Base admin.", + "Use before +role-update to inspect the current full permission config.", + }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { return common.FlagErrorf("--base-token must not be blank") diff --git a/shortcuts/base/base_role_list.go b/shortcuts/base/base_role_list.go index 08bc29341..110192c12 100644 --- a/shortcuts/base/base_role_list.go +++ b/shortcuts/base/base_role_list.go @@ -26,6 +26,10 @@ var BaseRoleList = common.Shortcut{ Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, }, + Tips: []string{ + "Requires advanced permissions to be enabled and the caller to be a Base admin.", + "Returns role summaries; use +role-get for the full permission config.", + }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { return common.FlagErrorf("--base-token must not be blank") diff --git a/shortcuts/base/base_role_update.go b/shortcuts/base/base_role_update.go index 8c05c4d89..30e1e4ac3 100644 --- a/shortcuts/base/base_role_update.go +++ b/shortcuts/base/base_role_update.go @@ -26,7 +26,13 @@ var BaseRoleUpdate = common.Shortcut{ Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true}, - {Name: "json", Desc: `body JSON (delta AdvPermBaseRoleConfig), e.g. {"role_name":"New Name","role_type":"custom_role","table_rule_map":{...}}`, Required: true}, + {Name: "json", Desc: "delta role config JSON; read lark-base-role-guide.md and role-config.md before changing permissions", Required: true}, + }, + Tips: []string{ + baseHighRiskYesTip, + "Requires advanced permissions to be enabled and the caller to be a Base admin.", + "Update is a delta merge: only changed fields are updated, others remain unchanged.", + "Use lark-base-role-guide.md as the entry guide and role-config.md as the role permission JSON SSOT.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { diff --git a/shortcuts/base/base_shortcut_helpers.go b/shortcuts/base/base_shortcut_helpers.go index 67ea64c6e..8d42fe4da 100644 --- a/shortcuts/base/base_shortcut_helpers.go +++ b/shortcuts/base/base_shortcut_helpers.go @@ -63,7 +63,7 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) { } func jsonInputTip(flagName string) string { - return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName) + return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; for complex JSON/DSL, read the lark-base reference and match the documented shape", flagName) } func formatJSONError(flagName string, target string, err error) error { diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 9f75ac763..24d01a21f 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -146,7 +146,7 @@ func TestShortcutsCatalog(t *testing.T) { "+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list", "+form-submit", "+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange", - "+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete", + "+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-get-data", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete", } if len(shortcuts) != len(want) { t.Fatalf("len(shortcuts)=%d want=%d", len(shortcuts), len(want)) @@ -198,6 +198,25 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) { } } +func TestBaseHighRiskShortcutsTipsGuideAgents(t *testing.T) { + for _, shortcut := range Shortcuts() { + if shortcut.Risk != "high-risk-write" { + continue + } + parent := &cobra.Command{Use: "base"} + shortcut.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + flag := cmd.Flags().Lookup("yes") + if flag == nil { + t.Fatalf("%s missing --yes flag", shortcut.Command) + } + tips := strings.Join(cmdutil.GetTips(cmd), "\n") + if !strings.Contains(tips, "pass --yes without asking again") { + t.Fatalf("%s tips missing agent guidance:\n%s", shortcut.Command, tips) + } + } +} + func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) { parent := &cobra.Command{Use: "base"} BaseFieldCreate.Mount(parent, &cmdutil.Factory{}) @@ -235,36 +254,39 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) { wantHelp: []string{ "field ID or name to include; repeat to project only needed fields", "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view", + `filter JSON object or @file`, + `sort JSON array or @file`, "pagination size, range 1-200", "output format: markdown (default) | json", }, wantTips: []string{ "lark-cli base +record-list --base-token --table-id --limit 50", "lark-cli base +record-list --base-token --table-id --field-id Name --field-id Status --limit 50", + "Text equality filter", + "Option intersection filter", + "Query priority", "Default output is markdown", "Use --field-id repeatedly to keep output small", - "Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view", - "lark-base record read SOP", }, }, { name: "record search", shortcut: BaseRecordSearch, wantHelp: []string{ - "requires keyword/search_fields", - "optional select_fields/view_id/offset/limit", + `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`, + "keyword for record search", + "field ID or name to search", + `filter JSON object or @file`, + `sort JSON array or @file`, "output format: markdown (default) | json", }, wantTips: []string{ - `lark-cli base +record-search --base-token --table-id --json`, - `"select_fields":["Name","Status"]`, - `JSON shape: {"keyword":"","search_fields":[""]`, - "search_fields length 1-20", - "limit range 1-200 defaults to 10", - "view_id scopes search to records in that view", + "Example: lark-cli base +record-search", + "Example with filter/sort JSON", + "Text equality filter", + "Query priority", + "Use --json only when you need to pass the full search body directly", "Default output is markdown", - "only for keyword search", - "lark-base record read SOP", }, }, { @@ -311,6 +333,401 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) { } } +func TestBaseDashboardHelpGuidesAgents(t *testing.T) { + tests := []struct { + name string + shortcut common.Shortcut + wantTips []string + }{ + { + name: "dashboard list", + shortcut: BaseDashboardList, + wantTips: []string{ + "Use returned dashboard_id values", + }, + }, + { + name: "dashboard get", + shortcut: BaseDashboardGet, + wantTips: []string{ + "block-level details", + }, + }, + { + name: "dashboard create", + shortcut: BaseDashboardCreate, + wantTips: []string{ + "Record the returned dashboard_id", + }, + }, + { + name: "dashboard update", + shortcut: BaseDashboardUpdate, + wantTips: []string{}, + }, + { + name: "dashboard delete", + shortcut: BaseDashboardDelete, + wantTips: []string{ + "lark-cli base +dashboard-delete --base-token --dashboard-id --yes", + "also deletes its blocks", + "pass --yes", + }, + }, + { + name: "dashboard arrange", + shortcut: BaseDashboardArrange, + wantTips: []string{ + "not deterministic or position-specific", + }, + }, + { + name: "dashboard block list", + shortcut: BaseDashboardBlockList, + wantTips: []string{ + "lark-cli base +dashboard-block-list --base-token --dashboard-id ", + "Use returned block_id and type values", + }, + }, + { + name: "dashboard block get", + shortcut: BaseDashboardBlockGet, + wantTips: []string{ + "lark-cli base +dashboard-block-get --base-token --dashboard-id --block-id ", + "metadata such as name, type, layout, and data_config", + "computed chart result", + }, + }, + { + name: "dashboard block get data", + shortcut: BaseDashboardBlockGetData, + wantTips: []string{ + "lark-cli base +dashboard-block-get-data --base-token --block-id ", + "does not need --dashboard-id", + "computed chart protocol JSON", + }, + }, + { + name: "dashboard block create", + shortcut: BaseDashboardBlockCreate, + wantTips: []string{ + `lark-cli base +dashboard-block-create --base-token --dashboard-id --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`, + `--type text --data-config '{"text":"# Sales Dashboard"}'`, + "+table-list and +field-list", + "not table_id or field_id", + "dashboard-block-data-config.md as the SSOT", + "do not invent data_config from natural language", + "sequentially", + }, + }, + { + name: "dashboard block update", + shortcut: BaseDashboardBlockUpdate, + wantTips: []string{ + `lark-cli base +dashboard-block-update --base-token --dashboard-id --block-id --name "Total Sales"`, + `--data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`, + "dashboard-block-data-config.md as the SSOT", + "do not invent data_config from natural language", + "Block type cannot be changed", + "top-level keys", + }, + }, + { + name: "dashboard block delete", + shortcut: BaseDashboardBlockDelete, + wantTips: []string{ + "lark-cli base +dashboard-block-delete --base-token --dashboard-id --block-id --yes", + "pass --yes", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &cobra.Command{Use: "base"} + tt.shortcut.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + + tips := strings.Join(cmdutil.GetTips(cmd), "\n") + for _, want := range tt.wantTips { + if !strings.Contains(tips, want) { + t.Fatalf("tips missing %q:\n%s", want, tips) + } + } + }) + } +} + +func TestBaseWorkflowHelpGuidesAgents(t *testing.T) { + tests := []struct { + name string + shortcut common.Shortcut + wantTips []string + }{ + { + name: "workflow list", + shortcut: BaseWorkflowList, + wantTips: []string{ + "workflow_id values with wkf prefix", + "auto-paginates", + }, + }, + { + name: "workflow get", + shortcut: BaseWorkflowGet, + wantTips: []string{ + "workflow-id must start with wkf", + "steps may be an empty array", + "Use +workflow-get before +workflow-update", + "lark-base-workflow-schema.md", + }, + }, + { + name: "workflow create", + shortcut: BaseWorkflowCreate, + wantTips: []string{ + "lark-cli base +workflow-create --base-token --json @workflow.json", + "client_token is required", + "New workflows are created disabled", + "+table-list and +field-list", + "Step ids must be unique", + "lark-base-workflow-guide.md as the entry guide", + "lark-base-workflow-schema.md as the steps JSON SSOT", + "do not invent steps[].type/data/next/children from natural language", + }, + }, + { + name: "workflow update", + shortcut: BaseWorkflowUpdate, + wantTips: []string{ + "lark-cli base +workflow-update --base-token --workflow-id --json @workflow.json", + "PUT uses full replacement semantics", + "Use +workflow-get first", + "keep title/status/steps fields", + "workflow-id must start with wkf", + "Updating does not enable or disable", + "do not invent steps[].type/data/next/children from natural language", + }, + }, + { + name: "workflow enable", + shortcut: BaseWorkflowEnable, + wantTips: []string{ + "workflow-id must start with wkf", + "does not modify steps", + "New workflows are created disabled", + }, + }, + { + name: "workflow disable", + shortcut: BaseWorkflowDisable, + wantTips: []string{ + "workflow-id must start with wkf", + "does not delete the workflow or its steps", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &cobra.Command{Use: "base"} + tt.shortcut.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + + tips := strings.Join(cmdutil.GetTips(cmd), "\n") + for _, want := range tt.wantTips { + if !strings.Contains(tips, want) { + t.Fatalf("tips missing %q:\n%s", want, tips) + } + } + }) + } +} + +func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) { + tests := []struct { + name string + shortcut common.Shortcut + wantHelp []string + }{ + { + name: "table create fields", + shortcut: BaseTableCreate, + wantHelp: []string{ + `field JSON array for create, e.g. [{"name":"Title","type":"text"}`, + }, + }, + { + name: "view set filter", + shortcut: BaseViewSetFilter, + wantHelp: []string{ + `filter JSON object, e.g. {"logic":"and","conditions":[["Status","==","Todo"]]}`, + }, + }, + { + name: "view set sort", + shortcut: BaseViewSetSort, + wantHelp: []string{ + `sort_config JSON object, e.g. {"sort_config":[{"field":"Priority","desc":true}]}`, + `use {"sort_config":[]} to clear`, + }, + }, + { + name: "view set group", + shortcut: BaseViewSetGroup, + wantHelp: []string{ + `group JSON object with group_config array, e.g. {"group_config":[{"field":"Status","desc":false}]}`, + }, + }, + { + name: "view set card", + shortcut: BaseViewSetCard, + wantHelp: []string{ + `card JSON object, e.g. {"cover_field":"Cover"} or {"cover_field":null} to clear`, + }, + }, + { + name: "view set timebar", + shortcut: BaseViewSetTimebar, + wantHelp: []string{ + `timebar JSON object with start_time, end_time, title, e.g. {"start_time":"Start Date","end_time":"End Date","title":"Name"}`, + }, + }, + { + name: "view set visible fields", + shortcut: BaseViewSetVisibleFields, + wantHelp: []string{ + `visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`, + }, + }, + { + name: "form question delete", + shortcut: BaseFormQuestionsDelete, + wantHelp: []string{ + `JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`, + }, + }, + { + name: "record search json", + shortcut: BaseRecordSearch, + wantHelp: []string{ + `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`, + }, + }, + { + name: "record upsert json", + shortcut: BaseRecordUpsert, + wantHelp: []string{ + `record field map JSON object, e.g. {"Name":"Alice","Status":"Todo"}; do not wrap in fields`, + }, + }, + { + name: "record batch create json", + shortcut: BaseRecordBatchCreate, + wantHelp: []string{ + `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, + }, + }, + { + name: "record batch update json", + shortcut: BaseRecordBatchUpdate, + wantHelp: []string{ + `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &cobra.Command{Use: "base"} + tt.shortcut.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + + help := cmd.Flags().FlagUsages() + for _, want := range tt.wantHelp { + if !strings.Contains(help, want) { + t.Fatalf("flag help missing %q:\n%s", want, help) + } + } + }) + } +} + +func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) { + tests := []struct { + name string + shortcut common.Shortcut + wantTips []string + }{ + { + name: "record upsert", + shortcut: BaseRecordUpsert, + wantTips: []string{ + "Happy path JSON is a top-level field map", + "Without --record-id this creates a record", + "does not auto-upsert by business key", + "use +field-list to confirm real writable fields", + "do not write system fields, formula, lookup, or attachment fields", + "CellValue happy path: text/phone/url", + "select -> \"Todo\"", + "multi-select -> [\"Tag A\",\"Tag B\"]", + "datetime -> \"2026-03-24 10:00:00\"", + "checkbox -> true/false", + `ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`, + `location uses {"lng":116.397428,"lat":39.90923}`, + "Do not guess user/chat/linked-record IDs or location coordinates", + "lark-base-cell-value.md", + "do not invent values for fields not covered by the happy path", + }, + }, + { + name: "record batch create", + shortcut: BaseRecordBatchCreate, + wantTips: []string{ + "Happy path fields: fields is the column order", + "rows is an array of row arrays", + "may use null for empty cells", + "use +field-list to confirm real writable fields", + "Batch create supports max 200 rows per call", + "CellValue happy path: text/phone/url", + `ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`, + "lark-base-cell-value.md", + "do not invent values for fields not covered by the happy path", + }, + }, + { + name: "record batch update", + shortcut: BaseRecordBatchUpdate, + wantTips: []string{ + "Happy path fields: record_id_list is the target record IDs", + "patch is a field map applied unchanged to every target record", + "Do not use +record-batch-update for per-row different values", + "use +field-list to confirm real writable fields", + "Batch update supports max 200 records per call", + "CellValue happy path: text/phone/url", + `ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`, + "lark-base-cell-value.md", + "do not invent values for fields not covered by the happy path", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &cobra.Command{Use: "base"} + tt.shortcut.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + + tips := strings.Join(cmdutil.GetTips(cmd), "\n") + for _, want := range tt.wantTips { + if !strings.Contains(tips, want) { + t.Fatalf("tips missing %q:\n%s", want, tips) + } + } + }) + } +} + func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) { parent := &cobra.Command{Use: "base"} BaseFieldUpdate.Mount(parent, &cmdutil.Factory{}) @@ -328,7 +745,7 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) { tips := strings.Join(cmdutil.GetTips(cmd), "\n") wantTips := []string{ - `lark-cli base +field-update --base-token --table-id --field-id --json '{"name":"Status","type":"text"}'`, + `lark-cli base +field-update --base-token --table-id --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`, `"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`, "full field-definition PUT semantics", "Read the current field first with +field-get", @@ -472,11 +889,11 @@ func TestBaseTableValidate(t *testing.T) { func TestBaseRecordValidate(t *testing.T) { ctx := context.Background() - if BaseRecordList.Validate != nil { - t.Fatalf("record list validate should be nil for repeatable --field-id") + if BaseRecordList.Validate == nil { + t.Fatalf("record list validate should reject invalid query flags before dry-run") } if BaseRecordSearch.Validate == nil { - t.Fatalf("record search validate should reject invalid JSON before dry-run") + t.Fatalf("record search validate should reject invalid JSON/query flags before dry-run") } if BaseRecordGet.Validate == nil { t.Fatalf("record get validate should reject invalid record selection before dry-run") @@ -487,6 +904,58 @@ func TestBaseRecordValidate(t *testing.T) { if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"Alice"}`}, nil, nil)); err != nil { t.Fatalf("record upsert map validate err=%v", err) } + if err := BaseRecordList.Validate(ctx, newBaseTestRuntime( + map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`}, + nil, + nil, + )); err != nil { + t.Fatalf("record list filter-json validate err=%v", err) + } + if err := BaseRecordList.Validate(ctx, newBaseTestRuntime( + map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `[["Status","==","Todo"]]`}, + nil, + nil, + )); err == nil || !strings.Contains(err.Error(), "--filter-json must be a JSON object") { + t.Fatalf("err=%v", err) + } + if err := BaseRecordList.Validate(ctx, newBaseTestRuntimeWithArrays( + map[string]string{"base-token": "b", "table-id": "tbl_1", "sort-json": `[{"field":"F1"},{"field":"F2"},{"field":"F3"},{"field":"F4"},{"field":"F5"},{"field":"F6"},{"field":"F7"},{"field":"F8"},{"field":"F9"},{"field":"F10"},{"field":"F11"}]`}, + nil, + nil, + nil, + )); err == nil || !strings.Contains(err.Error(), "sort supports at most 10 sort conditions") { + t.Fatalf("err=%v", err) + } + if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--keyword is required unless --json is used") { + t.Fatalf("err=%v", err) + } + if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays( + map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"}, + map[string][]string{"search-field": {"Name"}}, + nil, + nil, + )); err != nil { + t.Fatalf("record search flag validate err=%v", err) + } + if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime( + map[string]string{ + "base-token": "b", + "table-id": "tbl_1", + "json": `{"keyword":"Alice","search_fields":["Name"],"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`, + "sort-json": `[{"field":"Title","desc":false}]`, + }, + nil, + nil, + )); err != nil { + t.Fatalf("record search json with sort-json validate err=%v", err) + } + if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime( + map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"}, + nil, + nil, + )); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") { + t.Fatalf("err=%v", err) + } } func TestBaseViewValidate(t *testing.T) { diff --git a/shortcuts/base/dashboard_arrange.go b/shortcuts/base/dashboard_arrange.go index 320b704a3..ab031add0 100644 --- a/shortcuts/base/dashboard_arrange.go +++ b/shortcuts/base/dashboard_arrange.go @@ -22,6 +22,9 @@ var BaseDashboardArrange = common.Shortcut{ dashboardIDFlag(true), {Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"}, }, + Tips: []string{ + "Server-side smart layout is not deterministic or position-specific; use only when the user asks to arrange or beautify a dashboard.", + }, DryRun: dryRunDashboardArrange, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeDashboardArrange(runtime) diff --git a/shortcuts/base/dashboard_block_create.go b/shortcuts/base/dashboard_block_create.go index e74671d9d..ec8af8d3b 100644 --- a/shortcuts/base/dashboard_block_create.go +++ b/shortcuts/base/dashboard_block_create.go @@ -25,10 +25,19 @@ var BaseDashboardBlockCreate = common.Shortcut{ dashboardIDFlag(true), {Name: "name", Desc: "block name", Required: true}, {Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true}, - {Name: "data-config", Desc: "data config JSON object (table_name, series, count_all, group_by, filter, etc.)"}, - {Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"}, + {Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"}, + {Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"}, {Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"}, }, + Tips: []string{ + `lark-cli base +dashboard-block-create --base-token --dashboard-id --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`, + `lark-cli base +dashboard-block-create --base-token --dashboard-id --name "Dashboard Note" --type text --data-config '{"text":"# Sales Dashboard"}'`, + "Before creating data-backed blocks, use +table-list and +field-list to confirm real table and field names.", + "data_config uses table and field names, not table_id or field_id.", + "Read dashboard-block-data-config.md as the SSOT for chart templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.", + "Record the returned block_id; block update/delete/get-data commands need it.", + "Create dashboard blocks sequentially; do not parallelize multiple block creates for the same dashboard.", + }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) if runtime.Bool("no-validate") { diff --git a/shortcuts/base/dashboard_block_delete.go b/shortcuts/base/dashboard_block_delete.go index 97d667a98..2e78a5995 100644 --- a/shortcuts/base/dashboard_block_delete.go +++ b/shortcuts/base/dashboard_block_delete.go @@ -22,6 +22,10 @@ var BaseDashboardBlockDelete = common.Shortcut{ dashboardIDFlag(true), blockIDFlag(true), }, + Tips: []string{ + "lark-cli base +dashboard-block-delete --base-token --dashboard-id --block-id --yes", + baseHighRiskYesTip, + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). diff --git a/shortcuts/base/dashboard_block_get.go b/shortcuts/base/dashboard_block_get.go index b6c605e3c..5f6d62631 100644 --- a/shortcuts/base/dashboard_block_get.go +++ b/shortcuts/base/dashboard_block_get.go @@ -24,6 +24,11 @@ var BaseDashboardBlockGet = common.Shortcut{ blockIDFlag(true), {Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"}, }, + Tips: []string{ + "lark-cli base +dashboard-block-get --base-token --dashboard-id --block-id ", + "Use this command for block metadata such as name, type, layout, and data_config.", + "Use +dashboard-block-get-data when you need the computed chart result instead of metadata.", + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { params := map[string]interface{}{} if uid := strings.TrimSpace(runtime.Str("user-id-type")); uid != "" { diff --git a/shortcuts/base/dashboard_block_get_data.go b/shortcuts/base/dashboard_block_get_data.go new file mode 100644 index 000000000..3ee3b5a66 --- /dev/null +++ b/shortcuts/base/dashboard_block_get_data.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardBlockGetData = common.Shortcut{ + Service: "base", + Command: "+dashboard-block-get-data", + Description: "Get computed data for a dashboard chart block", + Risk: "read", + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + blockIDFlag(true), + }, + Tips: []string{ + "lark-cli base +dashboard-block-get-data --base-token --block-id ", + "This command does not need --dashboard-id.", + "Use +dashboard-block-get first when you need block metadata like name, type, or data_config.", + "This command returns computed chart protocol JSON directly, not wrapped block metadata.", + "Text blocks do not have computed chart data; this shortcut is for chart/statistics blocks.", + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunDashboardBlockGetData(ctx, runtime) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardBlockGetData(runtime) + }, +} diff --git a/shortcuts/base/dashboard_block_list.go b/shortcuts/base/dashboard_block_list.go index 852dcc18b..2f4d7461e 100644 --- a/shortcuts/base/dashboard_block_list.go +++ b/shortcuts/base/dashboard_block_list.go @@ -21,9 +21,13 @@ var BaseDashboardBlockList = common.Shortcut{ Flags: []common.Flag{ baseTokenFlag(true), dashboardIDFlag(true), - {Name: "page-size", Desc: "page size (max 100)"}, + {Name: "page-size", Desc: "page size, default 20, max 100"}, {Name: "page-token", Desc: "pagination token"}, }, + Tips: []string{ + "lark-cli base +dashboard-block-list --base-token --dashboard-id ", + "Use returned block_id and type values for +dashboard-block-get/update/delete/get-data.", + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { params := map[string]interface{}{} if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" { diff --git a/shortcuts/base/dashboard_block_update.go b/shortcuts/base/dashboard_block_update.go index 6f8a37dc9..718d7e856 100644 --- a/shortcuts/base/dashboard_block_update.go +++ b/shortcuts/base/dashboard_block_update.go @@ -24,10 +24,18 @@ var BaseDashboardBlockUpdate = common.Shortcut{ dashboardIDFlag(true), blockIDFlag(true), {Name: "name", Desc: "new block name"}, - {Name: "data-config", Desc: "data config JSON. For chart types: table_name, series|count_all, group_by, filter. For text type: text (markdown supported). See dashboard-block-data-config.md for details."}, - {Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"}, + {Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"}, + {Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"}, {Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"}, }, + Tips: []string{ + `lark-cli base +dashboard-block-update --base-token --dashboard-id --block-id --name "Total Sales"`, + `lark-cli base +dashboard-block-update --base-token --dashboard-id --block-id --data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`, + "Read dashboard-block-data-config.md as the SSOT for data_config templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.", + "Use +dashboard-block-get first to inspect the current data_config before replacing nested values.", + "Block type cannot be changed; delete and recreate the block to change chart type.", + "data_config update merges top-level keys, but each provided key is replaced as a whole.", + }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) if runtime.Bool("no-validate") { diff --git a/shortcuts/base/dashboard_create.go b/shortcuts/base/dashboard_create.go index 214d8d175..d0ac6ab29 100644 --- a/shortcuts/base/dashboard_create.go +++ b/shortcuts/base/dashboard_create.go @@ -20,7 +20,10 @@ var BaseDashboardCreate = common.Shortcut{ Flags: []common.Flag{ baseTokenFlag(true), {Name: "name", Desc: "dashboard name", Required: true}, - {Name: "theme-style", Desc: "theme style"}, + {Name: "theme-style", Desc: "theme style, defaults to platform default when omitted"}, + }, + Tips: []string{ + "Record the returned dashboard_id; dashboard block create/get/update/delete/arrange commands need it.", }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { body := map[string]interface{}{} diff --git a/shortcuts/base/dashboard_delete.go b/shortcuts/base/dashboard_delete.go index 5d6df0064..587d5f8a4 100644 --- a/shortcuts/base/dashboard_delete.go +++ b/shortcuts/base/dashboard_delete.go @@ -21,6 +21,11 @@ var BaseDashboardDelete = common.Shortcut{ baseTokenFlag(true), dashboardIDFlag(true), }, + Tips: []string{ + "lark-cli base +dashboard-delete --base-token --dashboard-id --yes", + "Deleting a dashboard also deletes its blocks and cannot be recovered.", + baseHighRiskYesTip, + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). diff --git a/shortcuts/base/dashboard_get.go b/shortcuts/base/dashboard_get.go index 90f21f6cd..a42642790 100644 --- a/shortcuts/base/dashboard_get.go +++ b/shortcuts/base/dashboard_get.go @@ -21,6 +21,9 @@ var BaseDashboardGet = common.Shortcut{ baseTokenFlag(true), dashboardIDFlag(true), }, + Tips: []string{ + "Use +dashboard-block-list or +dashboard-block-get when you need block-level details.", + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). diff --git a/shortcuts/base/dashboard_list.go b/shortcuts/base/dashboard_list.go index 8d270daa0..c1eca4902 100644 --- a/shortcuts/base/dashboard_list.go +++ b/shortcuts/base/dashboard_list.go @@ -20,9 +20,12 @@ var BaseDashboardList = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ baseTokenFlag(true), - {Name: "page-size", Desc: "page size (max 100)"}, + {Name: "page-size", Desc: "page size, max 100"}, {Name: "page-token", Desc: "pagination token"}, }, + Tips: []string{ + "Use returned dashboard_id values for +dashboard-get, +dashboard-block-list, and +dashboard-block-create.", + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { params := map[string]interface{}{} if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" { diff --git a/shortcuts/base/dashboard_ops.go b/shortcuts/base/dashboard_ops.go index 3a05d771d..3d32099f0 100644 --- a/shortcuts/base/dashboard_ops.go +++ b/shortcuts/base/dashboard_ops.go @@ -104,6 +104,14 @@ func dryRunDashboardBlockGet(_ context.Context, runtime *common.RuntimeContext) Params(params) } +// dryRunDashboardBlockGetData returns a DryRunAPI for getting computed data for a dashboard block. +func dryRunDashboardBlockGetData(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/dashboards/blocks/:block_id/data"). + Set("base_token", runtime.Str("base-token")). + Set("block_id", runtime.Str("block-id")) +} + // dryRunDashboardBlockCreate returns a DryRunAPI for creating a dashboard block. func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { pc := newParseCtx(runtime) @@ -261,6 +269,16 @@ func executeDashboardBlockGet(runtime *common.RuntimeContext) error { return nil } +// executeDashboardBlockGetData retrieves computed data for a dashboard chart block. +func executeDashboardBlockGetData(runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", "blocks", runtime.Str("block-id"), "data"), nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil +} + // executeDashboardBlockCreate creates a new dashboard block. func executeDashboardBlockCreate(runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) diff --git a/shortcuts/base/dashboard_update.go b/shortcuts/base/dashboard_update.go index 5832e9021..f9dda81a4 100644 --- a/shortcuts/base/dashboard_update.go +++ b/shortcuts/base/dashboard_update.go @@ -21,7 +21,7 @@ var BaseDashboardUpdate = common.Shortcut{ baseTokenFlag(true), dashboardIDFlag(true), {Name: "name", Desc: "new dashboard name"}, - {Name: "theme-style", Desc: "theme style"}, + {Name: "theme-style", Desc: "theme style, leave empty to keep current theme"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { body := map[string]interface{}{} diff --git a/shortcuts/base/field_create.go b/shortcuts/base/field_create.go index f2f342c1c..121117627 100644 --- a/shortcuts/base/field_create.go +++ b/shortcuts/base/field_create.go @@ -23,7 +23,8 @@ var BaseFieldCreate = common.Shortcut{ {Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true}, }, Tips: []string{ - `Example: --json '{"name":"Status","type":"text"}'`, + `Example text: lark-cli base +field-create --base-token --table-id --json '{"name":"Status","type":"text"}'`, + `Example select: lark-cli base +field-create --base-token --table-id --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`, "Agent hint: use the lark-base skill's field-create guide for usage and limits.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/field_delete.go b/shortcuts/base/field_delete.go index d242763d1..8b6a0a793 100644 --- a/shortcuts/base/field_delete.go +++ b/shortcuts/base/field_delete.go @@ -17,7 +17,11 @@ var BaseFieldDelete = common.Shortcut{ Scopes: []string{"base:field:delete"}, AuthTypes: authTypes(), Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)}, - DryRun: dryRunFieldDelete, + Tips: []string{ + baseHighRiskYesTip, + `Example: lark-cli base +field-delete --base-token --table-id --field-id "Status" --yes`, + }, + DryRun: dryRunFieldDelete, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeFieldDelete(runtime) }, diff --git a/shortcuts/base/field_get.go b/shortcuts/base/field_get.go index 63d66a697..a272b54c5 100644 --- a/shortcuts/base/field_get.go +++ b/shortcuts/base/field_get.go @@ -17,7 +17,12 @@ var BaseFieldGet = common.Shortcut{ Scopes: []string{"base:field:read"}, AuthTypes: authTypes(), Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)}, - DryRun: dryRunFieldGet, + Tips: []string{ + `Example: lark-cli base +field-get --base-token --table-id --field-id "Status"`, + "field-id accepts a field ID (fld...) or the field name from the current table.", + "Returns full field configuration; use it as the baseline before +field-update.", + }, + DryRun: dryRunFieldGet, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeFieldGet(runtime) }, diff --git a/shortcuts/base/field_list.go b/shortcuts/base/field_list.go index da487827b..7851a6f91 100644 --- a/shortcuts/base/field_list.go +++ b/shortcuts/base/field_list.go @@ -20,7 +20,7 @@ var BaseFieldList = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, - {Name: "limit", Type: "int", Default: "100", Desc: "pagination size"}, + {Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"}, }, DryRun: dryRunFieldList, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/field_search_options.go b/shortcuts/base/field_search_options.go index 674cfe6ba..1783d3681 100644 --- a/shortcuts/base/field_search_options.go +++ b/shortcuts/base/field_search_options.go @@ -22,7 +22,11 @@ var BaseFieldSearchOptions = common.Shortcut{ fieldRefFlag(true), {Name: "keyword", Desc: "keyword for option query"}, {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, - {Name: "limit", Type: "int", Default: "30", Desc: "pagination size"}, + {Name: "limit", Type: "int", Default: "30", Desc: "pagination size, default 30"}, + }, + Tips: []string{ + `Example: lark-cli base +field-search-options --base-token --table-id --field-id "Status" --keyword "Do"`, + "Use only for fields with options, such as select or multi-select fields.", }, DryRun: dryRunFieldSearchOptions, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/field_update.go b/shortcuts/base/field_update.go index f8e8a47d0..177ad7e9a 100644 --- a/shortcuts/base/field_update.go +++ b/shortcuts/base/field_update.go @@ -24,8 +24,9 @@ var BaseFieldUpdate = common.Shortcut{ {Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true}, }, Tips: []string{ - `Example: lark-cli base +field-update --base-token --table-id --field-id --json '{"name":"Status","type":"text"}'`, - `Example: lark-cli base +field-update --base-token --table-id --field-id --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`, + baseHighRiskYesTip, + `Example text: lark-cli base +field-update --base-token --table-id --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`, + `Example select: lark-cli base +field-update --base-token --table-id --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`, "Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.", "Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.", "Formula and lookup updates require reading the corresponding guide first.", diff --git a/shortcuts/base/helpers_test.go b/shortcuts/base/helpers_test.go index 1038c33e9..a49f0efc4 100644 --- a/shortcuts/base/helpers_test.go +++ b/shortcuts/base/helpers_test.go @@ -38,7 +38,7 @@ func TestParseHelpers(t *testing.T) { if err != nil || obj["name"] != "demo" { t.Fatalf("obj=%v err=%v", obj, err) } - if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "lark-base skill") || strings.Contains(err.Error(), "array") { + if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "match the documented shape") || strings.Contains(err.Error(), "array") { t.Fatalf("err=%v", err) } if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") { @@ -66,7 +66,7 @@ func TestParseHelpers(t *testing.T) { if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") { t.Fatalf("err=%v", err) } - if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") { + if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "complex JSON/DSL") { t.Fatalf("err=%v", err) } if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) { @@ -334,11 +334,11 @@ func TestJSONInputHelpers(t *testing.T) { t.Fatalf("err=%v", err) } syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7}) - if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") { + if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "complex JSON/DSL") { t.Fatalf("syntaxErr=%v", syntaxErr) } typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"}) - if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "lark-base skill") { + if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "complex JSON/DSL") { t.Fatalf("typeErr=%v", typeErr) } } diff --git a/shortcuts/base/high_risk.go b/shortcuts/base/high_risk.go new file mode 100644 index 000000000..dd34bf034 --- /dev/null +++ b/shortcuts/base/high_risk.go @@ -0,0 +1,6 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +const baseHighRiskYesTip = "This is a high-risk write command. If the user explicitly requested it and the target is unambiguous, pass --yes without asking again." diff --git a/shortcuts/base/record_batch_create.go b/shortcuts/base/record_batch_create.go index 5e0d85c36..50cde35fa 100644 --- a/shortcuts/base/record_batch_create.go +++ b/shortcuts/base/record_batch_create.go @@ -19,13 +19,14 @@ var BaseRecordBatchCreate = common.Shortcut{ Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), - {Name: "json", Desc: "batch create JSON object", Required: true}, - }, - Tips: []string{ - `Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`, - "Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.", - "Agent hint: use lark-base-cell-value.md as the source of truth for each CellValue.", + {Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true}, }, + Tips: append([]string{ + "Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.", + "Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.", + "Batch create supports max 200 rows per call.", + "Use the record-batch-create guide for command limits and edge cases.", + }, recordCellValueHappyPathTips...), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateRecordJSON(runtime) }, diff --git a/shortcuts/base/record_batch_update.go b/shortcuts/base/record_batch_update.go index 4b8d026ae..74a52cea8 100644 --- a/shortcuts/base/record_batch_update.go +++ b/shortcuts/base/record_batch_update.go @@ -19,13 +19,14 @@ var BaseRecordBatchUpdate = common.Shortcut{ Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), - {Name: "json", Desc: "batch update JSON object", Required: true}, - }, - Tips: []string{ - `Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`, - "Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.", - "Agent hint: use lark-base-cell-value.md as the source of truth for each patch CellValue.", + {Name: "json", Desc: `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, Required: true}, }, + Tips: append([]string{ + "Happy path fields: record_id_list is the target record IDs; patch is a field map applied unchanged to every target record.", + "Do not use +record-batch-update for per-row different values; call +record-upsert per record or use another supported flow.", + "Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.", + "Batch update supports max 200 records per call; use the record-batch-update guide for command limits and edge cases.", + }, recordCellValueHappyPathTips...), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateRecordJSON(runtime) }, diff --git a/shortcuts/base/record_delete.go b/shortcuts/base/record_delete.go index 158ddcb50..281376d5e 100644 --- a/shortcuts/base/record_delete.go +++ b/shortcuts/base/record_delete.go @@ -22,6 +22,10 @@ var BaseRecordDelete = common.Shortcut{ {Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"}, {Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`}, }, + Tips: []string{ + baseHighRiskYesTip, + `Example: lark-cli base +record-delete --base-token --table-id --record-id --record-id --yes`, + }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateRecordSelection(runtime) }, diff --git a/shortcuts/base/record_history_list.go b/shortcuts/base/record_history_list.go index 3a2f08e6e..896de0d42 100644 --- a/shortcuts/base/record_history_list.go +++ b/shortcuts/base/record_history_list.go @@ -21,7 +21,11 @@ var BaseRecordHistoryList = common.Shortcut{ tableRefFlag(true), recordRefFlag(true), {Name: "max-version", Type: "int", Desc: "max version for next page"}, - {Name: "page-size", Type: "int", Default: "30", Desc: "pagination size"}, + {Name: "page-size", Type: "int", Default: "30", Desc: "pagination size, max 50"}, + }, + Tips: []string{ + `Example: lark-cli base +record-history-list --base-token --table-id --record-id `, + "This reads one record's history only; it is not a table-wide audit scan.", }, DryRun: dryRunRecordHistoryList, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/record_list.go b/shortcuts/base/record_list.go index dfa7256b7..b6489a5c8 100644 --- a/shortcuts/base/record_list.go +++ b/shortcuts/base/record_list.go @@ -22,6 +22,8 @@ var BaseRecordList = common.Shortcut{ tableRefFlag(true), recordListFieldRefFlag(), recordListViewRefFlag(), + recordFilterFlag(), + recordSortFlag(), {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"}, recordReadFormatFlag(), @@ -29,10 +31,21 @@ var BaseRecordList = common.Shortcut{ Tips: []string{ "Example: lark-cli base +record-list --base-token --table-id --limit 50", "Example with projection: lark-cli base +record-list --base-token --table-id --field-id Name --field-id Status --limit 50", + `Text equality filter: --filter-json '{"logic":"and","conditions":[["Title","==","Launch plan"]]}'`, + `Text contains/like filter: --filter-json '{"logic":"and","conditions":[["Title","intersects","urgent"]]}'`, + `Number equality filter: --filter-json '{"logic":"and","conditions":[["Score","==",95]]}'`, + `Date equality filter: --filter-json '{"logic":"and","conditions":[["Due Date","==","ExactDate(2026-06-02)"]]}'`, + `Option intersection filter: --filter-json '{"logic":"and","conditions":[["Tags","intersects",["P0","Blocked"]]]}'`, + `Sort priority follows --sort-json array order: --sort-json '[{"field":"Updated","desc":true},{"field":"Title","desc":false}]'`, + formatRecordQueryPriorityTip(), "Default output is markdown; pass --format json to get the raw JSON envelope.", "Use --field-id repeatedly to keep output small and aligned with the task.", - "Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view.", - "For structured filters, sorting, Top/Bottom N, and link fields, follow the lark-base record read SOP.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateRecordReadFormat(runtime); err != nil { + return err + } + return validateRecordQueryOptions(runtime) }, DryRun: dryRunRecordList, PostMount: func(cmd *cobra.Command) { diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go index f1873c23f..af48e6e72 100644 --- a/shortcuts/base/record_ops.go +++ b/shortcuts/base/record_ops.go @@ -15,6 +15,13 @@ import ( const maxRecordSelectionCount = 200 const maxBatchGetSelectFieldCount = 100 +var recordCellValueHappyPathTips = []string{ + `CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`, + `ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`, + "Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.", + "Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.", +} + type recordSelection struct { recordIDs []string selectFields []string @@ -210,6 +217,9 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common if viewID := runtime.Str("view-id"); viewID != "" { params.Set("view_id", viewID) } + if err := applyRecordQueryToURLValues(runtime, params); err != nil { + return common.NewDryRunAPI() + } path := "/open-apis/base/v3/bases/:base_token/tables/:table_id/records?" + params.Encode() return common.NewDryRunAPI(). GET(path). @@ -230,8 +240,12 @@ func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common. } func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - pc := newParseCtx(runtime) - body, _ := parseJSONObject(pc, runtime.Str("json"), "json") + var body map[string]interface{} + if strings.TrimSpace(runtime.Str("json")) != "" { + body, _ = recordSearchJSONBody(runtime) + } else { + body, _ = recordSearchFlagBody(runtime) + } return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/search"). Body(body). @@ -381,6 +395,9 @@ func executeRecordList(runtime *common.RuntimeContext) error { if viewID := runtime.Str("view-id"); viewID != "" { params["view_id"] = viewID } + if err := applyRecordQueryToParams(runtime, params); err != nil { + return err + } data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records"), params, nil) if err != nil { return err @@ -413,8 +430,13 @@ func executeRecordGet(runtime *common.RuntimeContext) error { } func executeRecordSearch(runtime *common.RuntimeContext) error { - pc := newParseCtx(runtime) - body, err := parseJSONObject(pc, runtime.Str("json"), "json") + var body map[string]interface{} + var err error + if strings.TrimSpace(runtime.Str("json")) != "" { + body, err = recordSearchJSONBody(runtime) + } else { + body, err = recordSearchFlagBody(runtime) + } if err != nil { return err } diff --git a/shortcuts/base/record_query.go b/shortcuts/base/record_query.go new file mode 100644 index 000000000..0bf37403b --- /dev/null +++ b/shortcuts/base/record_query.go @@ -0,0 +1,248 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + recordFilterJSONFlag = "filter-json" + recordSortJSONFlag = "sort-json" + recordSortMaxCount = 10 +) + +func recordFilterFlag() common.Flag { + return common.Flag{ + Name: recordFilterJSONFlag, + Desc: `filter JSON object or @file, same shape as view filter JSON; overrides --view-id view filters`, + Input: []string{common.File}, + } +} + +func recordSortFlag() common.Flag { + return common.Flag{ + Name: recordSortJSONFlag, + Desc: `sort JSON array or @file, e.g. [{"field":"Updated","desc":true}]; also accepts {"sort_config":[...]}; order is priority; max 10`, + Input: []string{common.File}, + } +} + +func validateRecordQueryOptions(runtime *common.RuntimeContext) error { + if _, err := parseRecordFilterFlag(runtime); err != nil { + return err + } + _, err := parseRecordSortFlag(runtime) + return err +} + +func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) { + filterRaw := strings.TrimSpace(runtime.Str(recordFilterJSONFlag)) + if filterRaw == "" { + return nil, nil + } + pc := newParseCtx(runtime) + return parseJSONObject(pc, filterRaw, recordFilterJSONFlag) +} + +func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) { + sortRaw := strings.TrimSpace(runtime.Str(recordSortJSONFlag)) + if sortRaw == "" { + return nil, nil + } + pc := newParseCtx(runtime) + value, err := parseJSONValue(pc, sortRaw, recordSortJSONFlag) + if err != nil { + return nil, err + } + return normalizeRecordSortValue(value, "--"+recordSortJSONFlag) +} + +func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, error) { + var sortConfig []interface{} + if parsed, ok := value.([]interface{}); ok { + sortConfig = parsed + } else if obj, ok := value.(map[string]interface{}); ok { + rawSortConfig, ok := obj["sort_config"] + if !ok { + return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label) + } + parsed, ok := rawSortConfig.([]interface{}) + if !ok { + return nil, common.FlagErrorf("%s.sort_config must be a JSON array", label) + } + sortConfig = parsed + } else { + return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label) + } + if len(sortConfig) > recordSortMaxCount { + return nil, common.FlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig)) + } + return sortConfig, nil +} + +func marshalRecordQueryFlag(flagName string, value interface{}) (string, error) { + data, err := json.Marshal(value) + if err != nil { + return "", common.FlagErrorf("--%s cannot encode JSON: %v", flagName, err) + } + return string(data), nil +} + +func applyRecordQueryToParams(runtime *common.RuntimeContext, params map[string]interface{}) error { + filter, err := parseRecordFilterFlag(runtime) + if err != nil { + return err + } + if filter != nil { + filterJSON, err := marshalRecordQueryFlag(recordFilterJSONFlag, filter) + if err != nil { + return err + } + params["filter"] = filterJSON + } + sortConfig, err := parseRecordSortFlag(runtime) + if err != nil { + return err + } + if len(sortConfig) > 0 { + sortJSON, err := marshalRecordQueryFlag(recordSortJSONFlag, sortConfig) + if err != nil { + return err + } + params["sort"] = sortJSON + } + return nil +} + +func applyRecordQueryToURLValues(runtime *common.RuntimeContext, params url.Values) error { + filter, err := parseRecordFilterFlag(runtime) + if err != nil { + return err + } + if filter != nil { + filterJSON, err := marshalRecordQueryFlag(recordFilterJSONFlag, filter) + if err != nil { + return err + } + params["filter"] = []string{filterJSON} + } + sortConfig, err := parseRecordSortFlag(runtime) + if err != nil { + return err + } + if len(sortConfig) > 0 { + sortJSON, err := marshalRecordQueryFlag(recordSortJSONFlag, sortConfig) + if err != nil { + return err + } + params["sort"] = []string{sortJSON} + } + return nil +} + +func applyRecordQueryToBody(runtime *common.RuntimeContext, body map[string]interface{}) error { + filter, err := parseRecordFilterFlag(runtime) + if err != nil { + return err + } + if filter != nil { + body["filter"] = filter + } + sortConfig, err := parseRecordSortFlag(runtime) + if err != nil { + return err + } + if len(sortConfig) > 0 { + body["sort"] = sortConfig + } + return nil +} + +func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + body := map[string]interface{}{} + if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" { + body["keyword"] = keyword + } + searchFields := runtime.StrArray("search-field") + if len(searchFields) > 0 { + body["search_fields"] = searchFields + } + selectFields := recordListFields(runtime) + if len(selectFields) > 0 { + body["select_fields"] = selectFields + } + if viewID := runtime.Str("view-id"); viewID != "" { + body["view_id"] = viewID + } + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + body["offset"] = offset + body["limit"] = common.ParseIntBounded(runtime, "limit", 1, 200) + return body, applyRecordQueryToBody(runtime, body) +} + +func recordSearchJSONBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + pc := newParseCtx(runtime) + body, err := parseJSONObject(pc, runtime.Str("json"), "json") + if err != nil { + return nil, err + } + if err := normalizeRecordSearchJSONBody(body); err != nil { + return nil, err + } + return body, applyRecordQueryToBody(runtime, body) +} + +func normalizeRecordSearchJSONBody(body map[string]interface{}) error { + if rawSort, ok := body["sort"]; ok { + if sortConfig, err := normalizeRecordSortValue(rawSort, "--json.sort"); err == nil { + body["sort"] = sortConfig + } else { + return err + } + } + return nil +} + +func validateRecordSearchFlags(runtime *common.RuntimeContext) error { + if err := validateRecordReadFormat(runtime); err != nil { + return err + } + jsonRaw := strings.TrimSpace(runtime.Str("json")) + if jsonRaw != "" { + if recordSearchHasJSONExclusiveFlagInputs(runtime) { + return common.FlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json") + } + _, err := recordSearchJSONBody(runtime) + return err + } + if strings.TrimSpace(runtime.Str("keyword")) == "" { + return common.FlagErrorf("--keyword is required unless --json is used") + } + if len(runtime.StrArray("search-field")) == 0 { + return common.FlagErrorf("--search-field is required unless --json is used") + } + return validateRecordQueryOptions(runtime) +} + +func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool { + return strings.TrimSpace(runtime.Str("keyword")) != "" || + len(runtime.StrArray("search-field")) > 0 || + len(recordListFields(runtime)) > 0 || + runtime.Str("view-id") != "" || + runtime.Changed("offset") || + runtime.Changed("limit") +} + +func formatRecordQueryPriorityTip() string { + return fmt.Sprintf("Query priority: --%s overrides --view-id's view filter JSON; --%s overrides --view-id's view sort config.", recordFilterJSONFlag, recordSortJSONFlag) +} diff --git a/shortcuts/base/record_query_test.go b/shortcuts/base/record_query_test.go new file mode 100644 index 000000000..1238fa4c6 --- /dev/null +++ b/shortcuts/base/record_query_test.go @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "encoding/json" + "net/url" + "strings" + "testing" +) + +func TestNormalizeRecordSortValue(t *testing.T) { + t.Run("array", func(t *testing.T) { + sortConfig, err := normalizeRecordSortValue([]interface{}{ + map[string]interface{}{"field": "Updated", "desc": true}, + }, "--sort-json") + if err != nil { + t.Fatalf("err=%v", err) + } + if len(sortConfig) != 1 { + t.Fatalf("sortConfig=%#v", sortConfig) + } + }) + + t.Run("wrapped sort_config", func(t *testing.T) { + sortConfig, err := normalizeRecordSortValue(map[string]interface{}{ + "sort_config": []interface{}{ + map[string]interface{}{"field": "Updated", "desc": false}, + }, + }, "--json.sort") + if err != nil { + t.Fatalf("err=%v", err) + } + first := sortConfig[0].(map[string]interface{}) + if first["field"] != "Updated" || first["desc"] != false { + t.Fatalf("sortConfig=%#v", sortConfig) + } + }) + + t.Run("invalid wrapper", func(t *testing.T) { + _, err := normalizeRecordSortValue(map[string]interface{}{"sort": []interface{}{}}, "--sort-json") + if err == nil || !strings.Contains(err.Error(), "sort_config array") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("invalid sort_config type", func(t *testing.T) { + _, err := normalizeRecordSortValue(map[string]interface{}{"sort_config": "Updated"}, "--sort-json") + if err == nil || !strings.Contains(err.Error(), "--sort-json.sort_config must be a JSON array") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("invalid scalar", func(t *testing.T) { + _, err := normalizeRecordSortValue("Updated", "--sort-json") + if err == nil || !strings.Contains(err.Error(), "must be a JSON array") { + t.Fatalf("err=%v", err) + } + }) +} + +func TestApplyRecordQueryToParams(t *testing.T) { + runtime := newBaseTestRuntime( + map[string]string{ + "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`, + "sort-json": `{"sort_config":[{"field":"Updated","desc":true}]}`, + }, + nil, + nil, + ) + params := map[string]interface{}{"view_id": "viw_1"} + if err := applyRecordQueryToParams(runtime, params); err != nil { + t.Fatalf("err=%v", err) + } + if params["view_id"] != "viw_1" { + t.Fatalf("params=%#v", params) + } + var filter map[string]interface{} + if err := json.Unmarshal([]byte(params["filter"].(string)), &filter); err != nil { + t.Fatalf("filter err=%v", err) + } + if filter["logic"] != "and" { + t.Fatalf("filter=%#v", filter) + } + var sortConfig []interface{} + if err := json.Unmarshal([]byte(params["sort"].(string)), &sortConfig); err != nil { + t.Fatalf("sort err=%v", err) + } + firstSort := sortConfig[0].(map[string]interface{}) + if firstSort["field"] != "Updated" || firstSort["desc"] != true { + t.Fatalf("sort=%#v", sortConfig) + } +} + +func TestApplyRecordQueryToURLValues(t *testing.T) { + runtime := newBaseTestRuntime( + map[string]string{ + "filter-json": `{"logic":"or","conditions":[["Score",">",90]]}`, + "sort-json": `[{"field":"Score","desc":false}]`, + }, + nil, + nil, + ) + params := url.Values{"view_id": {"viw_1"}} + if err := applyRecordQueryToURLValues(runtime, params); err != nil { + t.Fatalf("err=%v", err) + } + if got := params.Get("view_id"); got != "viw_1" { + t.Fatalf("view_id=%q", got) + } + if !strings.Contains(params.Get("filter"), `"logic":"or"`) || !strings.Contains(params.Get("sort"), `"field":"Score"`) { + t.Fatalf("params=%#v", params) + } +} + +func TestRecordSearchJSONBodyAppliesQueryFlagOverrides(t *testing.T) { + runtime := newBaseTestRuntime( + map[string]string{ + "json": `{"keyword":"urgent","search_fields":["Title"],"filter":{"logic":"and","conditions":[["Status","==","Done"]]},"sort":{"sort_config":[{"field":"Updated","desc":false}]}}`, + "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`, + "sort-json": `[{"field":"Score","desc":true}]`, + }, + nil, + nil, + ) + body, err := recordSearchJSONBody(runtime) + if err != nil { + t.Fatalf("err=%v", err) + } + filter := body["filter"].(map[string]interface{}) + conditions := filter["conditions"].([]interface{}) + statusCondition := conditions[0].([]interface{}) + if statusCondition[2] != "Todo" { + t.Fatalf("filter=%#v", filter) + } + sortConfig := body["sort"].([]interface{}) + firstSort := sortConfig[0].(map[string]interface{}) + if firstSort["field"] != "Score" || firstSort["desc"] != true { + t.Fatalf("sort=%#v", sortConfig) + } +} + +func TestRecordSearchJSONBodyNormalizesWrappedSort(t *testing.T) { + runtime := newBaseTestRuntime( + map[string]string{ + "json": `{"keyword":"urgent","search_fields":["Title"],"sort":{"sort_config":[{"field":"Updated","desc":false}]}}`, + }, + nil, + nil, + ) + body, err := recordSearchJSONBody(runtime) + if err != nil { + t.Fatalf("err=%v", err) + } + sortConfig := body["sort"].([]interface{}) + firstSort := sortConfig[0].(map[string]interface{}) + if firstSort["field"] != "Updated" || firstSort["desc"] != false { + t.Fatalf("sort=%#v", sortConfig) + } +} diff --git a/shortcuts/base/record_search.go b/shortcuts/base/record_search.go index 1b4ef35b5..586e5071a 100644 --- a/shortcuts/base/record_search.go +++ b/shortcuts/base/record_search.go @@ -20,23 +20,34 @@ var BaseRecordSearch = common.Shortcut{ Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), - {Name: "json", Desc: `record search JSON object; requires keyword/search_fields, optional select_fields/view_id/offset/limit`, Required: true}, + {Name: "json", Desc: `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`}, + {Name: "keyword", Desc: "keyword for record search; required unless --json is used"}, + {Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"}, + recordListFieldRefFlag(), + recordListViewRefFlag(), + recordFilterFlag(), + recordSortFlag(), + {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, + {Name: "limit", Type: "int", Default: "10", Desc: "pagination size, range 1-200"}, recordReadFormatFlag(), }, Tips: []string{ - `Example: lark-cli base +record-search --base-token --table-id --json '{"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}'`, - `JSON shape: {"keyword":"","search_fields":[""],"select_fields":[""],"view_id":"","offset":0,"limit":10}.`, + `Happy path fields: keyword (string), search_fields (1-20 field names/ids), select_fields (optional projection, <=50), view_id (optional), offset (default 0), limit (default 10, range 1-200).`, "JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.", "view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.", + `Example: lark-cli base +record-search --base-token --table-id --keyword Alice --search-field Name --field-id Name --field-id Status --limit 20`, + `Example with filter/sort JSON: lark-cli base +record-search --base-token --table-id --keyword Alice --search-field Name --filter-json @filter.json --sort-json '[{"field":"Updated","desc":true}]'`, + `Text equality filter: --filter-json '{"logic":"and","conditions":[["Title","==","Launch plan"]]}'`, + `Text contains/like filter: --filter-json '{"logic":"and","conditions":[["Title","intersects","urgent"]]}'`, + `Option intersection filter: --filter-json '{"logic":"and","conditions":[["Tags","intersects",["P0","Blocked"]]]}'`, + `Sort priority follows --sort-json array order.`, + formatRecordQueryPriorityTip(), + "Use +record-search for keyword matching; use --filter-json for structured conditions and --sort-json for result ordering.", + "Use --json only when you need to pass the full search body directly.", "Default output is markdown; pass --format json to get the raw JSON envelope.", - "Use +record-search only for keyword search; use a filtered view plus +record-list for structured conditions.", - "Agent hint: follow the lark-base record read SOP for record read routing and limits.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if err := validateRecordReadFormat(runtime); err != nil { - return err - } - return validateRecordJSON(runtime) + return validateRecordSearchFlags(runtime) }, DryRun: dryRunRecordSearch, PostMount: func(cmd *cobra.Command) { diff --git a/shortcuts/base/record_share_link_create.go b/shortcuts/base/record_share_link_create.go index 8baf3589a..522369fcb 100644 --- a/shortcuts/base/record_share_link_create.go +++ b/shortcuts/base/record_share_link_create.go @@ -22,8 +22,9 @@ var BaseRecordShareLinkCreate = common.Shortcut{ {Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)", Required: true}, }, Tips: []string{ - `Single record: --base-token xxx --table-id tblxxx --record-ids recxxx`, - `Multiple records: --base-token xxx --table-id tblxxx --record-ids rec001,rec002,rec003`, + `Example: lark-cli base +record-share-link-create --base-token --table-id --record-ids `, + "Max 100 record IDs per call; duplicate IDs are ignored.", + "Output record_share_links maps record_id to URL; records without permission or missing records may be absent.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateRecordShareBatch(runtime) diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go index d26d654d0..3bc564162 100644 --- a/shortcuts/base/record_upload_attachment.go +++ b/shortcuts/base/record_upload_attachment.go @@ -20,6 +20,7 @@ import ( "strings" "unicode/utf8" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" @@ -116,6 +117,7 @@ var BaseRecordRemoveAttachment = common.Shortcut{ {Name: "file-token", Type: "string_array", Desc: "attachment file_token to remove from the target cell; repeat to remove multiple attachments; max 50 tokens", Required: true}, }, Tips: []string{ + baseHighRiskYesTip, `Example: lark-cli base +record-remove-attachment --base-token --table-id --record-id --field-id --file-token --yes`, `Repeat --file-token to remove multiple attachments from the same cell in one call.`, `This is a high-risk write command and requires --yes.`, @@ -787,7 +789,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, QueryParams: query, }) if err != nil { - return nil, output.ErrNetwork("download failed: %v", err) + return nil, err } defer resp.Body.Close() @@ -833,6 +835,15 @@ func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) m func attachmentDownloadProgressError(err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error { msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err) + detail := map[string]interface{}{ + "downloaded": downloaded, + "failed": failed, + } + if logID := baseAttachmentDownloadLogID(err); logID != "" { + detail["log_id"] = logID + } + const hint = "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists." + var exitErr *output.ExitError if errors.As(err, &exitErr) && exitErr.Detail != nil { return &output.ExitError{ @@ -841,11 +852,22 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac Type: exitErr.Detail.Type, Code: exitErr.Detail.Code, Message: msg, - Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.", - Detail: map[string]interface{}{ - "downloaded": downloaded, - "failed": failed, - }, + Hint: hint, + Detail: detail, + }, + Err: err, + } + } + var netErr *errs.NetworkError + if errors.As(err, &netErr) { + return &output.ExitError{ + Code: output.ExitNetwork, + Detail: &output.ErrDetail{ + Type: "network", + Code: netErr.Code, + Message: msg, + Hint: hint, + Detail: detail, }, Err: err, } @@ -855,16 +877,31 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac Detail: &output.ErrDetail{ Type: "io", Message: msg, - Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.", - Detail: map[string]interface{}{ - "downloaded": downloaded, - "failed": failed, - }, + Hint: hint, + Detail: detail, }, Err: err, } } +func baseAttachmentDownloadLogID(err error) string { + var netErr *errs.NetworkError + if errors.As(err, &netErr) { + if id := strings.TrimSpace(netErr.LogID); id != "" { + return id + } + } + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Detail != nil { + if detail, ok := exitErr.Detail.Detail.(map[string]interface{}); ok { + if logID, _ := detail["log_id"].(string); logID != "" { + return strings.TrimSpace(logID) + } + } + } + return "" +} + func outputPathLooksDirectory(runtime *common.RuntimeContext, outputPath string) bool { if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, string(filepath.Separator)) { return true diff --git a/shortcuts/base/record_upsert.go b/shortcuts/base/record_upsert.go index f83f8b8eb..abcea7e9f 100644 --- a/shortcuts/base/record_upsert.go +++ b/shortcuts/base/record_upsert.go @@ -20,12 +20,14 @@ var BaseRecordUpsert = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), recordRefFlag(false), - {Name: "json", Desc: "record JSON object: Map", Required: true}, - }, - Tips: []string{ - `Example: --json '{"Name":"Alice"}'`, - "Agent hint: use the lark-base skill's record-upsert guide for usage and limits.", + {Name: "json", Desc: `record field map JSON object, e.g. {"Name":"Alice","Status":"Todo"}; do not wrap in fields`, Required: true}, }, + Tips: append([]string{ + "Happy path JSON is a top-level field map: each key is a real field name or field ID, each value is that field's CellValue.", + "Without --record-id this creates a record; with --record-id this updates that record. It does not auto-upsert by business key.", + "Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.", + "Use the record-upsert guide for command limits and edge cases.", + }, recordCellValueHappyPathTips...), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateRecordJSON(runtime) }, diff --git a/shortcuts/base/shortcuts.go b/shortcuts/base/shortcuts.go index c98ccff7e..13f2a9210 100644 --- a/shortcuts/base/shortcuts.go +++ b/shortcuts/base/shortcuts.go @@ -84,6 +84,7 @@ func Shortcuts() []common.Shortcut { BaseDashboardArrange, BaseDashboardBlockList, BaseDashboardBlockGet, + BaseDashboardBlockGetData, BaseDashboardBlockCreate, BaseDashboardBlockUpdate, BaseDashboardBlockDelete, diff --git a/shortcuts/base/table_create.go b/shortcuts/base/table_create.go index 3bb65a8a1..b49c0a4c0 100644 --- a/shortcuts/base/table_create.go +++ b/shortcuts/base/table_create.go @@ -20,7 +20,11 @@ var BaseTableCreate = common.Shortcut{ baseTokenFlag(true), {Name: "name", Desc: "table name", Required: true}, {Name: "view", Desc: "view JSON object/array for create"}, - {Name: "fields", Desc: "field JSON array for create"}, + {Name: "fields", Desc: `field JSON array for create, e.g. [{"name":"Title","type":"text"},{"name":"Status","type":"select","options":[{"name":"Todo"},{"name":"Done"}]}]`}, + }, + Tips: []string{ + "Before using --fields, read lark-base-field-json.md or rely on the same field JSON shape used by +field-create; do not invent field properties.", + "The first --fields item replaces the default field.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateTableCreate(runtime) diff --git a/shortcuts/base/table_delete.go b/shortcuts/base/table_delete.go index 58ad50100..0426d5e3f 100644 --- a/shortcuts/base/table_delete.go +++ b/shortcuts/base/table_delete.go @@ -17,7 +17,12 @@ var BaseTableDelete = common.Shortcut{ Scopes: []string{"base:table:delete"}, AuthTypes: authTypes(), Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)}, - DryRun: dryRunTableDelete, + Tips: []string{ + `Example: lark-cli base +table-delete --base-token --table-id "Old Tasks" --yes`, + "table-id accepts a table ID (tbl...) or the table name in the current Base.", + baseHighRiskYesTip, + }, + DryRun: dryRunTableDelete, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeTableDelete(runtime) }, diff --git a/shortcuts/base/table_get.go b/shortcuts/base/table_get.go index c427b4504..ac29a8ee3 100644 --- a/shortcuts/base/table_get.go +++ b/shortcuts/base/table_get.go @@ -17,7 +17,11 @@ var BaseTableGet = common.Shortcut{ Scopes: []string{"base:table:read", "base:field:read", "base:view:read"}, AuthTypes: authTypes(), Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)}, - DryRun: dryRunTableGet, + Tips: []string{ + `Example: lark-cli base +table-get --base-token --table-id "Tasks"`, + "table-id accepts a table ID (tbl...) or the table name in the current Base.", + }, + DryRun: dryRunTableGet, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeTableGet(runtime) }, diff --git a/shortcuts/base/table_list.go b/shortcuts/base/table_list.go index 01de1572d..6c5e58520 100644 --- a/shortcuts/base/table_list.go +++ b/shortcuts/base/table_list.go @@ -19,7 +19,7 @@ var BaseTableList = common.Shortcut{ Flags: []common.Flag{ baseTokenFlag(true), {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, - {Name: "limit", Type: "int", Default: "50", Desc: "pagination limit"}, + {Name: "limit", Type: "int", Default: "50", Desc: "pagination size, range 1-100"}, }, DryRun: dryRunTableList, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/view_create.go b/shortcuts/base/view_create.go index 6e38b0453..9f803a361 100644 --- a/shortcuts/base/view_create.go +++ b/shortcuts/base/view_create.go @@ -19,11 +19,13 @@ var BaseViewCreate = common.Shortcut{ Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), - {Name: "json", Desc: "view JSON object/array", Required: true}, + {Name: "json", Desc: "view JSON object/array; type defaults to grid; type range: grid, kanban, gallery, calendar, gantt", Required: true}, }, Tips: []string{ - `Example: --json '{"name":"Main","type":"grid"}'`, - "Agent hint: use the lark-base skill's view-create guide for usage and limits.", + `Example: lark-cli base +view-create --base-token --table-id --json '{"name":"Main","type":"grid"}'`, + `Minimal: --json '{"name":"Main"}' creates a grid view.`, + "Do not pass form as a view type; form views are managed through form commands.", + `Use +view-set-visible-fields after creation when the user needs a specific field order or visibility.`, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateViewCreate(runtime) diff --git a/shortcuts/base/view_delete.go b/shortcuts/base/view_delete.go index 5db39902b..493e726be 100644 --- a/shortcuts/base/view_delete.go +++ b/shortcuts/base/view_delete.go @@ -17,7 +17,11 @@ var BaseViewDelete = common.Shortcut{ Scopes: []string{"base:view:write_only"}, AuthTypes: authTypes(), Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, - DryRun: dryRunViewDelete, + Tips: []string{ + baseHighRiskYesTip, + `Example: lark-cli base +view-delete --base-token --table-id --view-id "Old View" --yes`, + }, + DryRun: dryRunViewDelete, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeViewDelete(runtime) }, diff --git a/shortcuts/base/view_list.go b/shortcuts/base/view_list.go index 30fba37be..141b26152 100644 --- a/shortcuts/base/view_list.go +++ b/shortcuts/base/view_list.go @@ -20,7 +20,7 @@ var BaseViewList = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, - {Name: "limit", Type: "int", Default: "100", Desc: "pagination size"}, + {Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"}, }, DryRun: dryRunViewList, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/view_set_card.go b/shortcuts/base/view_set_card.go index 410a9b46e..3a0a46d8a 100644 --- a/shortcuts/base/view_set_card.go +++ b/shortcuts/base/view_set_card.go @@ -20,11 +20,12 @@ var BaseViewSetCard = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true), - {Name: "json", Desc: "card JSON object", Required: true}, + {Name: "json", Desc: `card JSON object, e.g. {"cover_field":"Cover"} or {"cover_field":null} to clear`, Required: true}, }, Tips: []string{ - `Example: --json '{"cover_field":"fldCover"}'`, - "Agent hint: use the lark-base skill's view-set-card guide for usage and limits.", + "Supported view types: gallery, kanban.", + "cover_field should be an attachment field id/name, or null to clear.", + "Use +view-get-card first when updating an existing card view configuration.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateViewJSONObject(runtime) diff --git a/shortcuts/base/view_set_filter.go b/shortcuts/base/view_set_filter.go index 721129e06..c3a97762b 100644 --- a/shortcuts/base/view_set_filter.go +++ b/shortcuts/base/view_set_filter.go @@ -20,10 +20,9 @@ var BaseViewSetFilter = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true), - {Name: "json", Desc: "filter JSON object", Required: true}, + {Name: "json", Desc: `filter JSON object, e.g. {"logic":"and","conditions":[["Status","==","Todo"]]}`, Required: true}, }, Tips: []string{ - `Example: --json '{"logic":"and","conditions":[["fldStatus","==","Todo"]]}'`, "Agent hint: use the lark-base skill's view-set-filter guide for usage and limits.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/view_set_group.go b/shortcuts/base/view_set_group.go index b96df3942..5247ca947 100644 --- a/shortcuts/base/view_set_group.go +++ b/shortcuts/base/view_set_group.go @@ -20,11 +20,13 @@ var BaseViewSetGroup = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true), - {Name: "json", Desc: "group JSON object", Required: true}, + {Name: "json", Desc: `group JSON object with group_config array, e.g. {"group_config":[{"field":"Status","desc":false}]}; use {"group_config":[]} to clear`, Required: true}, }, Tips: []string{ - `Example: --json '{"group_config":[{"field":"fldStatus","desc":false}]}'`, - "Agent hint: use the lark-base skill's view-set-group guide for usage and limits.", + "Supported view types: grid, kanban, gantt.", + "Use a JSON object, not a bare array; grouping fields must be supported by the current view.", + "group_config supports max 3 group items.", + "Use +view-get-group first when modifying an existing grouping configuration.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateViewJSONObject(runtime) diff --git a/shortcuts/base/view_set_sort.go b/shortcuts/base/view_set_sort.go index 5ce24a562..743e722c3 100644 --- a/shortcuts/base/view_set_sort.go +++ b/shortcuts/base/view_set_sort.go @@ -20,11 +20,13 @@ var BaseViewSetSort = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true), - {Name: "json", Desc: "sort_config JSON object", Required: true}, + {Name: "json", Desc: `sort_config JSON object, e.g. {"sort_config":[{"field":"Priority","desc":true}]}; use {"sort_config":[]} to clear; max 10 items`, Required: true}, }, Tips: []string{ - `Example: --json '{"sort_config":[{"field":"fldPriority","desc":true}]}'`, - "Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.", + "Supported view types: grid, kanban, gallery, gantt.", + "Use a JSON object, not a bare array; sorting fields must be supported by the current view.", + "sort_config supports max 10 sort items.", + "Use +view-get-sort first when modifying an existing sort configuration.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateViewJSONObject(runtime) diff --git a/shortcuts/base/view_set_timebar.go b/shortcuts/base/view_set_timebar.go index 88f23a936..f037aacc8 100644 --- a/shortcuts/base/view_set_timebar.go +++ b/shortcuts/base/view_set_timebar.go @@ -20,11 +20,12 @@ var BaseViewSetTimebar = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true), - {Name: "json", Desc: "timebar JSON object", Required: true}, + {Name: "json", Desc: `timebar JSON object with start_time, end_time, title, e.g. {"start_time":"Start Date","end_time":"End Date","title":"Name"}`, Required: true}, }, Tips: []string{ - `Example: --json '{"start_time":"fldStart","end_time":"fldEnd","title":"fldTitle"}'`, - "Agent hint: use the lark-base skill's view-set-timebar guide for usage and limits.", + "Supported view types: calendar, gantt.", + "start_time, end_time, and title are required; use date/time fields for start_time and end_time.", + "Use +view-get-timebar first when modifying an existing timebar configuration.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateViewJSONObject(runtime) diff --git a/shortcuts/base/view_set_visible_fields.go b/shortcuts/base/view_set_visible_fields.go index 48c7b0fb9..e1b54b121 100644 --- a/shortcuts/base/view_set_visible_fields.go +++ b/shortcuts/base/view_set_visible_fields.go @@ -20,11 +20,12 @@ var BaseViewSetVisibleFields = common.Shortcut{ baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true), - {Name: "json", Desc: `visible fields JSON object with "visible_fields"`, Required: true}, + {Name: "json", Desc: `visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`, Required: true}, }, Tips: []string{ - `Example: --json '{"visible_fields":["fldXXX"]}'`, - "Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.", + "Supported view types: grid, kanban, gallery, calendar, gantt.", + "Use a JSON object, not a bare array; primary field may be forced to the first position by the API.", + "visible_fields controls both visibility and order; include every field that should remain visible.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateViewJSONObject(runtime) diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go index c2c3c8c9d..30c7beb09 100644 --- a/shortcuts/base/workflow_create.go +++ b/shortcuts/base/workflow_create.go @@ -19,7 +19,15 @@ var BaseWorkflowCreate = common.Shortcut{ AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, - {Name: "json", Desc: `workflow body JSON, e.g. {"title":"My Workflow","steps":[...]}`, Required: true}, + {Name: "json", Desc: "workflow body JSON; read lark-base-workflow-guide.md and lark-base-workflow-schema.md before constructing steps", Required: true}, + }, + Tips: []string{ + "lark-cli base +workflow-create --base-token --json @workflow.json", + "client_token is required and should be unique per create request.", + "New workflows are created disabled; call +workflow-enable after creation when the user wants it active.", + "Before constructing steps, use +table-list and +field-list to confirm real table and field names.", + "Step ids must be unique, and every next/children link must reference an existing step id.", + "Use lark-base-workflow-guide.md as the entry guide and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { diff --git a/shortcuts/base/workflow_disable.go b/shortcuts/base/workflow_disable.go index 7b3e2c3cd..582c6f88b 100644 --- a/shortcuts/base/workflow_disable.go +++ b/shortcuts/base/workflow_disable.go @@ -21,6 +21,10 @@ var BaseWorkflowDisable = common.Shortcut{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, }, + Tips: []string{ + "workflow-id must start with wkf; do not pass a tbl table ID from the same URL.", + "Disable only changes workflow state; it does not delete the workflow or its steps.", + }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { return common.FlagErrorf("--base-token must not be blank") diff --git a/shortcuts/base/workflow_enable.go b/shortcuts/base/workflow_enable.go index 3bf9e96d9..98cba38c0 100644 --- a/shortcuts/base/workflow_enable.go +++ b/shortcuts/base/workflow_enable.go @@ -21,6 +21,11 @@ var BaseWorkflowEnable = common.Shortcut{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, }, + Tips: []string{ + "workflow-id must start with wkf; do not pass a tbl table ID from the same URL.", + "Enable only changes workflow state; it does not modify steps.", + "New workflows are created disabled; enable after creation only when the user wants it active.", + }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { return common.FlagErrorf("--base-token must not be blank") diff --git a/shortcuts/base/workflow_get.go b/shortcuts/base/workflow_get.go index 2a7517be2..f0d591f4a 100644 --- a/shortcuts/base/workflow_get.go +++ b/shortcuts/base/workflow_get.go @@ -20,7 +20,13 @@ var BaseWorkflowGet = common.Shortcut{ Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, - {Name: "user-id-type", Desc: "user ID type for creator/updater fields", Enum: []string{"open_id", "union_id", "user_id"}}, + {Name: "user-id-type", Desc: "user ID type for creator/updater fields, default open_id", Enum: []string{"open_id", "union_id", "user_id"}}, + }, + Tips: []string{ + "workflow-id must start with wkf; use +workflow-list if the ID is unknown.", + "steps may be an empty array; that is valid for an unconfigured workflow.", + "Use +workflow-get before +workflow-update, then edit the returned definition and keep fields you do not intend to change.", + "Read lark-base-workflow-schema.md when interpreting or reusing returned steps.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { diff --git a/shortcuts/base/workflow_list.go b/shortcuts/base/workflow_list.go index 92a405400..2ee6a17b7 100644 --- a/shortcuts/base/workflow_list.go +++ b/shortcuts/base/workflow_list.go @@ -20,7 +20,11 @@ var BaseWorkflowList = common.Shortcut{ Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "status", Desc: "filter by status", Enum: []string{"enabled", "disabled"}}, - {Name: "page-size", Type: "int", Default: "100", Desc: "page size per request (max 100)"}, + {Name: "page-size", Type: "int", Default: "100", Desc: "page size per request, max 100"}, + }, + Tips: []string{ + "Returns workflow_id values with wkf prefix; pass those IDs to +workflow-get/enable/disable/update.", + "This shortcut auto-paginates and returns all matched workflows.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index ff56a7be4..e9ffa4de8 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -20,7 +20,16 @@ var BaseWorkflowUpdate = common.Shortcut{ Flags: []common.Flag{ {Name: "base-token", Desc: "base token", Required: true}, {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, - {Name: "json", Desc: `workflow body JSON, e.g. {"title":"New Title","steps":[...]}`, Required: true}, + {Name: "json", Desc: "workflow body JSON; read lark-base-workflow-guide.md and lark-base-workflow-schema.md before replacing steps", Required: true}, + }, + Tips: []string{ + "lark-cli base +workflow-update --base-token --workflow-id --json @workflow.json", + "PUT uses full replacement semantics; omitting steps clears the existing workflow steps.", + "Use +workflow-get first, then edit the returned definition and keep title/status/steps fields you do not intend to change.", + "workflow-id must start with wkf; do not pass a tbl table ID.", + "Step ids must be unique, and every next/children link must reference an existing step id.", + "Updating does not enable or disable a workflow; call +workflow-enable or +workflow-disable separately.", + "Use lark-base-workflow-guide.md as the entry guide and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("base-token")) == "" { diff --git a/shortcuts/calendar/helpers.go b/shortcuts/calendar/helpers.go index a61c04b0a..b9511fea4 100644 --- a/shortcuts/calendar/helpers.go +++ b/shortcuts/calendar/helpers.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -48,5 +48,5 @@ func rejectCalendarAutoBotFallback(runtime *common.RuntimeContext) error { msg := "calendar commands require a valid user login by default; when no valid user login state is available, auto identity falls back to bot and may operate on the bot calendar instead of your own. Run `lark-cli auth login --domain calendar` for your calendar, or rerun with `--as bot` if bot identity is intentional." hint := "restore user login: `lark-cli auth login --domain calendar`\nintentional bot usage: rerun with `--as bot`" - return output.ErrWithHint(output.ExitAuth, "calendar_user_login_required", msg, hint) + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "%s", msg).WithHint("%s", hint) } diff --git a/shortcuts/common/call_api_typed_test.go b/shortcuts/common/call_api_typed_test.go new file mode 100644 index 000000000..40925e029 --- /dev/null +++ b/shortcuts/common/call_api_typed_test.go @@ -0,0 +1,200 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "errors" + "net/http" + "testing" + + "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" +) + +func newCallAPITypedRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) { + t.Helper() + cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"} + f, _, _, reg := cmdutil.TestFactory(t, cfg) + rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, cfg, f, core.AsUser) + return rt, reg +} + +// TestCallAPITyped_HeaderOnlyLogID pins the P1 fix: when the server returns +// log_id only in the x-tt-logid response header (not in the JSON body), the +// typed error still carries it. The legacy runtime.CallAPI path (body-only) +// dropped it. +func TestCallAPITyped_HeaderOnlyLogID(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/x/y", + Headers: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Tt-Logid": []string{"hdr-log-123"}, + }, + Body: map[string]interface{}{"code": float64(1061044), "msg": "boom"}, // no log_id in body + }) + + _, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{}) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T: %v", err, err) + } + if p.LogID != "hdr-log-123" { + t.Errorf("LogID = %q, want %q (lifted from x-tt-logid header)", p.LogID, "hdr-log-123") + } +} + +// TestCallAPITyped_BodyLogID confirms body-level log_id still surfaces. +func TestCallAPITyped_BodyLogID(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/x/y", + Body: map[string]interface{}{"code": float64(1061044), "msg": "boom", "log_id": "body-log-9"}, + }) + + _, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{}) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if p.LogID != "body-log-9" { + t.Errorf("LogID = %q, want body-log-9", p.LogID) + } +} + +// TestCallAPITyped_Success returns the data object on code 0, and does not leak +// the header log_id into the success payload (log_id surfacing is error-path +// only — success output stays identical to the legacy CallAPI). +func TestCallAPITyped_Success(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/x/y", + Headers: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Tt-Logid": []string{"hdr-log-ok"}, + }, + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"token": "tok1"}}, + }) + + data, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data["token"] != "tok1" { + t.Errorf("data[token] = %v, want tok1", data["token"]) + } + if _, leaked := data["log_id"]; leaked { + t.Errorf("success data must not carry log_id, got: %v", data) + } +} + +// TestAPIClassifyContext verifies the classify context is built from the +// runtime: Brand / AppID from config, Identity from the resolved caller, and +// LarkCmd from the running command path. +func TestAPIClassifyContext(t *testing.T) { + cfg := &core.CliConfig{Brand: core.BrandLark, AppID: "cli_x"} + rt := TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "+upload"}, cfg, core.AsUser) + + cc := rt.APIClassifyContext() + if cc.Brand != "lark" { + t.Errorf("Brand = %q, want lark", cc.Brand) + } + if cc.AppID != "cli_x" { + t.Errorf("AppID = %q, want cli_x", cc.AppID) + } + if cc.Identity != "user" { + t.Errorf("Identity = %q, want user", cc.Identity) + } + if cc.LarkCmd != "+upload" { + t.Errorf("LarkCmd = %q, want +upload", cc.LarkCmd) + } + + bot := TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "+push"}, &core.CliConfig{Brand: core.BrandFeishu, AppID: "y"}, core.AsBot) + if got := bot.APIClassifyContext().Identity; got != "bot" { + t.Errorf("bot Identity = %q, want bot", got) + } +} + +// TestCallAPITyped_NonJSON5xx pins that a non-JSON HTTP 5xx (e.g. a gateway 502 +// text/html page) is a retryable network/server_error carrying the header +// log_id — not a mis-parsed internal/invalid_response. +func TestCallAPITyped_NonJSON5xx(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/x/y", + Status: 502, + Headers: http.Header{ + "Content-Type": []string{"text/html"}, + "X-Tt-Logid": []string{"hdr-502"}, + }, + RawBody: []byte("502 Bad Gateway"), + }) + + _, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{}) + var netErr *errs.NetworkError + if !errors.As(err, &netErr) { + t.Fatalf("expected *errs.NetworkError for non-JSON 5xx, got %T: %v", err, err) + } + if netErr.Subtype != errs.SubtypeNetworkServer { + t.Errorf("subtype = %q, want %q", netErr.Subtype, errs.SubtypeNetworkServer) + } + if !netErr.Retryable { + t.Error("5xx network error must be retryable") + } + if netErr.LogID != "hdr-502" { + t.Errorf("LogID = %q, want hdr-502 (from header)", netErr.LogID) + } +} + +// TestCallAPITyped_5xxNoContentType pins that a 5xx with no Content-Type (which +// the body-only parse would mis-classify as invalid_response) is still a +// retryable network/server_error. +func TestCallAPITyped_5xxNoContentType(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/x/y", + Status: 503, + Headers: http.Header{}, // explicitly no Content-Type header + RawBody: []byte("service unavailable"), + }) + + _, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{}) + var netErr *errs.NetworkError + if !errors.As(err, &netErr) || netErr.Subtype != errs.SubtypeNetworkServer { + t.Fatalf("expected retryable network/server_error, got %T: %v", err, err) + } + if !netErr.Retryable { + t.Error("5xx network error must be retryable") + } +} + +// TestCallAPITyped_NonObjectJSON pins that a top-level non-object JSON body +// (e.g. "[]") is rejected as an invalid response, never a silent success ack. +func TestCallAPITyped_NonObjectJSON(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/x/y", + RawBody: []byte("[]"), + }) + + _, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{}) + var intErr *errs.InternalError + if !errors.As(err, &intErr) { + t.Fatalf("expected *errs.InternalError for non-object JSON, got %T: %v", err, err) + } + if intErr.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse) + } +} diff --git a/shortcuts/common/drive_media_upload.go b/shortcuts/common/drive_media_upload.go index 7c3a2cec5..146748676 100644 --- a/shortcuts/common/drive_media_upload.go +++ b/shortcuts/common/drive_media_upload.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "net/http" + "strings" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -170,13 +171,34 @@ func ParseDriveMediaUploadResponse(apiResp *larkcore.ApiResp, action string) (ma if larkCode := int(GetFloat(result, "code")); larkCode != 0 { msg, _ := result["msg"].(string) - return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), result["error"]) + return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), driveMediaUploadErrorDetail(apiResp, result["error"])) } data, _ := result["data"].(map[string]interface{}) return data, nil } +func driveMediaUploadErrorDetail(apiResp *larkcore.ApiResp, detail interface{}) interface{} { + logID := "" + if apiResp != nil { + logID = strings.TrimSpace(apiResp.LogId()) + } + if logID == "" { + return detail + } + detailMap, ok := detail.(map[string]interface{}) + if !ok { + if detail == nil { + return map[string]interface{}{"log_id": logID} + } + return map[string]interface{}{"error": detail, "log_id": logID} + } + if _, exists := detailMap["log_id"]; !exists { + detailMap["log_id"] = logID + } + return detailMap +} + func ExtractDriveMediaUploadFileToken(data map[string]interface{}, action string) (string, error) { fileToken := GetString(data, "file_token") if fileToken == "" { diff --git a/shortcuts/common/drive_media_upload_test.go b/shortcuts/common/drive_media_upload_test.go index fcce15cc5..062b343a0 100644 --- a/shortcuts/common/drive_media_upload_test.go +++ b/shortcuts/common/drive_media_upload_test.go @@ -7,10 +7,12 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "mime" "mime/multipart" + "net/http" "os" "strings" "sync/atomic" @@ -21,6 +23,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" ) var commonDriveMediaUploadTestSeq atomic.Int64 @@ -459,6 +462,24 @@ func TestParseDriveMediaUploadResponseErrors(t *testing.T) { t.Fatalf("expected API error, got %v", err) } }) + + t.Run("api code error includes log_id", func(t *testing.T) { + t.Parallel() + + resp := &larkcore.ApiResp{ + RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`), + Header: http.Header{"X-Tt-Logid": []string{"202605270002"}}, + } + _, err := ParseDriveMediaUploadResponse(resp, "upload media failed") + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured error, got %T %v", err, err) + } + detail, _ := exitErr.Detail.Detail.(map[string]interface{}) + if detail["log_id"] != "202605270002" { + t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail) + } + }) } func TestExtractDriveMediaUploadFileTokenRequiresToken(t *testing.T) { diff --git a/shortcuts/common/mcp_client.go b/shortcuts/common/mcp_client.go index 5e87eb66b..895aa2a4c 100644 --- a/shortcuts/common/mcp_client.go +++ b/shortcuts/common/mcp_client.go @@ -14,8 +14,9 @@ import ( "github.com/google/uuid" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/util" ) @@ -34,7 +35,7 @@ func CallMCPTool(runtime *RuntimeContext, toolName string, args map[string]inter httpClient, err := runtime.Factory.HttpClient() if err != nil { - return nil, output.ErrNetwork("failed to get HTTP client: %v", err) + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to get HTTP client: %v", err).WithCause(err) } raw, err := DoMCPCall(runtime.Ctx(), httpClient, toolName, args, accessToken, MCPEndpoint(runtime.Config.Brand), runtime.IsBot()) @@ -49,7 +50,7 @@ func normalizeMCPToolResult(raw interface{}) (map[string]interface{}, error) { result := ExtractMCPResult(raw) if m, ok := result.(map[string]interface{}); ok { if errMsg, ok := m["error"].(string); ok && strings.TrimSpace(errMsg) != "" { - return nil, output.Errorf(output.ExitAPI, "mcp_error", "MCP: %s", errMsg) + return nil, errs.NewAPIError(errs.SubtypeUnknown, "MCP: %s", errMsg) } return m, nil } @@ -72,12 +73,12 @@ func DoMCPCall(ctx context.Context, httpClient *http.Client, toolName string, ar jsonBody, err := json.Marshal(body) if err != nil { - return nil, output.Errorf(output.ExitInternal, "internal_error", "failed to marshal MCP request body: %v", err) + return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to marshal MCP request body: %v", err).WithCause(err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, mcpEndpoint, bytes.NewReader(jsonBody)) if err != nil { - return nil, output.Errorf(output.ExitInternal, "internal_error", "failed to create MCP request: %v", err) + return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to create MCP request: %v", err).WithCause(err) } req.Header.Set("Content-Type", "application/json") if isBot { @@ -89,13 +90,13 @@ func DoMCPCall(ctx context.Context, httpClient *http.Client, toolName string, ar resp, err := httpClient.Do(req) if err != nil { - return nil, output.ErrNetwork("MCP transport failed: %v", err) + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "MCP transport failed: %v", err).WithCause(err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, output.ErrNetwork("failed to read MCP response: %v", err) + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to read MCP response: %v", err).WithCause(err) } if resp.StatusCode >= 400 { return nil, classifyMCPHTTPError(resp.StatusCode, resp.Status, respBody) @@ -103,7 +104,9 @@ func DoMCPCall(ctx context.Context, httpClient *http.Client, toolName string, ar var data map[string]interface{} if err := json.Unmarshal(respBody, &data); err != nil { - return nil, output.Errorf(output.ExitAPI, "api_error", "MCP returned non-JSON: %s", TruncateStr(string(respBody), mcpErrorBodyLimit)) + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, + "MCP returned non-JSON: %s", TruncateStr(string(respBody), mcpErrorBodyLimit)). + WithCause(err) } if errObj, ok := data["error"]; ok { @@ -119,16 +122,19 @@ func classifyMCPHTTPError(statusCode int, status string, body []byte) error { if errObj, ok := payload["error"]; ok { return classifyMCPPayloadError(errObj) } - if code, msg, detail, ok := extractMCPBusinessError(payload); ok { - return output.ErrAPI(code, fmt.Sprintf("MCP HTTP %d %s: [%d] %s", statusCode, status, code, msg), detail) + if code, msg, ok := extractMCPBusinessError(payload); ok { + return errs.NewAPIError(errs.SubtypeUnknown, "MCP HTTP %d %s: [%d] %s", statusCode, status, code, msg).WithCode(code) } } bodyText := TruncateStr(strings.TrimSpace(string(body)), mcpErrorBodyLimit) if statusCode == http.StatusUnauthorized { - return output.ErrAuth("MCP HTTP %d %s: %s", statusCode, status, bodyText) + return errs.NewAuthenticationError(errs.SubtypeTokenInvalid, "MCP HTTP %d %s: %s", statusCode, status, bodyText).WithCode(statusCode) } - return output.Errorf(output.ExitAPI, "api_error", "MCP HTTP %d %s: %s", statusCode, status, bodyText) + if statusCode >= 500 { + return errs.NewNetworkError(errs.SubtypeNetworkServer, "MCP HTTP %d %s: %s", statusCode, status, bodyText).WithCode(statusCode) + } + return errs.NewAPIError(errs.SubtypeUnknown, "MCP HTTP %d %s: %s", statusCode, status, bodyText).WithCode(statusCode) } func classifyMCPPayloadError(errObj interface{}) error { @@ -138,54 +144,45 @@ func classifyMCPPayloadError(errObj interface{}) error { msg = GetString(errMap, "msg") } if code, ok := util.ToFloat64(errMap["code"]); ok { - return output.ErrAPI(int(code), fmt.Sprintf("MCP: [%.0f] %s", code, msg), errMap) + // Route known Lark error codes through errclass so 99991668-style + // codes become typed (Authentication / Permission / ...) rather + // than generic APIError. Falls back to APIError for unknown codes. + payload := map[string]any{"code": int(code), "msg": msg, "error": errMap} + if classified := errclass.BuildAPIError(payload, errclass.ClassifyContext{}); classified != nil { + return classified + } + return errs.NewAPIError(errs.SubtypeUnknown, "MCP: [%.0f] %s", code, msg).WithCode(int(code)) } if msg != "" { - return classifyMCPMessageError(fmt.Sprintf("MCP: %s", msg), errMap) + return classifyMCPMessageError(fmt.Sprintf("MCP: %s", msg)) } } if msg, ok := errObj.(string); ok && strings.TrimSpace(msg) != "" { - return classifyMCPMessageError(fmt.Sprintf("MCP: %s", msg), errObj) + return classifyMCPMessageError(fmt.Sprintf("MCP: %s", msg)) } - return output.Errorf(output.ExitAPI, "api_error", "MCP returned an error response") + return errs.NewAPIError(errs.SubtypeUnknown, "MCP returned an error response") } -func classifyMCPMessageError(msg string, detail interface{}) error { +func classifyMCPMessageError(msg string) error { lower := strings.ToLower(msg) switch { case strings.Contains(lower, "unauthorized"), strings.Contains(lower, "access token"), strings.Contains(lower, "token invalid"), strings.Contains(lower, "token expired"): - return &output.ExitError{ - Code: output.ExitAuth, - Detail: &output.ErrDetail{ - Type: "auth", - Message: msg, - Hint: "run `lark-cli auth login` in the background to re-authorize. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", - Detail: detail, - }, - } + return errs.NewAuthenticationError(errs.SubtypeTokenInvalid, "%s", msg). + WithHint("run `lark-cli auth login` in the background to re-authorize. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.") default: - code, errType, hint := output.ClassifyLarkError(0, msg) - return &output.ExitError{ - Code: code, - Detail: &output.ErrDetail{ - Type: errType, - Message: msg, - Hint: hint, - Detail: detail, - }, - } + return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg) } } -func extractMCPBusinessError(payload map[string]interface{}) (int, string, interface{}, bool) { +func extractMCPBusinessError(payload map[string]interface{}) (int, string, bool) { code, ok := util.ToFloat64(payload["code"]) if !ok || code == 0 { - return 0, "", nil, false + return 0, "", false } msg := GetString(payload, "msg") @@ -195,7 +192,7 @@ func extractMCPBusinessError(payload map[string]interface{}) (int, string, inter if msg == "" { msg = "unknown MCP error" } - return int(code), msg, payload["error"], true + return int(code), msg, true } func UnwrapMCPResult(v interface{}) interface{} { diff --git a/shortcuts/common/mcp_client_test.go b/shortcuts/common/mcp_client_test.go index 6a1b03a19..da021a22d 100644 --- a/shortcuts/common/mcp_client_test.go +++ b/shortcuts/common/mcp_client_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" ) @@ -53,15 +54,15 @@ func TestDoMCPCallJSONRPCErrorUsesLarkClassification(t *testing.T) { } _, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "uat-token", "https://example.com/mcp", false) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected ExitError, got %v", err) + if err == nil { + t.Fatal("expected error, got nil") } - if exitErr.Code != output.ExitAuth { - t.Fatalf("expected auth exit code, got %d", exitErr.Code) + if got := output.ExitCodeOf(err); got != output.ExitAuth { + t.Fatalf("expected auth exit code (%d), got %d", output.ExitAuth, got) } - if exitErr.Detail == nil || exitErr.Detail.Type != "auth" { - t.Fatalf("expected auth detail, got %#v", exitErr.Detail) + var authErr *errs.AuthenticationError + if !errors.As(err, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T: %v", err, err) } } diff --git a/shortcuts/common/resource_url.go b/shortcuts/common/resource_url.go index 69345ea90..29ec31c10 100644 --- a/shortcuts/common/resource_url.go +++ b/shortcuts/common/resource_url.go @@ -73,6 +73,9 @@ var urlPathToType = []struct { Type string }{ {"/drive/folder/", "folder"}, + {"/drive/file/", "file"}, + {"/drive/shr/", "folder"}, + {"/chat/drive/", "folder"}, {"/docx/", "docx"}, {"/doc/", "doc"}, {"/sheets/", "sheet"}, diff --git a/shortcuts/common/resource_url_test.go b/shortcuts/common/resource_url_test.go index baa1165f1..c0109fe9c 100644 --- a/shortcuts/common/resource_url_test.go +++ b/shortcuts/common/resource_url_test.go @@ -28,6 +28,9 @@ func TestParseResourceURL(t *testing.T) { {"wiki", "https://xxx.feishu.cn/wiki/wikcnABC", "wiki", "wikcnABC", true}, {"file", "https://xxx.feishu.cn/file/boxcnABC", "file", "boxcnABC", true}, {"folder", "https://xxx.feishu.cn/drive/folder/fldcnABC", "folder", "fldcnABC", true}, + {"file via /drive/file/", "https://feishu.doubao.com/drive/file/boxcnABC", "file", "boxcnABC", true}, + {"folder via /chat/drive/", "https://feishu.doubao.com/chat/drive/fldcnABC", "folder", "fldcnABC", true}, + {"folder via /drive/shr/", "https://feishu.doubao.com/drive/shr/fldcnABC", "folder", "fldcnABC", true}, {"mindnote", "https://xxx.feishu.cn/mindnote/mncnABC", "mindnote", "mncnABC", true}, {"slides", "https://xxx.feishu.cn/slides/slkcnABC", "slides", "slkcnABC", true}, diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 962012c8f..81baf3ab9 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -19,12 +19,15 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/errclass" + "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" ) @@ -73,6 +76,13 @@ func (ctx *RuntimeContext) IsBot() bool { // UserOpenId returns the current user's open_id from config. func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId } +// Lang returns the user's preference as a canonical locale, or "" if unset or +// unrecognized; callers choose their own fallback. +func (ctx *RuntimeContext) Lang() i18n.Lang { + lang, _ := i18n.Parse(string(ctx.Config.Lang)) + return lang +} + // BotInfo holds bot identity metadata fetched lazily from /bot/v3/info. type BotInfo struct { OpenID string @@ -225,6 +235,133 @@ func (ctx *RuntimeContext) CallAPI(method, url string, params map[string]interfa return HandleApiResult(result, err, "API call failed") } +// CallAPITyped is the typed-only replacement for CallAPI: it performs the same +// SDK request (buildRequest → APIClient.DoAPI → DoSDKRequest, identical +// transport and query model to CallAPI) and returns the "data" object, but +// classifies failures into typed errs.* errors via errclass.BuildAPIError. +// +// A transport / auth error from the client boundary is already typed and passes +// through unchanged; a non-zero API response code is classified into a typed +// error carrying subtype / code / log_id. Unlike CallAPI it never emits a legacy +// output.ExitError envelope, and never downgrades a typed network/auth error. +// +// It lifts x-tt-logid from the response header (which the body-only parse drops) +// so log_id surfaces on the typed error even when the server returns it only in +// the header. +func (ctx *RuntimeContext) CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { + ac, err := ctx.getAPIClient() + if err != nil { + return nil, typedOrInternal(err) + } + resp, err := ac.DoAPI(ctx.ctx, ctx.buildRequest(method, url, params, data)) + if err != nil { + return nil, typedOrInternal(err) + } + return ctx.ClassifyAPIResponse(resp) +} + +// ClassifyAPIResponse turns a raw *larkcore.ApiResp into the "data" object or a +// typed errs.* error. It is the shared response classifier for typed API paths +// — used by CallAPITyped and by callers that drive the request themselves +// (e.g. file upload via DoAPI). It: +// +// 1. parses the JSON body; an unparseable body on an HTTP error status (a +// gateway 5xx text/html page, an empty body, a missing Content-Type) is +// classified by status — 5xx → retryable network/server_error, 404 → +// not_found, other 4xx → api error — not a misleading invalid-response +// internal error; +// 2. rejects a top-level non-object JSON ([], null, scalar) as an +// invalid-response internal error — never a silent success ack; +// 3. lifts x-tt-logid from the response header onto the typed error so log_id +// surfaces even when the body omits it; +// 4. classifies a non-zero API code via errclass.BuildAPIError, and treats any +// HTTP error status that parsed to code==0 as a status error. +// +// The success "data" object is returned untouched. On a non-zero API code the +// data is returned alongside the typed error, since the response can still +// 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) { + logID, _ := logIDFromHeader(resp)["log_id"].(string) + + result, parseErr := client.ParseJSONResponse(resp) + if parseErr != nil { + if resp.StatusCode >= 400 { + return nil, httpStatusError(resp.StatusCode, resp.RawBody, logID) + } + return nil, client.WrapJSONResponseParseError(parseErr, resp.RawBody) + } + resultMap, ok := result.(map[string]interface{}) + if !ok { + e := errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response") + if logID != "" { + e = e.WithLogID(logID) + } + return nil, e + } + if logID != "" { + if _, present := resultMap["log_id"]; !present { + resultMap["log_id"] = logID + } + } + out, _ := resultMap["data"].(map[string]interface{}) + if apiErr := errclass.BuildAPIError(resultMap, ctx.APIClassifyContext()); apiErr != nil { + return out, apiErr + } + if resp.StatusCode >= 400 { + return out, httpStatusError(resp.StatusCode, resp.RawBody, logID) + } + return out, nil +} + +// httpStatusError classifies an HTTP error status whose body is not a usable +// API envelope: 5xx → retryable network/server_error, 404 → not_found, other +// 4xx → api error. The x-tt-logid (when present) is attached for diagnosis. +func httpStatusError(status int, rawBody []byte, logID string) error { + body := TruncateStr(strings.TrimSpace(string(rawBody)), 500) + if status >= 500 { + e := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", status, body).WithCode(status).WithRetryable() + if logID != "" { + e = e.WithLogID(logID) + } + return e + } + subtype := errs.SubtypeUnknown + if status == http.StatusNotFound { + subtype = errs.SubtypeNotFound + } + e := errs.NewAPIError(subtype, "HTTP %d: %s", status, body).WithCode(status) + if logID != "" { + e = e.WithLogID(logID) + } + return e +} + +// typedOrInternal passes an already-typed errs.* error through unchanged and +// lifts a still-untyped one to a typed internal error, so CallAPITyped never +// returns a bare/legacy error. +func typedOrInternal(err error) error { + if _, ok := errs.ProblemOf(err); ok { + return err + } + return errs.WrapInternal(err) +} + +// APIClassifyContext builds the errclass.ClassifyContext for the running command +// from the runtime config and resolved identity. +func (ctx *RuntimeContext) APIClassifyContext() errclass.ClassifyContext { + larkCmd := "" + if ctx.Cmd != nil { + larkCmd = strings.TrimPrefix(ctx.Cmd.CommandPath(), "lark ") + } + return errclass.ClassifyContext{ + Brand: string(ctx.Config.Brand), + AppID: ctx.Config.AppID, + Identity: string(ctx.As()), + LarkCmd: larkCmd, + } +} + // Deprecated: RawAPI uses an internal HTTP wrapper with limited control over request/response. // Prefer DoAPI for new code — it calls the Lark SDK directly and supports file upload/download options. // @@ -544,28 +681,47 @@ func (ctx *RuntimeContext) ValidatePath(path string) error { // Out prints a success JSON envelope to stdout. func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) { - ctx.emit(data, meta, false) + ctx.emit(data, meta, false, true) } // OutRaw prints a success JSON envelope to stdout with HTML escaping disabled. // Use this instead of Out when the data contains XML/HTML content (e.g. document bodies) // that should be preserved as-is in JSON output. func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) { - ctx.emit(data, meta, true) + ctx.emit(data, meta, true, true) } -// emit is the shared success-path emitter. raw=true disables JSON HTML escaping so -// XML/HTML payloads (e.g. DocxXML bodies) are preserved verbatim; otherwise behavior +// OutPartialFailure writes an ok:false multi-status result envelope to stdout +// and returns the partial-failure exit signal. Use it for batch operations +// where some items failed but the per-item outcomes are the primary output: +// the full result (summary + per-item statuses) stays machine-readable on +// stdout, the process exits non-zero, and nothing is written to stderr. +// +// It is the typed alternative to `Out(...)` + `output.ErrBare(...)` — the +// envelope's ok field honestly reports failure instead of a misleading +// ok:true, and the exit signal is distinct from the predicate-only ErrBare. +func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta) error { + ctx.emit(data, meta, false, false) + if ctx.outputErr != nil { + return ctx.outputErr + } + return output.PartialFailure(output.ExitAPI) +} + +// emit is the shared stdout envelope emitter; ok sets the envelope's ok field +// (true for success, false for a partial-failure result). raw=true disables JSON +// HTML escaping so XML/HTML payloads (e.g. DocxXML bodies) are preserved +// verbatim; otherwise behavior // is identical — content-safety scanning and race-safe first-error capture via // outputErrOnce apply in both modes. -func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw bool) { +func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw, ok bool) { scanResult := output.ScanForSafety(ctx.Cmd.CommandPath(), data, ctx.IO().ErrOut) if scanResult.Blocked { ctx.outputErrOnce.Do(func() { ctx.outputErr = scanResult.BlockErr }) return } - env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()} + env := output.Envelope{OK: ok, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()} if scanResult.Alert != nil { env.ContentSafetyAlert = scanResult.Alert } @@ -674,24 +830,31 @@ func checkScopePrereqs(f *cmdutil.Factory, ctx context.Context, appID string, id // enhancePermissionError enriches a permission / auth error with the // shortcut's declared required scopes so the user knows exactly what to do. +// +// Detection is typed: an error qualifies when it (or any error in its +// Unwrap chain) is *errs.PermissionError, or — for legacy bridge paths — +// when it is an *output.ExitError carrying Detail.Type "permission" or +// "missing_scope". The previous implementation scanned the upstream +// message text for keywords like "permission" / "scope" / "unauthorized", +// which was brittle to canonical-message rewrites; routing on the typed +// shape decouples this helper from the wording. func enhancePermissionError(err error, requiredScopes []string) error { + var permErr *errs.PermissionError + if errors.As(err, &permErr) { + scopeDisplay := strings.Join(requiredScopes, ", ") + scopeArg := strings.Join(requiredScopes, " ") + hint := fmt.Sprintf( + "this command requires scope(s): %s\nrun `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", + scopeDisplay, scopeArg) + permErr.Hint = hint + return err + } + var exitErr *output.ExitError if !errors.As(err, &exitErr) || exitErr.Detail == nil { return err } - - // Detect permission-related errors by type or message keywords. - isPermErr := exitErr.Detail.Type == "permission" || exitErr.Detail.Type == "missing_scope" - if !isPermErr { - lower := strings.ToLower(exitErr.Detail.Message) - for _, kw := range []string{"permission", "scope", "authorization", "unauthorized"} { - if strings.Contains(lower, kw) { - isPermErr = true - break - } - } - } - if !isPermErr { + if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "missing_scope" { return err } @@ -823,9 +986,11 @@ func checkShortcutScopes(f *cmdutil.Factory, ctx context.Context, as core.Identi if len(missing) == 0 { return nil } - return output.ErrWithHint(output.ExitAuth, "missing_scope", - fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))) + return errs.NewPermissionError(errs.SubtypeMissingScope, + "missing required scope(s): %s", strings.Join(missing, ", ")). + WithIdentity(string(as)). + WithMissingScopes(missing...). + WithHint("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")) } func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) (*RuntimeContext, error) { @@ -843,9 +1008,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf } rctx.larkSDK = sdk - if s.HasFormat { - rctx.Format = rctx.Str("format") - } + rctx.Format = rctx.Str("format") rctx.JqExpr, _ = cmd.Flags().GetString("jq") return rctx, nil } @@ -1022,19 +1185,17 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f } cmd.Flags().Bool("dry-run", false, "print request without executing") - if s.HasFormat { + if cmd.Flags().Lookup("format") == nil { cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv") + cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp + }) } if s.Risk == "high-risk-write" { cmd.Flags().Bool("yes", false, "confirm high-risk operation") } cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output") cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes) - if s.HasFormat { - cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp - }) - } } // TypedArgs returns the per-run Args struct populated by the binder. Returns diff --git a/shortcuts/common/runner_format_universal_test.go b/shortcuts/common/runner_format_universal_test.go new file mode 100644 index 000000000..777cd2e47 --- /dev/null +++ b/shortcuts/common/runner_format_universal_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +// TestShortcutMount_FormatFlagAlwaysRegistered verifies that --format is +// injected for every shortcut regardless of the HasFormat field value. +func TestShortcutMount_FormatFlagAlwaysRegistered(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + parent := &cobra.Command{Use: "root"} + shortcut := Shortcut{ + Service: "im", + Command: "+message-send", + Description: "send message", + HasFormat: false, // explicitly false — format must still be registered + Execute: func(context.Context, *RuntimeContext) error { return nil }, + } + shortcut.Mount(parent, f) + + cmd, _, err := parent.Find([]string{"+message-send"}) + if err != nil { + t.Fatalf("Find() error = %v", err) + } + flag := cmd.Flags().Lookup("format") + if flag == nil { + t.Fatal("--format flag not registered; expected it to be injected even when HasFormat is false") + } + if flag.DefValue != "json" { + t.Errorf("--format default = %q, want %q", flag.DefValue, "json") + } +} diff --git a/shortcuts/common/runner_lang_test.go b/shortcuts/common/runner_lang_test.go new file mode 100644 index 000000000..9efd0eb6b --- /dev/null +++ b/shortcuts/common/runner_lang_test.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "testing" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/i18n" +) + +func TestRuntimeContext_Lang(t *testing.T) { + tests := []struct { + name string + stored i18n.Lang + want i18n.Lang + }{ + {"canonical locale", i18n.LangJaJP, i18n.LangJaJP}, + {"legacy short value normalizes", "ja", i18n.LangJaJP}, + {"legacy short zh normalizes", "zh", i18n.LangZhCN}, + {"unset stays empty", "", ""}, + {"unrecognized stays empty", "klingon", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &RuntimeContext{Config: &core.CliConfig{Lang: tt.stored}} + if got := ctx.Lang(); got != tt.want { + t.Errorf("Lang() with stored %q = %q, want %q", tt.stored, got, tt.want) + } + }) + } +} diff --git a/shortcuts/common/runner_partial_failure_test.go b/shortcuts/common/runner_partial_failure_test.go new file mode 100644 index 000000000..1be71c255 --- /dev/null +++ b/shortcuts/common/runner_partial_failure_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// TestOutPartialFailure pins the batch / multi-status contract: the result +// rides on stdout as an ok:false envelope (carrying the full payload), and the +// returned error is the typed partial-failure exit signal (ExitAPI), distinct +// from the predicate-only ErrBare. +func TestOutPartialFailure(t *testing.T) { + cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"} + f, stdout, _, _ := cmdutil.TestFactory(t, cfg) + rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+push"}, cfg, f, core.AsUser) + + payload := map[string]interface{}{ + "summary": map[string]interface{}{"uploaded": 1, "failed": 1}, + "items": []map[string]interface{}{ + {"rel_path": "a.txt", "action": "uploaded"}, + {"rel_path": "b.txt", "action": "failed", "error": "boom"}, + }, + } + + err := rt.OutPartialFailure(payload, nil) + + // 1) typed partial-failure exit signal + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) + } + if pfErr.Code != output.ExitAPI { + t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI) + } + + // 2) stdout envelope reports ok:false but still carries the full payload + // (both the succeeded and failed items) — consistent with a success Out(). + var env struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String()) + } + if env.OK { + t.Errorf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String()) + } + items, _ := env.Data["items"].([]interface{}) + if len(items) != 2 { + t.Fatalf("both succeeded and failed items must ride on stdout, got %d items\nstdout: %s", len(items), stdout.String()) + } +} diff --git a/shortcuts/common/runner_scope_test.go b/shortcuts/common/runner_scope_test.go index 9d8620b0b..9b1602d92 100644 --- a/shortcuts/common/runner_scope_test.go +++ b/shortcuts/common/runner_scope_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" @@ -44,51 +45,57 @@ func TestEnhancePermissionError_MissingScopeType(t *testing.T) { } } -func TestEnhancePermissionError_KeywordPermission(t *testing.T) { +// TestEnhancePermissionError_TypedPermissionErrorRouted pins typed routing: +// an *errs.PermissionError gets enhanced regardless of its Message text, +// decoupling this helper from canonical-message rewrites that would +// previously break the legacy keyword scan. +func TestEnhancePermissionError_TypedPermissionErrorRouted(t *testing.T) { scopes := []string{"drive:drive:read"} - err := &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{Type: "api_error", Message: "Permission denied for resource"}, + err := &errs.PermissionError{ + Problem: errs.Problem{ + Category: errs.CategoryAuthorization, + Subtype: errs.SubtypeMissingScope, + Message: "access denied: app cli_x has not applied for the required scope(s)", + }, } got := enhancePermissionError(err, scopes) - var exitErr *output.ExitError - if !errors.As(got, &exitErr) { - t.Fatalf("expected ExitError, got %T", got) + var permErr *errs.PermissionError + if !errors.As(got, &permErr) { + t.Fatalf("expected *PermissionError, got %T", got) } - if !strings.Contains(exitErr.Detail.Hint, "drive:drive:read") { - t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) + if !strings.Contains(permErr.Hint, "drive:drive:read") { + t.Errorf("hint %q missing scope info", permErr.Hint) } } -func TestEnhancePermissionError_KeywordScope(t *testing.T) { - scopes := []string{"task:task:read"} - err := &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{Type: "api_error", Message: "Insufficient scope for operation"}, - } - got := enhancePermissionError(err, scopes) - var exitErr *output.ExitError - if !errors.As(got, &exitErr) { - t.Fatalf("expected ExitError, got %T", got) - } - if !strings.Contains(exitErr.Detail.Hint, "task:task:read") { - t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) - } -} - -func TestEnhancePermissionError_KeywordAuthorization(t *testing.T) { +// TestEnhancePermissionError_KeywordScanRemoved pins that an *output.ExitError +// whose Detail.Type is NOT "permission" / "missing_scope" is no longer +// matched by upstream-message keyword scan. This is the contract change in +// T15: typed routing replaces the brittle keyword scan, so canonical +// message rewrites cannot accidentally flip an unrelated api_error into +// the permission-enhancement path. +func TestEnhancePermissionError_KeywordScanRemoved(t *testing.T) { scopes := []string{"contact:contact:read"} - err := &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{Type: "api_error", Message: "Authorization required"}, + cases := []struct { + name string + msg string + }{ + {"permission keyword", "Permission denied for resource"}, + {"scope keyword", "Insufficient scope for operation"}, + {"authorization keyword", "Authorization required"}, + {"unauthorized keyword", "request unauthorized by server"}, } - got := enhancePermissionError(err, scopes) - var exitErr *output.ExitError - if !errors.As(got, &exitErr) { - t.Fatalf("expected ExitError, got %T", got) - } - if !strings.Contains(exitErr.Detail.Hint, "contact:contact:read") { - t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "api_error", Message: tc.msg}, + } + got := enhancePermissionError(err, scopes) + if got != err { + t.Errorf("expected original error returned (type=api_error must not match), got %T: %v", got, got) + } + }) } } @@ -111,13 +118,12 @@ func TestEnhancePermissionError(t *testing.T) { hintSubstr: "scope", }, { - name: "mcp_error with unauthorized keyword gets enhanced", + name: "mcp_error with unauthorized keyword not enhanced (keyword scan removed)", err: &output.ExitError{ Code: 1, Detail: &output.ErrDetail{Type: "mcp_error", Message: "request unauthorized by server"}, }, - wantHint: true, - hintSubstr: "scope", + wantHint: false, }, { name: "api_error without keyword not modified", @@ -189,6 +195,57 @@ func TestCheckShortcutScopes_PropagatesContextCancellation(t *testing.T) { } } +// TestCheckShortcutScopes_ReturnsTypedPermissionError pins that the local +// precheck — when it finds the issued token is missing required scopes — +// emits a typed *errs.PermissionError with Subtype MissingScope, the resolved +// Identity, and the deterministic MissingScopes set. AI/script consumers +// downstream rely on these structured fields instead of parsing the hint +// string. The Hint still carries the actionable `auth login --scope ...` +// command for human consumers. +func TestCheckShortcutScopes_ReturnsTypedPermissionError(t *testing.T) { + f := &cmdutil.Factory{ + Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{ + result: &credential.TokenResult{Token: "t", Scopes: "im:message:read calendar:calendar:read"}, + }, nil), + } + + required := []string{"im:message:read", "drive:drive:read", "docx:document:read"} + err := checkShortcutScopes(f, context.Background(), core.AsUser, &core.CliConfig{AppID: "app-1"}, required) + if err == nil { + t.Fatal("expected error when token is missing required scopes, got nil") + } + + var permErr *errs.PermissionError + if !errors.As(err, &permErr) { + t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err) + } + if permErr.Category != errs.CategoryAuthorization { + t.Errorf("Category = %q, want %q", permErr.Category, errs.CategoryAuthorization) + } + if permErr.Subtype != errs.SubtypeMissingScope { + t.Errorf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope) + } + if permErr.Identity != string(core.AsUser) { + t.Errorf("Identity = %q, want %q", permErr.Identity, string(core.AsUser)) + } + wantMissing := map[string]bool{"drive:drive:read": true, "docx:document:read": true} + for _, m := range permErr.MissingScopes { + if !wantMissing[m] { + t.Errorf("unexpected MissingScopes entry %q (granted scopes should not appear)", m) + } + delete(wantMissing, m) + } + if len(wantMissing) != 0 { + t.Errorf("MissingScopes %v did not include expected entries %v", permErr.MissingScopes, wantMissing) + } + if permErr.Hint == "" { + t.Error("Hint must carry the `auth login --scope ...` recovery action") + } + if !strings.Contains(permErr.Hint, "auth login") { + t.Errorf("Hint = %q, want it to mention `auth login`", permErr.Hint) + } +} + func TestCheckShortcutScopes_IgnoresNonContextTokenErrors(t *testing.T) { f := &cmdutil.Factory{ Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{err: errors.New("token cache unavailable")}, nil), diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 45886a576..0180b9541 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -49,7 +49,7 @@ type Shortcut struct { // Declarative fields (new framework). AuthTypes []string // supported identities: "user", "bot" (default: ["user"]) Flags []Flag // flag definitions; --dry-run is auto-injected - HasFormat bool // auto-inject --format flag (json|pretty|table|ndjson|csv) + HasFormat bool // Deprecated: --format is now always injected; this field has no effect. Tips []string // optional tips shown in --help output Hidden bool // hide from --help / tab completion (still executable); use when deprecating a command in favor of a replacement diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go index f5a0df2c1..52885faed 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -16,7 +16,7 @@ import ( // v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path. func v2FetchFlags() []common.Flag { return []common.Flag{ - {Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown", "text"}}, + {Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}}, {Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}}, {Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"}, {Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}}, @@ -142,7 +142,7 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} { return ro } -// validateFetchDetail 非 xml 格式(markdown/text)不承载 block_id 与样式属性,拒绝 with-ids/full。 +// validateFetchDetail 非 xml 格式(markdown)不承载 block_id 与样式属性,拒绝 with-ids/full。 func validateFetchDetail(runtime *common.RuntimeContext) error { format := strings.TrimSpace(runtime.Str("doc-format")) detail := strings.TrimSpace(runtime.Str("detail")) diff --git a/shortcuts/drive/drive_add_comment.go b/shortcuts/drive/drive_add_comment.go index b40ca959d..337ed27b3 100644 --- a/shortcuts/drive/drive_add_comment.go +++ b/shortcuts/drive/drive_add_comment.go @@ -11,7 +11,7 @@ import ( "strings" "unicode/utf8" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -152,13 +152,13 @@ var DriveAddComment = common.Shortcut{ if docRef.Kind == "sheet" { blockID := strings.TrimSpace(runtime.Str("block-id")) if blockID == "" { - return output.ErrValidation("--block-id is required for sheet comments (format: !, e.g. a281f9!D6)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for sheet comments (format: !, e.g. a281f9!D6)").WithParam("--block-id") } if _, err := parseSheetCellRef(blockID); err != nil { return err } if runtime.Bool("full-comment") || strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" { - return output.ErrValidation("--full-comment and --selection-with-ellipsis are not applicable for sheet comments; use --block-id with ! format") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment and --selection-with-ellipsis are not applicable for sheet comments; use --block-id with ! format") } return nil } @@ -167,20 +167,20 @@ var DriveAddComment = common.Shortcut{ return err } if runtime.Bool("full-comment") { - return output.ErrValidation("--full-comment is not applicable for slide comments; use --block-id !") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment is not applicable for slide comments; use --block-id !") } if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" { - return output.ErrValidation("--selection-with-ellipsis is not applicable for slide comments; use --block-id !") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis is not applicable for slide comments; use --block-id !") } return nil } selection := runtime.Str("selection-with-ellipsis") blockID := strings.TrimSpace(runtime.Str("block-id")) if strings.TrimSpace(selection) != "" && blockID != "" { - return output.ErrValidation("--selection-with-ellipsis and --block-id are mutually exclusive") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis and --block-id are mutually exclusive") } if runtime.Bool("full-comment") && (strings.TrimSpace(selection) != "" || blockID != "") { - return output.ErrValidation("--full-comment cannot be used with --selection-with-ellipsis or --block-id") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment cannot be used with --selection-with-ellipsis or --block-id") } mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID) @@ -188,7 +188,7 @@ var DriveAddComment = common.Shortcut{ return validateFileCommentMode(mode, "") } if mode == commentModeLocal && docRef.Kind == "doc" { - return output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments") } return nil @@ -398,7 +398,7 @@ var DriveAddComment = common.Shortcut{ } blockID = match.AnchorBlockID if strings.TrimSpace(blockID) == "" { - return output.Errorf(output.ExitAPI, "api_error", "locate-doc response missing anchor_block_id") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "locate-doc response missing anchor_block_id") } selectedMatch = idx fmt.Fprintf(runtime.IO().ErrOut, "Locate-doc matched %d block(s); using match #%d (%s)\n", len(locateResult.Matches), idx, blockID) @@ -418,7 +418,7 @@ var DriveAddComment = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Creating full comment in %s\n", common.MaskToken(target.FileToken)) } - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "POST", requestPath, nil, @@ -473,7 +473,7 @@ func resolveCommentMode(explicitFullComment bool, selection, blockID string) com func parseCommentDocRef(input, docType string) (commentDocRef, error) { raw := strings.TrimSpace(input) if raw == "" { - return commentDocRef{}, output.ErrValidation("--doc cannot be empty") + return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc cannot be empty").WithParam("--doc") } if token, ok := extractURLToken(raw, "/wiki/"); ok { @@ -495,16 +495,16 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) { return commentDocRef{Kind: "doc", Token: token}, nil } if strings.Contains(raw, "://") { - return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw) + return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw).WithParam("--doc") } if strings.ContainsAny(raw, "/?#") { - return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw) + return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a token with --type, or a wiki URL", raw).WithParam("--doc") } // Bare token: --type is required. docType = strings.TrimSpace(docType) if docType == "" { - return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)") + return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)").WithParam("--type") } return commentDocRef{Kind: docType, Token: raw}, nil } @@ -519,7 +519,7 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i if mode == commentModeLocal { switch docRef.Kind { case "doc": - return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments") + return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments") case "file": if err := validateFileCommentMode(mode, ""); err != nil { return resolvedCommentTarget{}, err @@ -535,7 +535,7 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i } fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token)) - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": docRef.Token}, @@ -549,13 +549,13 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i objType := common.GetString(node, "obj_type") objToken := common.GetString(node, "obj_token") if objType == "" || objToken == "" { - return resolvedCommentTarget{}, output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data") + return resolvedCommentTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data") } if objType == "slides" && mode == commentModeFull { - return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but slide comments require --block-id !; --full-comment is not applicable", objType) + return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but slide comments require --block-id !; --full-comment is not applicable", objType) } if objType == "slides" && strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" { - return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id !", objType) + return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id !", objType) } if objType == "sheet" { // Sheet comments are handled via the sheet fast path in Execute. @@ -592,10 +592,10 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i }, nil } if mode == commentModeLocal && objType != "docx" { - return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id !, for slides use --block-id !", objType) + return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id !, for slides use --block-id !", objType) } if mode == commentModeFull && objType != "docx" && objType != "doc" { - return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType) + return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType) } fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken)) @@ -663,16 +663,14 @@ func parseLocateDocResult(result map[string]interface{}) locateDocResult { func selectLocateMatch(result locateDocResult) (locateDocMatch, int, error) { if len(result.Matches) == 0 { - return locateDocMatch{}, 0, output.ErrValidation("locate-doc did not find any matching block") + return locateDocMatch{}, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "locate-doc did not find any matching block").WithParam("--selection-with-ellipsis") } if len(result.Matches) > 1 { - return locateDocMatch{}, 0, output.ErrWithHint( - output.ExitValidation, - "ambiguous_match", - fmt.Sprintf("locate-doc matched %d blocks:\n%s", len(result.Matches), formatLocateCandidates(result.Matches)), - "narrow --selection-with-ellipsis until only one block matches", - ) + return locateDocMatch{}, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, + "locate-doc matched %d blocks:\n%s", len(result.Matches), formatLocateCandidates(result.Matches)). + WithHint("narrow --selection-with-ellipsis until only one block matches"). + WithParam("--selection-with-ellipsis") } return result.Matches[0], 1, nil @@ -705,15 +703,15 @@ func summarizeLocateMatch(match locateDocMatch) string { func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) { if strings.TrimSpace(raw) == "" { - return nil, output.ErrValidation("--content cannot be empty") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content cannot be empty").WithParam("--content") } var inputs []commentReplyElementInput if err := json.Unmarshal([]byte(raw), &inputs); err != nil { - return nil, output.ErrValidation("--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err).WithParam("--content") } if len(inputs) == 0 { - return nil, output.ErrValidation("--content must contain at least one reply element") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must contain at least one reply element").WithParam("--content") } replyElements := make([]map[string]interface{}, 0, len(inputs)) @@ -724,7 +722,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) { switch elementType { case "text": if strings.TrimSpace(input.Text) == "" { - return nil, output.ErrValidation("--content element #%d type=text requires non-empty text", index) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=text requires non-empty text", index).WithParam("--content") } // Measure the raw rune count of the user input — that is what // the server actually counts. byte width and post-escape form @@ -734,13 +732,11 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) { runes := utf8.RuneCountInString(input.Text) totalRunes += runes if totalRunes > maxCommentTotalRunes { - return nil, output.ErrWithHint( - output.ExitValidation, - "text_too_long", - fmt.Sprintf("--content reply_elements text totals %d characters at element #%d (this element: %d); the server caps the combined length at %d characters across ALL reply_elements", - totalRunes, index, runes, maxCommentTotalRunes), - fmt.Sprintf("shorten the comment so the combined text across all reply_elements fits within %d characters. The server enforces this cap on the TOTAL — splitting one long element into multiple smaller text elements does NOT help (they all add up against the same %d-rune budget). Server returns an opaque [1069302] on overflow, so this check is pre-flight; no escape transform changes the count (server reads raw runes).", maxCommentTotalRunes, maxCommentTotalRunes), - ) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--content reply_elements text totals %d characters at element #%d (this element: %d); the server caps the combined length at %d characters across ALL reply_elements", + totalRunes, index, runes, maxCommentTotalRunes). + WithHint("shorten the comment so the combined text across all reply_elements fits within %d characters. The server enforces this cap on the TOTAL — splitting one long element into multiple smaller text elements does NOT help (they all add up against the same %d-rune budget). Server returns an opaque [1069302] on overflow, so this check is pre-flight; no escape transform changes the count (server reads raw runes).", maxCommentTotalRunes, maxCommentTotalRunes). + WithParam("--content") } // Escape '<' and '>' so the rendered comment displays them as // literal characters instead of being interpreted as markup @@ -754,7 +750,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) { case "mention_user": mentionUser := firstNonEmptyString(input.MentionUser, input.Text) if mentionUser == "" { - return nil, output.ErrValidation("--content element #%d type=mention_user requires text or mention_user", index) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=mention_user requires text or mention_user", index).WithParam("--content") } replyElements = append(replyElements, map[string]interface{}{ "type": "mention_user", @@ -763,14 +759,14 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) { case "link": link := firstNonEmptyString(input.Link, input.Text) if link == "" { - return nil, output.ErrValidation("--content element #%d type=link requires text or link", index) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=link requires text or link", index).WithParam("--content") } replyElements = append(replyElements, map[string]interface{}{ "type": "link", "link": link, }) default: - return nil, output.ErrValidation("--content element #%d has unsupported type %q; allowed values: text, mention_user, link", index, input.Type) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d has unsupported type %q; allowed values: text, mention_user, link", index, input.Type).WithParam("--content") } } @@ -827,17 +823,17 @@ func anchorBlockIDForDryRun(blockID string) string { func parseSlidesBlockRef(blockID string) (string, string, error) { blockID = strings.TrimSpace(blockID) if blockID == "" { - return "", "", output.ErrValidation("slide comments require --block-id in ! format") + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "slide comments require --block-id in ! format").WithParam("--block-id") } parts := strings.SplitN(blockID, "!", 2) if len(parts) != 2 { - return "", "", output.ErrValidation("slide --block-id must be ! (e.g. shape!bPq), got %q", blockID) + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "slide --block-id must be ! (e.g. shape!bPq), got %q", blockID).WithParam("--block-id") } parsedType := strings.TrimSpace(parts[0]) parsedID := strings.TrimSpace(parts[1]) if parsedType == "" || parsedID == "" { - return "", "", output.ErrValidation("slide --block-id must be ! (e.g. shape!bPq), got %q", blockID) + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "slide --block-id must be ! (e.g. shape!bPq), got %q", blockID).WithParam("--block-id") } return parsedID, parsedType, nil } @@ -865,7 +861,7 @@ func firstPresentValue(m map[string]interface{}, keys ...string) interface{} { func parseSheetCellRef(input string) (*sheetAnchor, error) { parts := strings.SplitN(input, "!", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return nil, output.ErrValidation("--block-id for sheet must be ! (e.g. a281f9!D6), got %q", input) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id for sheet must be ! (e.g. a281f9!D6), got %q", input).WithParam("--block-id") } sheetID := parts[0] cell := strings.TrimSpace(parts[1]) @@ -876,7 +872,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) { i++ } if i == 0 || i >= len(cell) { - return nil, output.ErrValidation("--block-id cell reference %q is invalid (expected e.g. D6)", cell) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id cell reference %q is invalid (expected e.g. D6)", cell).WithParam("--block-id") } colStr := strings.ToUpper(cell[:i]) rowStr := cell[i:] @@ -890,7 +886,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) { row, err := strconv.Atoi(rowStr) if err != nil || row < 1 { - return nil, output.ErrValidation("--block-id row %q is invalid (must be >= 1)", rowStr) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id row %q is invalid (must be >= 1)", rowStr).WithParam("--block-id") } row-- // convert to 0-based @@ -898,7 +894,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) { } func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken string) (string, error) { - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "POST", "/open-apis/drive/v1/metas/batch_query", nil, @@ -917,11 +913,11 @@ func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken strin metas := common.GetSlice(data, "metas") if len(metas) == 0 { - return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken)) + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken)) } meta, ok := metas[0].(map[string]interface{}) if !ok { - return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken)) + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken)) } return common.GetString(meta, "title"), nil } @@ -936,23 +932,19 @@ func ensureSupportedFileCommentTarget(runtime *common.RuntimeContext, fileToken return title, extension, nil } if strings.TrimSpace(title) == "" { - return "", "", output.ErrWithHint( - output.ExitValidation, - "unsupported_file_comment_type", - "drive +add-comment does not support comments for this Drive file type yet; the file metadata did not return a title", - "file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(), - ) + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "drive +add-comment does not support comments for this Drive file type yet; the file metadata did not return a title"). + WithHint("file comments currently support full comments only for these extensions: " + supportedFileCommentExtensionsText()). + WithParam("--doc") } extensionLabel := extension if extensionLabel == "" { extensionLabel = "no extension" } - return "", "", output.ErrWithHint( - output.ExitValidation, - "unsupported_file_comment_type", - fmt.Sprintf("drive +add-comment does not support comments for this Drive file type yet; got %q (%s)", title, extensionLabel), - "file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(), - ) + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "drive +add-comment does not support comments for this Drive file type yet; got %q (%s)", title, extensionLabel). + WithHint("file comments currently support full comments only for these extensions: " + supportedFileCommentExtensionsText()). + WithParam("--doc") } func fileCommentExtension(title string) string { @@ -993,9 +985,9 @@ func validateFileCommentMode(mode commentMode, resolvedObjType string) error { return nil } if resolvedObjType != "" { - return output.ErrValidation("wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType) } - return output.ErrValidation("file comments only support full comments; omit --block-id and --selection-with-ellipsis") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file comments only support full comments; omit --block-id and --selection-with-ellipsis") } func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error { @@ -1006,7 +998,7 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e blockID := strings.TrimSpace(runtime.Str("block-id")) if blockID == "" { - return output.ErrValidation("--block-id is required for sheet comments (format: !, e.g. a281f9!D6)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for sheet comments (format: !, e.g. a281f9!D6)").WithParam("--block-id") } anchor, err := parseSheetCellRef(blockID) if err != nil { @@ -1019,7 +1011,7 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e fmt.Fprintf(runtime.IO().ErrOut, "Creating sheet comment in %s (sheet=%s, col=%d, row=%d)\n", common.MaskToken(docRef.Token), anchor.SheetID, anchor.Col, anchor.Row) - data, err := runtime.CallAPI("POST", requestPath, nil, requestBody) + data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody) if err != nil { return err } @@ -1054,7 +1046,7 @@ func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTa fmt.Fprintf(runtime.IO().ErrOut, "Creating file comment in %s (%s)\n", common.MaskToken(target.FileToken), extension) - data, err := runtime.CallAPI("POST", requestPath, nil, requestBody) + data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody) if err != nil { return err } @@ -1097,7 +1089,7 @@ func executeSlidesComment(runtime *common.RuntimeContext, docRef commentDocRef) fmt.Fprintf(runtime.IO().ErrOut, "Creating slide block comment in %s (block_id=%s, slide_block_type=%s)\n", common.MaskToken(docRef.Token), blockID, slideBlockType) - data, err := runtime.CallAPI("POST", requestPath, nil, requestBody) + data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody) if err != nil { return err } diff --git a/shortcuts/drive/drive_add_comment_test.go b/shortcuts/drive/drive_add_comment_test.go index 7875893eb..5d39d5d34 100644 --- a/shortcuts/drive/drive_add_comment_test.go +++ b/shortcuts/drive/drive_add_comment_test.go @@ -9,11 +9,32 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/output" ) +// assertContentValidationHint asserts err is a typed *errs.ValidationError +// carrying SubtypeInvalidArgument, Param "--content", and a Hint containing +// the given substring. The over-cap message now flows through a typed +// ValidationError instead of the legacy *output.ExitError.Detail shape. +func assertContentValidationHint(t *testing.T, err error, wantHint string) { + t.Helper() + var valErr *errs.ValidationError + if !errors.As(err, &valErr) { + t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err) + } + if valErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument) + } + if valErr.Param != "--content" { + t.Errorf("Param = %q, want %q", valErr.Param, "--content") + } + if !strings.Contains(valErr.Hint, wantHint) { + t.Errorf("expected hint substring %q, got %q", wantHint, valErr.Hint) + } +} + func decodeJSONMap(t *testing.T, raw string) map[string]interface{} { t.Helper() @@ -421,14 +442,8 @@ func TestParseCommentReplyElementsTextLength(t *testing.T) { t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) } if tt.wantHint != "" { - // Hint lives on ExitError.Detail.Hint, not err.Error(). - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err) - } - if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) { - t.Errorf("expected hint substring %q, got %q", tt.wantHint, exitErr.Detail.Hint) - } + // Hint lives on the typed ValidationError, not err.Error(). + assertContentValidationHint(t, err, tt.wantHint) } return } @@ -458,11 +473,11 @@ func TestParseCommentReplyElementsHintForbidsSplitAdvice(t *testing.T) { if err == nil { t.Fatal("expected over-cap error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err) + var valErr *errs.ValidationError + if !errors.As(err, &valErr) { + t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err) } - hint := exitErr.Detail.Hint + hint := valErr.Hint // The hint must explicitly call out that splitting does NOT help. if !strings.Contains(hint, "does NOT help") { diff --git a/shortcuts/drive/drive_apply_permission.go b/shortcuts/drive/drive_apply_permission.go index dff8a8474..2fd76b37b 100644 --- a/shortcuts/drive/drive_apply_permission.go +++ b/shortcuts/drive/drive_apply_permission.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -44,7 +44,7 @@ var permApplyURLMarkers = []struct { func resolvePermApplyTarget(raw, explicitType string) (token, docType string, err error) { raw = strings.TrimSpace(raw) if raw == "" { - return "", "", output.ErrValidation("--token is required") + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token") } if strings.Contains(raw, "://") { @@ -58,10 +58,10 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er } } if token == "" { - return "", "", output.ErrValidation( + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual", raw, - ) + ).WithParam("--token") } } else { token = raw @@ -71,10 +71,10 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er docType = explicitType } if docType == "" { - return "", "", output.ErrValidation( + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --token is a bare token; accepted values: %s", strings.Join(permApplyTypes, ", "), - ) + ).WithParam("--type") } return token, docType, nil } @@ -125,7 +125,7 @@ var DriveApplyPermission = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Requesting %s access on %s %s...\n", runtime.Str("perm"), docType, common.MaskToken(token)) - data, err := runtime.CallAPI("POST", + data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/apply", validate.EncodePathSegment(token)), map[string]interface{}{"type": docType}, body, diff --git a/shortcuts/drive/drive_create_folder.go b/shortcuts/drive/drive_create_folder.go index 75c50aee6..38507d491 100644 --- a/shortcuts/drive/drive_create_folder.go +++ b/shortcuts/drive/drive_create_folder.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -72,7 +72,7 @@ var DriveCreateFolder = common.Shortcut{ } fmt.Fprintf(runtime.IO().ErrOut, "Creating folder %q in %s...\n", spec.Name, target) - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "POST", "/open-apis/drive/v1/files/create_folder", nil, @@ -84,7 +84,7 @@ var DriveCreateFolder = common.Shortcut{ folderToken := common.GetString(data, "token") if folderToken == "" { - return output.Errorf(output.ExitAPI, "api_error", "drive create_folder succeeded but returned no folder token (data.token)") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "drive create_folder succeeded but returned no folder token (data.token)") } out := map[string]interface{}{ "created": true, @@ -108,14 +108,14 @@ var DriveCreateFolder = common.Shortcut{ func validateDriveCreateFolderSpec(spec driveCreateFolderSpec) error { if spec.Name == "" { - return output.ErrValidation("--name must not be empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name must not be empty").WithParam("--name") } if nameBytes := len([]byte(spec.Name)); nameBytes > 256 { - return output.ErrValidation("--name exceeds the maximum of 256 bytes (got %d)", nameBytes) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 256 bytes (got %d)", nameBytes).WithParam("--name") } if spec.FolderToken != "" { if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } } return nil diff --git a/shortcuts/drive/drive_create_shortcut.go b/shortcuts/drive/drive_create_shortcut.go index 92d0c5dc5..b79499631 100644 --- a/shortcuts/drive/drive_create_shortcut.go +++ b/shortcuts/drive/drive_create_shortcut.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -84,7 +84,7 @@ var DriveCreateShortcut = common.Shortcut{ common.MaskToken(spec.FolderToken), ) - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "POST", "/open-apis/drive/v1/files/create_shortcut", nil, @@ -118,19 +118,19 @@ var DriveCreateShortcut = common.Shortcut{ // validateDriveCreateShortcutSpec validates shortcut creation inputs before API execution. func validateDriveCreateShortcutSpec(spec driveCreateShortcutSpec) error { if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } if spec.FileType == "wiki" { - return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first").WithParam("--type") } if spec.FileType == "folder" { - return output.ErrValidation("unsupported file type: folder. The create_shortcut API only supports Drive files, not folders") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: folder. The create_shortcut API only supports Drive files, not folders").WithParam("--type") } if !driveCreateShortcutAllowedTypes[spec.FileType] { - return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType).WithParam("--type") } return nil } diff --git a/shortcuts/drive/drive_create_shortcut_test.go b/shortcuts/drive/drive_create_shortcut_test.go index 942e079d8..14d83048d 100644 --- a/shortcuts/drive/drive_create_shortcut_test.go +++ b/shortcuts/drive/drive_create_shortcut_test.go @@ -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/httpmock" "github.com/larksuite/cli/internal/output" @@ -312,24 +313,24 @@ func TestDriveCreateShortcutClassifiesKnownAPIConstraints(t *testing.T) { t.Fatal("expected API error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured exit error, got %v", err) + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *errs.APIError, got %T (%v)", err, err) } - if exitErr.Code != output.ExitAPI { - t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI) + if output.ExitCodeOf(err) != output.ExitAPI { + t.Fatalf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitAPI) } - if exitErr.Detail.Type != tt.wantType { - t.Fatalf("type = %q, want %q", exitErr.Detail.Type, tt.wantType) + if string(apiErr.Subtype) != tt.wantType { + t.Fatalf("subtype = %q, want %q", apiErr.Subtype, tt.wantType) } - if exitErr.Detail.Code != tt.code { - t.Fatalf("detail code = %d, want %d", exitErr.Detail.Code, tt.code) + if apiErr.Code != tt.code { + t.Fatalf("code = %d, want %d", apiErr.Code, tt.code) } - if !strings.Contains(exitErr.Detail.Message, tt.wantMsgPart) { - t.Fatalf("message = %q, want substring %q", exitErr.Detail.Message, tt.wantMsgPart) + if !strings.Contains(apiErr.Message, tt.wantMsgPart) { + t.Fatalf("message = %q, want substring %q", apiErr.Message, tt.wantMsgPart) } - if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) { - t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint) + if !strings.Contains(apiErr.Hint, tt.wantHint) { + t.Fatalf("hint = %q, want substring %q", apiErr.Hint, tt.wantHint) } }) } diff --git a/shortcuts/drive/drive_delete.go b/shortcuts/drive/drive_delete.go index 98a331e6e..d9190a793 100644 --- a/shortcuts/drive/drive_delete.go +++ b/shortcuts/drive/drive_delete.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -81,7 +81,7 @@ var DriveDelete = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Deleting %s %s...\n", spec.FileType, common.MaskToken(spec.FileToken)) - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "DELETE", fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)), map[string]interface{}{"type": spec.FileType}, @@ -94,7 +94,7 @@ var DriveDelete = common.Shortcut{ if spec.FileType == "folder" { taskID := common.GetString(data, "task_id") if taskID == "" { - return output.Errorf(output.ExitAPI, "api_error", "delete folder returned no task_id") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "delete folder returned no task_id") } fmt.Fprintf(runtime.IO().ErrOut, "Folder delete is async, polling task %s...\n", taskID) @@ -136,13 +136,13 @@ var DriveDelete = common.Shortcut{ func validateDriveDeleteSpec(spec driveDeleteSpec) error { if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } if spec.FileType == "wiki" { - return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported").WithParam("--type") } if !driveDeleteAllowedTypes[spec.FileType] { - return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides", spec.FileType) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides", spec.FileType).WithParam("--type") } return nil } diff --git a/shortcuts/drive/drive_download.go b/shortcuts/drive/drive_download.go index 13d96d619..2a29e16a3 100644 --- a/shortcuts/drive/drive_download.go +++ b/shortcuts/drive/drive_download.go @@ -10,8 +10,8 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -44,7 +44,7 @@ var DriveDownload = common.Shortcut{ overwrite := runtime.Bool("overwrite") if err := validate.ResourceName(fileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } if outputPath == "" { @@ -53,10 +53,10 @@ var DriveDownload = common.Shortcut{ // Early path validation + overwrite check if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil { - return output.ErrValidation("unsafe output path: %s", resolveErr) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output") } if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite { - return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output") } fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken)) @@ -66,7 +66,7 @@ var DriveDownload = common.Shortcut{ ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)), }) if err != nil { - return output.ErrNetwork("download failed: %s", err) + return wrapDriveNetworkErr(err, "download failed: %s", err) } defer resp.Body.Close() @@ -75,7 +75,7 @@ var DriveDownload = common.Shortcut{ ContentLength: resp.ContentLength, }, resp.Body) if err != nil { - return common.WrapSaveErrorByCategory(err, "io") + return driveSaveError(err) } savedPath, _ := runtime.ResolveSavePath(outputPath) diff --git a/shortcuts/drive/drive_duplicate_remote_test.go b/shortcuts/drive/drive_duplicate_remote_test.go index 6c8a391f6..574c1c1bb 100644 --- a/shortcuts/drive/drive_duplicate_remote_test.go +++ b/shortcuts/drive/drive_duplicate_remote_test.go @@ -17,9 +17,9 @@ import ( "testing" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/output" ) const ( @@ -823,64 +823,37 @@ func registerDownload(reg *httpmock.Registry, fileToken, body string) { func assertDuplicateRemotePathError(t *testing.T, err error, relPath string, tokens ...string) { t.Helper() if err == nil { - t.Fatal("expected duplicate_remote_path error, got nil") + t.Fatal("expected duplicate rel_path validation error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) } - if exitErr.Code != output.ExitAPI { - t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI) + if validationErr.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition) } - if exitErr.Detail == nil || exitErr.Detail.Type != "duplicate_remote_path" { - t.Fatalf("error detail = %#v, want duplicate_remote_path", exitErr.Detail) + if validationErr.Hint == "" { + t.Fatal("duplicate validation error should carry a recovery hint so AI consumers know the next action") } - detailMap, ok := exitErr.Detail.Detail.(map[string]interface{}) - if !ok { - t.Fatalf("duplicate detail type = %T, want map[string]interface{}", exitErr.Detail.Detail) + if len(validationErr.Params) == 0 { + t.Fatal("duplicate validation error should carry at least one param") } - duplicates, ok := detailMap["duplicates_remote"].([]driveDuplicateRemotePath) - if !ok { - t.Fatalf("duplicate detail duplicates_remote type = %T, want []driveDuplicateRemotePath", detailMap["duplicates_remote"]) - } - if len(duplicates) == 0 { - t.Fatal("duplicate detail should include at least one rel_path group") - } - if _, hasLegacyFilesKey := detailMap["files"]; hasLegacyFilesKey { - t.Fatalf("duplicate detail should not expose legacy files key: %#v", detailMap) - } - var matched bool - for _, duplicate := range duplicates { - if duplicate.RelPath != relPath { - continue - } - matched = true - if len(duplicate.Entries) != len(tokens) { - t.Fatalf("duplicate entry count = %d, want %d for rel_path %q", len(duplicate.Entries), len(tokens), relPath) - } - for i, token := range tokens { - if duplicate.Entries[i].FileToken != token { - t.Fatalf("duplicate entry %d file_token = %q, want %q", i, duplicate.Entries[i].FileToken, token) - } - if duplicate.Entries[i].Type == "" { - t.Fatalf("duplicate entry %d missing type for rel_path %q", i, relPath) - } + var matched *errs.InvalidParam + for i := range validationErr.Params { + if validationErr.Params[i].Name == relPath { + matched = &validationErr.Params[i] + break } } - if !matched { - t.Fatalf("duplicate detail missing rel_path group %q: %#v", relPath, duplicates) + if matched == nil { + t.Fatalf("duplicate params missing rel_path group %q: %#v", relPath, validationErr.Params) } - raw, marshalErr := json.Marshal(exitErr.Detail.Detail) - if marshalErr != nil { - t.Fatalf("marshal detail: %v", marshalErr) - } - text := string(raw) - if !strings.Contains(text, relPath) { - t.Fatalf("duplicate detail missing rel_path %q: %s", relPath, text) + if matched.Reason == "" { + t.Fatalf("duplicate param for rel_path %q missing reason", relPath) } for _, token := range tokens { - if !strings.Contains(text, token) { - t.Fatalf("duplicate detail missing token %q: %s", token, text) + if !strings.Contains(matched.Reason, token) { + t.Fatalf("duplicate param reason missing token %q: %s", token, matched.Reason) } } } diff --git a/shortcuts/drive/drive_errors.go b/shortcuts/drive/drive_errors.go new file mode 100644 index 000000000..4184d158b --- /dev/null +++ b/shortcuts/drive/drive_errors.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "errors" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" +) + +// wrapDriveNetworkErr returns err unchanged when it is already a typed errs.* +// error (preserving its subtype / code / log_id from the runtime boundary), +// and only wraps a raw, unclassified error as a transport-level network error. +func wrapDriveNetworkErr(err error, format string, args ...any) error { + if _, ok := errs.ProblemOf(err); ok { + return err + } + return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err) +} + +// driveInputStatError maps a FileIO.Stat/Open error for input file validation +// to a typed validation error: +// - Path validation failures → "unsafe file path: ..." +// - Other errors → "cannot read file: ..." +func driveInputStatError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, fileio.ErrPathValidation) { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err) + } + return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err) +} + +// driveSaveError maps a FileIO.Save error to a typed error. Path validation +// failures are validation errors (exit code 2); mkdir / write failures are +// internal file-I/O errors (exit code 5). +func driveSaveError(err error) error { + if err == nil { + return nil + } + var me *fileio.MkdirError + switch { + case errors.Is(err, fileio.ErrPathValidation): + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithCause(err) + case errors.As(err, &me): + return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err) + default: + return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err) + } +} + +// appendDriveExportRecoveryHint attaches a recovery hint to err while preserving +// its original classification (typed subtype/code or legacy detail), only falling +// back to a typed internal error when err is unclassified. +func appendDriveExportRecoveryHint(err error, hint string) error { + if err == nil { + return nil + } + // An already-typed error keeps its own category/subtype/code/log_id + // (per ERROR_CONTRACT.md "propagate typed errors unchanged"); we only + // append the recovery hint. p points at the embedded Problem, so the + // mutation is reflected in the returned err. + if p, ok := errs.ProblemOf(err); ok { + if strings.TrimSpace(p.Hint) != "" { + p.Hint = p.Hint + "\n" + hint + } else { + p.Hint = hint + } + return err + } + // Legacy *output.ExitError fallback: preserve the original error's + // class/exit code by appending the hint in place rather than downgrading + // to api/server_error. + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Detail != nil { + if strings.TrimSpace(exitErr.Detail.Hint) != "" { + exitErr.Detail.Hint = exitErr.Detail.Hint + "\n" + hint + } else { + exitErr.Detail.Hint = hint + } + return err + } + return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(hint).WithCause(err) +} diff --git a/shortcuts/drive/drive_export.go b/shortcuts/drive/drive_export.go index e0031fdb0..86e7c99a5 100644 --- a/shortcuts/drive/drive_export.go +++ b/shortcuts/drive/drive_export.go @@ -5,13 +5,12 @@ package drive import ( "context" - "errors" "fmt" "path/filepath" "strings" "time" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -107,7 +106,7 @@ var DriveExport = common.Shortcut{ if spec.FileExtension == "markdown" { fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token)) apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token)) - data, err := runtime.DoAPIJSONWithLogID( + data, err := runtime.CallAPITyped( "POST", apiPath, nil, @@ -122,11 +121,11 @@ var DriveExport = common.Shortcut{ // Extract content from the V2 response: data.document.content doc, ok := data["document"].(map[string]interface{}) if !ok { - return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document object") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object") } content, ok := doc["content"].(string) if !ok { - return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document.content") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content") } fileName := preferredFileName @@ -207,11 +206,7 @@ var DriveExport = common.Shortcut{ status.FileToken, recoveryCommand, ) - var exitErr *output.ExitError - if errors.As(err, &exitErr) && exitErr.Detail != nil { - return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint) - } - return output.ErrWithHint(output.ExitAPI, "api_error", err.Error(), hint) + return appendDriveExportRecoveryHint(err, hint) } out["ticket"] = ticket out["doc_type"] = spec.DocType @@ -225,7 +220,7 @@ var DriveExport = common.Shortcut{ if msg == "" { msg = status.StatusLabel() } - return output.Errorf(output.ExitAPI, "api_error", "export task failed: %s (ticket=%s)", msg, ticket) + return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket) } fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel()) @@ -238,14 +233,7 @@ var DriveExport = common.Shortcut{ ticket, nextCommand, ) - var exitErr *output.ExitError - if errors.As(lastPollErr, &exitErr) && exitErr.Detail != nil { - if strings.TrimSpace(exitErr.Detail.Hint) != "" { - hint = exitErr.Detail.Hint + "\n" + hint - } - return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint) - } - return output.ErrWithHint(output.ExitAPI, "api_error", lastPollErr.Error(), hint) + return appendDriveExportRecoveryHint(lastPollErr, hint) } failed := false diff --git a/shortcuts/drive/drive_export_common.go b/shortcuts/drive/drive_export_common.go index 529c01b0e..187ac3e73 100644 --- a/shortcuts/drive/drive_export_common.go +++ b/shortcuts/drive/drive_export_common.go @@ -15,9 +15,9 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/client" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -127,48 +127,48 @@ func (s driveExportStatus) StatusLabel() string { // backend request is sent. func validateDriveExportSpec(spec driveExportSpec) error { if err := validate.ResourceName(spec.Token, "--token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token") } switch spec.DocType { case "doc", "docx", "sheet", "bitable", "slides": default: - return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType).WithParam("--doc-type") } switch spec.FileExtension { case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx": default: - return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension).WithParam("--file-extension") } if spec.FileExtension == "markdown" && spec.DocType != "docx" { - return output.ErrValidation("--file-extension markdown only supports --doc-type docx") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension markdown only supports --doc-type docx") } if spec.FileExtension == "base" && spec.DocType != "bitable" { - return output.ErrValidation("--file-extension base only supports --doc-type bitable") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable") } if spec.FileExtension == "pptx" && spec.DocType != "slides" { - return output.ErrValidation("--file-extension pptx only supports --doc-type slides") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides") } if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" { - return output.ErrValidation("--doc-type slides only supports --file-extension pptx or pdf") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type slides only supports --file-extension pptx or pdf") } if strings.TrimSpace(spec.SubID) != "" { if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") { - return output.ErrValidation("--sub-id is only used when exporting sheet/bitable as csv") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").WithParam("--sub-id") } if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id") } } if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" { - return output.ErrValidation("--sub-id is required when exporting sheet/bitable as csv") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").WithParam("--sub-id") } return nil @@ -186,14 +186,14 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) body["sub_id"] = spec.SubID } - data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body) + data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body) if err != nil { return "", err } ticket := common.GetString(data, "ticket") if ticket == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "export task created but ticket is missing") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "export task created but ticket is missing") } return ticket, nil } @@ -201,7 +201,7 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) // getDriveExportStatus fetches the current backend state for a previously // created export task. func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) { - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)), map[string]interface{}{"token": token}, @@ -251,12 +251,12 @@ func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, paylo // Overwrite check via FileIO.Stat if !overwrite { if _, statErr := fio.Stat(target); statErr == nil { - return "", output.ErrValidation("output file already exists: %s (use --overwrite to replace)", target) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", target) } } if _, err := fio.Save(target, fileio.SaveOptions{}, bytes.NewReader(payload)); err != nil { - return "", common.WrapSaveErrorByCategory(err, "io") + return "", driveSaveError(err) } resolvedPath, _ := fio.ResolvePath(target) if resolvedPath == "" { @@ -269,7 +269,7 @@ func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, paylo // file name, and returns metadata about the saved file. func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outputDir, preferredName string, overwrite bool) (map[string]interface{}, error) { if err := validate.ResourceName(fileToken, "--file-token"); err != nil { - return nil, output.ErrValidation("%s", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ @@ -277,10 +277,24 @@ func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)), }, larkcore.WithFileDownload()) if err != nil { - return nil, output.ErrNetwork("download failed: %s", err) + return nil, wrapDriveNetworkErr(err, "download failed: %s", err) } if apiResp.StatusCode >= 400 { - return nil, output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)) + subtype := errs.SubtypeNetworkTransport + if apiResp.StatusCode >= 500 { + subtype = errs.SubtypeNetworkServer + } + e := errs.NewNetworkError(subtype, "download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)).WithCode(apiResp.StatusCode) + // Mirror internal/client streamLogID: fall back to the request-id header + // when log-id is absent so the diagnostic ID is still populated. + logID := strings.TrimSpace(apiResp.Header.Get(larkcore.HttpHeaderKeyLogId)) + if logID == "" { + logID = strings.TrimSpace(apiResp.Header.Get(larkcore.HttpHeaderKeyRequestId)) + } + if logID != "" { + e = e.WithLogID(logID) + } + return nil, e } fileName := strings.TrimSpace(preferredName) diff --git a/shortcuts/drive/drive_export_download.go b/shortcuts/drive/drive_export_download.go index 62ddd9220..daed6cad2 100644 --- a/shortcuts/drive/drive_export_download.go +++ b/shortcuts/drive/drive_export_download.go @@ -6,7 +6,7 @@ package drive import ( "context" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -30,7 +30,7 @@ var DriveExportDownload = common.Shortcut{ }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } return nil }, diff --git a/shortcuts/drive/drive_export_test.go b/shortcuts/drive/drive_export_test.go index 74f94efdb..619c9d43d 100644 --- a/shortcuts/drive/drive_export_test.go +++ b/shortcuts/drive/drive_export_test.go @@ -13,6 +13,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" @@ -360,12 +361,18 @@ func TestDriveExportMarkdownRejectsMissingDocumentObject(t *testing.T) { t.Fatal("expected error for missing document object, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured exit error, got %v", err) + var intErr *errs.InternalError + if !errors.As(err, &intErr) { + t.Fatalf("expected *errs.InternalError, got %T", err) } - if !strings.Contains(exitErr.Detail.Message, "missing document object") { - t.Fatalf("error message = %q, want mention of missing document object", exitErr.Detail.Message) + if intErr.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse) + } + if !strings.Contains(intErr.Message, "missing document object") { + t.Fatalf("error message = %q, want mention of missing document object", intErr.Message) + } + if got := output.ExitCodeOf(err); got != output.ExitInternal { + t.Fatalf("exit code = %d, want %d", got, output.ExitInternal) } } @@ -396,12 +403,18 @@ func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) { t.Fatal("expected error for missing document.content, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured exit error, got %v", err) + var intErr *errs.InternalError + if !errors.As(err, &intErr) { + t.Fatalf("expected *errs.InternalError, got %T", err) } - if !strings.Contains(exitErr.Detail.Message, "missing document.content") { - t.Fatalf("error message = %q, want mention of missing document.content", exitErr.Detail.Message) + if intErr.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse) + } + if !strings.Contains(intErr.Message, "missing document.content") { + t.Fatalf("error message = %q, want mention of missing document.content", intErr.Message) + } + if got := output.ExitCodeOf(err); got != output.ExitInternal { + t.Fatalf("exit code = %d, want %d", got, output.ExitInternal) } } @@ -688,21 +701,25 @@ func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) { t.Fatal("expected download recovery error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured exit error, got %v", err) + // The download itself succeeds; the local "file already exists" failure is a + // validation error. The recovery-hint wrapper must preserve that typed class + // (exit 2) instead of downgrading it to api/server_error (exit 1), per + // ERROR_CONTRACT.md "propagate typed errors unchanged". + var valErr *errs.ValidationError + if !errors.As(err, &valErr) { + t.Fatalf("expected *errs.ValidationError (preserved class), got %T", err) } - if !strings.Contains(exitErr.Detail.Message, "already exists") { - t.Fatalf("message missing overwrite guidance: %q", exitErr.Detail.Message) + if !strings.Contains(valErr.Message, "already exists") { + t.Fatalf("message missing overwrite guidance: %q", valErr.Message) } - if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_ready") { - t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint) + if !strings.Contains(valErr.Hint, "ticket=tk_ready") { + t.Fatalf("hint missing ticket: %q", valErr.Hint) } - if !strings.Contains(exitErr.Detail.Hint, "file_token=box_ready") { - t.Fatalf("hint missing file token: %q", exitErr.Detail.Hint) + if !strings.Contains(valErr.Hint, "file_token=box_ready") { + t.Fatalf("hint missing file token: %q", valErr.Hint) } - if !strings.Contains(exitErr.Detail.Hint, `lark-cli drive +export-download --file-token "box_ready" --file-name "report.pdf"`) { - t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint) + if !strings.Contains(valErr.Hint, `lark-cli drive +export-download --file-token "box_ready" --file-name "report.pdf"`) { + t.Fatalf("hint missing recovery command: %q", valErr.Hint) } } @@ -856,18 +873,26 @@ func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) { t.Fatalf("stdout should stay empty on persistent poll error: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured exit error, got %v", err) + // The poll error is now a typed *errs.APIError (runtime.CallAPITyped). + // The recovery-hint wrapper must preserve that error's class and exit code + // (NOT downgrade it) and only append the recovery hint to the Problem in place. + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T (%v)", err, err) } - if !strings.Contains(exitErr.Detail.Message, "temporary backend failure") { - t.Fatalf("message missing last poll error: %q", exitErr.Detail.Message) + // Lark code 999 is unknown to the classifier, so it maps to CategoryAPI → + // ExitAPI — the wrapper must keep that, not force a different exit code. + if output.ExitCodeOf(err) != output.ExitAPI { + t.Fatalf("exit code = %d, want preserved %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI) } - if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_poll_fail") { - t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint) + if !strings.Contains(p.Message, "temporary backend failure") { + t.Fatalf("message missing last poll error: %q", p.Message) } - if !strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --scenario export --ticket tk_poll_fail --file-token docx123") { - t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint) + if !strings.Contains(p.Hint, "ticket=tk_poll_fail") { + t.Fatalf("hint missing ticket: %q", p.Hint) + } + if !strings.Contains(p.Hint, "lark-cli drive +task_result --scenario export --ticket tk_poll_fail --file-token docx123") { + t.Fatalf("hint missing recovery command: %q", p.Hint) } } diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go index a9f6e32f9..8c5c9a2c8 100644 --- a/shortcuts/drive/drive_import.go +++ b/shortcuts/drive/drive_import.go @@ -9,8 +9,8 @@ import ( "path/filepath" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -161,10 +161,10 @@ func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, // and format-specific size limits before planning the upload path. info, err := fio.Stat(spec.FilePath) if err != nil { - return 0, common.WrapInputStatError(err) + return 0, driveInputStatError(err) } if !info.Mode().IsRegular() { - return 0, output.ErrValidation("file must be a regular file: %s", spec.FilePath) + return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", spec.FilePath).WithParam("--file") } if err = validateDriveImportFileSize(spec.FilePath, spec.DocType, info.Size()); err != nil { return 0, err diff --git a/shortcuts/drive/drive_import_common.go b/shortcuts/drive/drive_import_common.go index e4abe0b84..525051359 100644 --- a/shortcuts/drive/drive_import_common.go +++ b/shortcuts/drive/drive_import_common.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -95,7 +95,7 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{} func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) { importInfo, err := runtime.FileIO().Stat(filePath) if err != nil { - return "", common.WrapInputStatError(err) + return "", driveInputStatError(err) } fileSize := importInfo.Size() @@ -142,7 +142,7 @@ func buildImportMediaExtra(filePath, docType string) (string, error) { "file_extension": strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), "."), }) if err != nil { - return "", output.Errorf(output.ExitInternal, "json_error", "build upload extra failed: %v", err) + return "", errs.NewInternalError(errs.SubtypeUnknown, "build upload extra failed: %v", err).WithCause(err) } return string(extraBytes), nil } @@ -178,20 +178,20 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") if ext == "csv" { // CSV is the only source format whose limit depends on the target type. - return output.ErrValidation( + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file %s exceeds %s import limit for .csv when importing as %s", common.FormatSize(fileSize), common.FormatSize(limit), docType, - ) + ).WithParam("--file") } - return output.ErrValidation( + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file %s exceeds %s import limit for .%s", common.FormatSize(fileSize), common.FormatSize(limit), ext, - ) + ).WithParam("--file") } // validateDriveImportSpec enforces the CLI-level compatibility rules before any @@ -199,18 +199,18 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error func validateDriveImportSpec(spec driveImportSpec) error { ext := spec.FileExtension() if ext == "" { - return output.ErrValidation("file must have an extension (e.g. .md, .docx, .xlsx, .pptx)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx)").WithParam("--file") } switch spec.DocType { case "docx", "sheet", "bitable", "slides": default: - return output.ErrValidation("unsupported target document type: %s. Supported types are: docx, sheet, bitable, slides", spec.DocType) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported target document type: %s. Supported types are: docx, sheet, bitable, slides", spec.DocType).WithParam("--type") } supportedTypes, ok := driveImportExtToDocTypes[ext] if !ok { - return output.ErrValidation("unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext).WithParam("--file") } typeAllowed := false @@ -236,21 +236,21 @@ func validateDriveImportSpec(spec driveImportSpec) error { default: hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType) } - return output.ErrValidation("file type mismatch: %s", hint) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file type mismatch: %s", hint) } if strings.TrimSpace(spec.FolderToken) != "" { if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } } if strings.TrimSpace(spec.TargetToken) != "" { if spec.DocType != "bitable" { - return output.ErrValidation("--target-token is only supported when --type is bitable") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-token is only supported when --type is bitable").WithParam("--target-token") } if err := validate.ResourceName(spec.TargetToken, "--target-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--target-token") } } @@ -308,14 +308,14 @@ func driveImportTaskResultCommand(ticket string) string { // createDriveImportTask creates the server-side import task after the media // upload has produced a reusable file token. func createDriveImportTask(runtime *common.RuntimeContext, spec driveImportSpec, fileToken string) (string, error) { - data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/import_tasks", nil, spec.CreateTaskBody(fileToken)) + data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/import_tasks", nil, spec.CreateTaskBody(fileToken)) if err != nil { return "", err } ticket := common.GetString(data, "ticket") if ticket == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "no ticket returned from import_tasks") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "no ticket returned from import_tasks") } return ticket, nil } @@ -323,10 +323,10 @@ func createDriveImportTask(runtime *common.RuntimeContext, spec driveImportSpec, // getDriveImportStatus fetches the current state of an import task by ticket. func getDriveImportStatus(runtime *common.RuntimeContext, ticket string) (driveImportStatus, error) { if err := validate.ResourceName(ticket, "--ticket"); err != nil { - return driveImportStatus{}, output.ErrValidation("%s", err) + return driveImportStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--ticket") } - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/drive/v1/import_tasks/%s", validate.EncodePathSegment(ticket)), nil, @@ -391,7 +391,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm if msg == "" { msg = status.StatusLabel() } - return status, false, output.Errorf(output.ExitAPI, "api_error", "import failed with status %d: %s", status.JobStatus, msg) + return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg) } } if !hadSuccessfulPoll && lastErr != nil { diff --git a/shortcuts/drive/drive_inspect.go b/shortcuts/drive/drive_inspect.go index 7941d6b48..e448311cf 100644 --- a/shortcuts/drive/drive_inspect.go +++ b/shortcuts/drive/drive_inspect.go @@ -9,7 +9,7 @@ import ( "io" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -37,18 +37,18 @@ var DriveInspect = common.Shortcut{ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { raw := strings.TrimSpace(runtime.Str("url")) if raw == "" { - return output.ErrValidation("--url cannot be empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url") } _, ok := common.ParseResourceURL(raw) if !ok { // Not a recognized URL pattern. if strings.Contains(raw, "://") { - return output.ErrValidation("unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url") } // Bare token: --type is required. if strings.TrimSpace(runtime.Str("type")) == "" { - return output.ErrValidation("--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)").WithParam("--type") } } return nil @@ -111,7 +111,7 @@ var DriveInspect = common.Shortcut{ // Step 2: If type is "wiki", unwrap via get_node API. if docType == "wiki" { fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken)) - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": docToken}, @@ -128,7 +128,7 @@ var DriveInspect = common.Shortcut{ nodeToken := common.GetString(node, "node_token") if objType == "" || objToken == "" { - return output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken) + return errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken) } wikiNode = map[string]interface{}{ diff --git a/shortcuts/drive/drive_inspect_test.go b/shortcuts/drive/drive_inspect_test.go index a40cc399d..ad3db1b17 100644 --- a/shortcuts/drive/drive_inspect_test.go +++ b/shortcuts/drive/drive_inspect_test.go @@ -109,6 +109,45 @@ func TestDriveInspectValidate_ValidWikiURL(t *testing.T) { } } +func TestDriveInspectValidate_ValidDoubaoDriveFileURL(t *testing.T) { + cmd := &cobra.Command{Use: "drive +inspect"} + cmd.Flags().String("url", "", "") + cmd.Flags().String("type", "", "") + _ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/file/boxcnABC") + + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + err := DriveInspect.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestDriveInspectValidate_ValidDoubaoChatDriveFolderURL(t *testing.T) { + cmd := &cobra.Command{Use: "drive +inspect"} + cmd.Flags().String("url", "", "") + cmd.Flags().String("type", "", "") + _ = cmd.Flags().Set("url", "https://feishu.doubao.com/chat/drive/fldcnABC") + + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + err := DriveInspect.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestDriveInspectValidate_ValidDoubaoDriveShareFolderURL(t *testing.T) { + cmd := &cobra.Command{Use: "drive +inspect"} + cmd.Flags().String("url", "", "") + cmd.Flags().String("type", "", "") + _ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/shr/fldcnABC") + + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + err := DriveInspect.Validate(context.Background(), runtime) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + // --- DryRun tests --- func TestDriveInspectDryRun_DocxURL(t *testing.T) { @@ -235,10 +274,85 @@ func TestDriveInspectDryRun_BareTokenWithType(t *testing.T) { } } +func TestDriveInspectDryRun_DoubaoDriveFileURL(t *testing.T) { + cmd := &cobra.Command{Use: "drive +inspect"} + cmd.Flags().String("url", "", "") + cmd.Flags().String("type", "", "") + _ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/file/boxcnABC") + + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + dry := DriveInspect.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run: %v", err) + } + reqDocs, ok := got.API[0].Body["request_docs"].([]interface{}) + if !ok || len(reqDocs) != 1 { + t.Fatalf("expected request_docs with 1 entry, got %v", got.API[0].Body["request_docs"]) + } + doc, _ := reqDocs[0].(map[string]interface{}) + if doc["doc_token"] != "boxcnABC" { + t.Errorf("doc_token = %v, want boxcnABC", doc["doc_token"]) + } + if doc["doc_type"] != "file" { + t.Errorf("doc_type = %v, want file", doc["doc_type"]) + } +} + +func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) { + cmd := &cobra.Command{Use: "drive +inspect"} + cmd.Flags().String("url", "", "") + cmd.Flags().String("type", "", "") + _ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/shr/fldcnABC") + + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + dry := DriveInspect.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run: %v", err) + } + reqDocs, ok := got.API[0].Body["request_docs"].([]interface{}) + if !ok || len(reqDocs) != 1 { + t.Fatalf("expected request_docs with 1 entry, got %v", got.API[0].Body["request_docs"]) + } + doc, _ := reqDocs[0].(map[string]interface{}) + if doc["doc_token"] != "fldcnABC" { + t.Errorf("doc_token = %v, want fldcnABC", doc["doc_token"]) + } + if doc["doc_type"] != "folder" { + t.Errorf("doc_type = %v, want folder", doc["doc_type"]) + } +} + // --- Execute tests --- func TestDriveInspectExecute_DocxURL(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) cfg := driveTestConfig() f, stdout, _, reg := cmdutil.TestFactory(t, cfg) @@ -280,7 +394,6 @@ func TestDriveInspectExecute_DocxURL(t *testing.T) { } func TestDriveInspectExecute_WikiURL(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) cfg := driveTestConfig() f, stdout, _, reg := cmdutil.TestFactory(t, cfg) @@ -343,7 +456,6 @@ func TestDriveInspectExecute_WikiURL(t *testing.T) { } func TestDriveInspectExecute_WikiGetNodeIncompleteData(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) cfg := driveTestConfig() f, stdout, _, reg := cmdutil.TestFactory(t, cfg) @@ -372,7 +484,6 @@ func TestDriveInspectExecute_WikiGetNodeIncompleteData(t *testing.T) { } func TestDriveInspectExecute_BareTokenWithType(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) cfg := driveTestConfig() f, stdout, _, reg := cmdutil.TestFactory(t, cfg) @@ -409,7 +520,6 @@ func TestDriveInspectExecute_BareTokenWithType(t *testing.T) { } func TestDriveInspectExecute_BatchQueryError(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) cfg := driveTestConfig() f, stdout, _, reg := cmdutil.TestFactory(t, cfg) @@ -433,7 +543,6 @@ func TestDriveInspectExecute_BatchQueryError(t *testing.T) { } func TestDriveInspectExecute_PrettyFormat(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) cfg := driveTestConfig() f, stdout, _, reg := cmdutil.TestFactory(t, cfg) diff --git a/shortcuts/drive/drive_io_test.go b/shortcuts/drive/drive_io_test.go index a1b7807e8..6391cbf09 100644 --- a/shortcuts/drive/drive_io_test.go +++ b/shortcuts/drive/drive_io_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "mime" "mime/multipart" "net/http" @@ -17,6 +18,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" @@ -1338,9 +1340,20 @@ func TestDriveUploadValidateRejectsConflictingTargets(t *testing.T) { runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) err := DriveUpload.Validate(context.Background(), runtime) - if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("Validate() error = %T %v, want *errs.ValidationError", err, err) + } + if verr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want %q", verr.Subtype, errs.SubtypeInvalidArgument) + } + if !strings.Contains(verr.Error(), "mutually exclusive") { t.Fatalf("Validate() error = %v, want mutually exclusive error", err) } + // Multi-flag conflict carries no single Param. + if verr.Param != "" { + t.Fatalf("Param = %q, want empty for multi-flag conflict", verr.Param) + } } func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) { @@ -1361,9 +1374,7 @@ func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) { runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) err := DriveUpload.Validate(context.Background(), runtime) - if err == nil || !strings.Contains(err.Error(), "--wiki-token cannot be empty") { - t.Fatalf("Validate() error = %v, want empty wiki-token error", err) - } + assertDriveValidationParam(t, err, "--wiki-token", "--wiki-token cannot be empty") } func TestDriveUploadValidateRejectsExplicitEmptyFileToken(t *testing.T) { @@ -1384,9 +1395,7 @@ func TestDriveUploadValidateRejectsExplicitEmptyFileToken(t *testing.T) { runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) err := DriveUpload.Validate(context.Background(), runtime) - if err == nil || !strings.Contains(err.Error(), "--file-token cannot be empty") { - t.Fatalf("Validate() error = %v, want empty file-token error", err) - } + assertDriveValidationParam(t, err, "--file-token", "--file-token cannot be empty") } func TestDriveUploadValidateRejectsExplicitEmptyFolderToken(t *testing.T) { @@ -1407,8 +1416,25 @@ func TestDriveUploadValidateRejectsExplicitEmptyFolderToken(t *testing.T) { runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) err := DriveUpload.Validate(context.Background(), runtime) - if err == nil || !strings.Contains(err.Error(), "--folder-token cannot be empty") { - t.Fatalf("Validate() error = %v, want empty folder-token error", err) + assertDriveValidationParam(t, err, "--folder-token", "--folder-token cannot be empty") +} + +// assertDriveValidationParam asserts err is a typed *errs.ValidationError with +// SubtypeInvalidArgument, the given Param, and a message containing wantMsg. +func assertDriveValidationParam(t *testing.T, err error, wantParam, wantMsg string) { + t.Helper() + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("error = %T %v, want *errs.ValidationError", err, err) + } + if verr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want %q", verr.Subtype, errs.SubtypeInvalidArgument) + } + if verr.Param != wantParam { + t.Fatalf("Param = %q, want %q", verr.Param, wantParam) + } + if !strings.Contains(verr.Error(), wantMsg) { + t.Fatalf("error = %q, want substring %q", verr.Error(), wantMsg) } } diff --git a/shortcuts/drive/drive_move.go b/shortcuts/drive/drive_move.go index 8eed0bcf3..d61ae1a7b 100644 --- a/shortcuts/drive/drive_move.go +++ b/shortcuts/drive/drive_move.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -74,14 +74,14 @@ var DriveMove = common.Shortcut{ return err } if rootToken == "" { - return output.Errorf(output.ExitAPI, "api_error", "get root folder token failed, root folder is empty") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "get root folder token failed, root folder is empty") } spec.FolderToken = rootToken } fmt.Fprintf(runtime.IO().ErrOut, "Moving %s %s to folder %s...\n", spec.FileType, common.MaskToken(spec.FileToken), common.MaskToken(spec.FolderToken)) - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "POST", fmt.Sprintf("/open-apis/drive/v1/files/%s/move", validate.EncodePathSegment(spec.FileToken)), nil, @@ -95,7 +95,7 @@ var DriveMove = common.Shortcut{ if spec.FileType == "folder" { taskID := common.GetString(data, "task_id") if taskID == "" { - return output.Errorf(output.ExitAPI, "api_error", "move folder returned no task_id") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "move folder returned no task_id") } fmt.Fprintf(runtime.IO().ErrOut, "Folder move is async, polling task %s...\n", taskID) @@ -139,14 +139,14 @@ var DriveMove = common.Shortcut{ // getRootFolderToken resolves the caller's Drive root folder token so other // commands can safely use it as a default destination. func getRootFolderToken(ctx context.Context, runtime *common.RuntimeContext) (string, error) { - data, err := runtime.CallAPI("GET", "/open-apis/drive/explorer/v2/root_folder/meta", nil, nil) + data, err := runtime.CallAPITyped("GET", "/open-apis/drive/explorer/v2/root_folder/meta", nil, nil) if err != nil { return "", err } token := common.GetString(data, "token") if token == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "root_folder/meta returned no token") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "root_folder/meta returned no token") } return token, nil diff --git a/shortcuts/drive/drive_move_common.go b/shortcuts/drive/drive_move_common.go index d200d8cf5..175937e7d 100644 --- a/shortcuts/drive/drive_move_common.go +++ b/shortcuts/drive/drive_move_common.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -47,15 +47,15 @@ func (s driveMoveSpec) RequestBody() map[string]interface{} { func validateDriveMoveSpec(spec driveMoveSpec) error { if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } if strings.TrimSpace(spec.FolderToken) != "" { if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } } if !driveMoveAllowedTypes[spec.FileType] { - return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, slides", spec.FileType) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, slides", spec.FileType).WithParam("--type") } return nil } @@ -109,10 +109,10 @@ func driveTaskCheckParams(taskID string) map[string]interface{} { // folder move or delete task. func getDriveTaskCheckStatus(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, error) { if err := validate.ResourceName(taskID, "--task-id"); err != nil { - return driveTaskCheckStatus{}, output.ErrValidation("%s", err) + return driveTaskCheckStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id") } - data, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files/task_check", driveTaskCheckParams(taskID), nil) + data, err := runtime.CallAPITyped("GET", "/open-apis/drive/v1/files/task_check", driveTaskCheckParams(taskID), nil) if err != nil { return driveTaskCheckStatus{}, err } @@ -163,7 +163,7 @@ func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTas return status, true, nil } if status.Failed() { - return status, false, output.Errorf(output.ExitAPI, "api_error", "folder task failed") + return status, false, errs.NewAPIError(errs.SubtypeServerError, "folder task failed") } } diff --git a/shortcuts/drive/drive_pull.go b/shortcuts/drive/drive_pull.go index 04fb4509f..3fa27eda2 100644 --- a/shortcuts/drive/drive_pull.go +++ b/shortcuts/drive/drive_pull.go @@ -15,8 +15,8 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -88,26 +88,26 @@ var DrivePull = common.Shortcut{ localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) if localDir == "" { - return common.FlagErrorf("--local-dir is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir") } if folderToken == "" { - return common.FlagErrorf("--folder-token is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token") } if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir") } info, err := runtime.FileIO().Stat(localDir) if err != nil { - return common.WrapInputStatError(err) + return driveInputStatError(err) } if !info.IsDir() { - return output.ErrValidation("--local-dir is not a directory: %s", localDir) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir") } if runtime.Bool("delete-local") && !runtime.Bool("yes") { - return output.ErrValidation("--delete-local requires --yes (high-risk: deletes local files absent from Drive)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--delete-local requires --yes (high-risk: deletes local files absent from Drive)").WithParam("--yes") } return nil }, @@ -143,18 +143,18 @@ var DrivePull = common.Shortcut{ // remove the wrong files outside cwd. safeRoot, err := validate.SafeInputPath(localDir) if err != nil { - return output.ErrValidation("--local-dir: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir") } cwdCanonical, err := validate.SafeInputPath(".") if err != nil { - return output.ErrValidation("could not resolve cwd: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err) } // rootRelToCwd is the localDir form FileIO.Save accepts (it // rejects absolute paths). For cwd itself it becomes ".", which // joins cleanly with the rel_paths returned by the lister. rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot) if err != nil { - return output.ErrValidation("--local-dir resolves outside cwd: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir resolves outside cwd: %s", err).WithParam("--local-dir") } fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken)) @@ -174,7 +174,7 @@ var DrivePull = common.Shortcut{ // treated as orphaned. remoteFiles, remotePaths, err := drivePullRemoteViews(entries, duplicateRemote) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%s", err) + return errs.WrapInternal(err) } var downloaded, skipped, failed, deletedLocal int @@ -293,26 +293,25 @@ var DrivePull = common.Shortcut{ // Item-level failures (download error, dir/file conflict, delete // error) must surface as a non-zero exit so AI / script callers // don't have to reach into summary.failed to detect a partial - // sync. The same structured payload rides along in error.detail - // so forensics aren't lost. When --delete-local was skipped - // because of an earlier download failure, callers see - // deleted_local=0 plus the download failure that aborted it, - // which is what makes the partial state self-explanatory. + // sync. On any failure the structured payload (summary + items + + // a "note" carrying the human guidance) is written to stdout as an + // ok:false result via OutPartialFailure, which also sets the exit + // code, so the per-item context is never lost. When --delete-local + // was skipped because + // of an earlier download failure, callers see deleted_local=0 + // plus the download failure that aborted it, which is what makes + // the partial state self-explanatory. if failed > 0 { - msg := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed) + note := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed) if deleteLocal && downloadFailed > 0 { - msg += " (--delete-local was skipped because the download pass had failures)" - } - return &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "partial_failure", - Message: msg, - Detail: payload, - }, + note += " (--delete-local was skipped because the download pass had failures)" } + payload["note"] = note } + if failed > 0 { + return runtime.OutPartialFailure(payload, nil) + } runtime.Out(payload, nil) return nil }, @@ -326,14 +325,14 @@ func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, file ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)), }) if err != nil { - return output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err) + return wrapDriveNetworkErr(err, "download %s: %s", common.MaskToken(fileToken), err) } defer resp.Body.Close() if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{ ContentType: resp.Header.Get("Content-Type"), ContentLength: resp.ContentLength, }, resp.Body); err != nil { - return common.WrapSaveErrorByCategory(err, "io") + return driveSaveError(err) } if err := drivePullApplyRemoteModifiedTime(target, remoteModifiedTime, runtime); err != nil { fmt.Fprintf(runtime.IO().ErrOut, "Downloaded %s but could not preserve remote modified_time: %s\n", target, err) @@ -350,10 +349,10 @@ func drivePullApplyRemoteModifiedTime(target, remoteModifiedTime string, runtime } resolved, err := runtime.FileIO().ResolvePath(target) if err != nil { - return output.ErrValidation("unsafe output path: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err) } if err := drivePullChtimes(resolved, remoteTime, remoteTime); err != nil { - return output.Errorf(output.ExitInternal, "io", "cannot preserve remote modified_time on local file: %s", err) + return errs.NewInternalError(errs.SubtypeFileIO, "cannot preserve remote modified_time on local file: %s", err).WithCause(err) } return nil } @@ -437,7 +436,7 @@ func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken, ModifiedTime: chosen.ModifiedTime} remotePaths[rel] = struct{}{} default: - return nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote) + return nil, nil, errs.NewInternalError(errs.SubtypeUnknown, "unsupported duplicate remote strategy %q", duplicateRemote) } } return remoteFiles, remotePaths, nil @@ -467,7 +466,7 @@ func drivePullWalkLocal(root string) ([]string, error) { return nil }) if err != nil { - return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err) + return nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err) } return paths, nil } diff --git a/shortcuts/drive/drive_pull_test.go b/shortcuts/drive/drive_pull_test.go index c47018481..5a6d6caa3 100644 --- a/shortcuts/drive/drive_pull_test.go +++ b/shortcuts/drive/drive_pull_test.go @@ -478,9 +478,9 @@ func TestDrivePullSkipsWhenSmartIgnoresRemoteSize(t *testing.T) { // already a directory locally. SafeOutputPath would refuse to overwrite // the directory at write time, but if --if-exists=skip silently swallows // the collision the caller sees "skipped" and assumes the mirror is -// in sync. The fix surfaces it as a structured `partial_failure` -// ExitError (non-zero exit + items[] in error.detail) under both skip -// and overwrite policies so callers can react via exit code. +// in sync. The fix surfaces it as a partial-failure (ok:false items[] payload +// on stdout + non-zero exit) under both skip and overwrite policies so callers +// can react via exit code. func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) { for _, policy := range []string{"overwrite", "skip"} { t.Run(policy, func(t *testing.T) { @@ -515,8 +515,8 @@ func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) { "--if-exists", policy, "--as", "bot", }, f, stdout) - detail := assertDrivePullPartialFailure(t, err) - summary, items := splitDrivePullDetail(t, detail) + assertDrivePullPartialFailure(t, err) + summary, items := splitDrivePullStdout(t, stdout.Bytes()) if got := summary["failed"]; got != float64(1) { t.Errorf("[%s] summary.failed = %v, want 1", policy, got) } @@ -529,9 +529,6 @@ func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) { if msg, _ := items[0]["error"].(string); !strings.Contains(msg, "is a directory") { t.Errorf("[%s] error message should mention the directory conflict, got: %q", policy, msg) } - if stdout.Len() != 0 { - t.Errorf("[%s] stdout should be empty on partial_failure, got: %s", policy, stdout.String()) - } }) } } @@ -900,8 +897,8 @@ func TestDrivePullDeleteLocalPreservesLocalFileShadowedByRemoteFolder(t *testing // TestDrivePullDeleteLocalCountsFailureInSummary pins the contract that // a failed delete shows up in summary.failed (not just in items[]) AND -// surfaces as a partial_failure ExitError so callers can detect the -// half-synced state via exit code. Before the fix, the delete_failed +// surfaces as a non-zero exit (partial-failure signal) so callers can detect +// the half-synced state via exit code. Before the fix, the delete_failed // branches appended an item but left `failed` at zero AND returned nil, // so the JSON envelope reported `ok=true`+`exit=0` even when the mirror // was incomplete. Setup forces os.Remove to fail by making the file's @@ -947,8 +944,8 @@ func TestDrivePullDeleteLocalCountsFailureInSummary(t *testing.T) { "--yes", "--as", "bot", }, f, stdout) - detail := assertDrivePullPartialFailure(t, err) - summary, items := splitDrivePullDetail(t, detail) + assertDrivePullPartialFailure(t, err) + summary, items := splitDrivePullStdout(t, stdout.Bytes()) if got := summary["failed"]; got != float64(1) { t.Errorf("summary.failed = %v, want 1 (delete_failed must increment failed)", got) } @@ -958,15 +955,12 @@ func TestDrivePullDeleteLocalCountsFailureInSummary(t *testing.T) { if len(items) != 1 || items[0]["action"] != "delete_failed" { t.Errorf("expected one items[] entry with action=delete_failed, got: %#v", items) } - if stdout.Len() != 0 { - t.Errorf("stdout should be empty on partial_failure, got: %s", stdout.String()) - } } // TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero pins the // gating contract for --delete-local: when the download pass produced // any failure, the delete walk MUST be skipped entirely and the command -// MUST exit non-zero with type=partial_failure. The half-synced state +// MUST exit non-zero via the partial-failure signal. The half-synced state // where some Drive files are missing locally AND some local-only files // have been removed is never observable. func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) { @@ -1014,12 +1008,12 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) { "--yes", "--as", "bot", }, f, stdout) - exitErr := assertDrivePullPartialFailure(t, err) - if !strings.Contains(exitErr.Detail.Message, "--delete-local was skipped") { - t.Errorf("expected message to mention --delete-local skip, got: %q", exitErr.Detail.Message) + assertDrivePullPartialFailure(t, err) + if note := drivePullStdoutNote(t, stdout.Bytes()); !strings.Contains(note, "--delete-local was skipped") { + t.Errorf("expected note to mention --delete-local skip, got: %q", note) } - summary, items := splitDrivePullDetail(t, exitErr) + summary, items := splitDrivePullStdout(t, stdout.Bytes()) if got := summary["failed"]; got != float64(1) { t.Errorf("summary.failed = %v, want 1", got) } @@ -1036,9 +1030,6 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) { if _, statErr := os.Stat(stale); statErr != nil { t.Fatalf("stale.txt must survive when --delete-local is skipped after a download failure; stat err=%v", statErr) } - if stdout.Len() != 0 { - t.Errorf("stdout should be empty on partial_failure, got: %s", stdout.String()) - } } // TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef is the @@ -1343,49 +1334,60 @@ func mustReadFile(t *testing.T, path, want string) { } } -// assertDrivePullPartialFailure asserts that err is the structured -// partial_failure ExitError +pull returns when any item-level failure -// happens, and returns the unwrapped *ExitError so the caller can drill -// into Detail.Detail without re-doing the type assertion. -func assertDrivePullPartialFailure(t *testing.T, err error) *output.ExitError { +// assertDrivePullPartialFailure asserts that err is the typed partial-failure +// exit signal +pull returns on any item-level failure. The structured +// {summary, items, note} payload rides on stdout as an ok:false envelope via +// runtime.OutPartialFailure (in alignment with +push/+sync), so this helper +// only checks the exit-code signal; callers read the payload from stdout via +// splitDrivePullStdout. +func assertDrivePullPartialFailure(t *testing.T, err error) { t.Helper() if err == nil { - t.Fatal("expected partial_failure ExitError, got nil") + t.Fatal("expected partial-failure exit signal, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) } - if exitErr.Code != output.ExitAPI { - t.Errorf("exit code = %d, want %d (ExitAPI)", exitErr.Code, output.ExitAPI) + if pfErr.Code != output.ExitAPI { + t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI) } - if exitErr.Detail == nil { - t.Fatalf("ExitError.Detail must be set on partial_failure") - } - if exitErr.Detail.Type != "partial_failure" { - t.Errorf("error.type = %q, want partial_failure", exitErr.Detail.Type) - } - return exitErr } -// splitDrivePullDetail extracts the {summary, items[]} payload from the -// ExitError detail. We round-trip through JSON so test assertions don't -// depend on the concrete map types the production code happens to use. -func splitDrivePullDetail(t *testing.T, exitErr *output.ExitError) (map[string]interface{}, []map[string]interface{}) { +// splitDrivePullStdout extracts the {summary, items[]} payload from the +// stdout envelope written by runtime.Out. We round-trip through JSON so test +// assertions don't depend on the concrete map types the production code +// happens to use. +func splitDrivePullStdout(t *testing.T, stdout []byte) (map[string]interface{}, []map[string]interface{}) { t.Helper() - raw, err := json.Marshal(exitErr.Detail.Detail) - if err != nil { - t.Fatalf("marshal detail: %v", err) + var envelope struct { + Data struct { + Summary map[string]interface{} `json:"summary"` + Items []map[string]interface{} `json:"items"` + } `json:"data"` } - var got struct { - Summary map[string]interface{} `json:"summary"` - Items []map[string]interface{} `json:"items"` + if err := json.Unmarshal(stdout, &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout)) } - if err := json.Unmarshal(raw, &got); err != nil { - t.Fatalf("unmarshal detail: %v\nraw=%s", err, string(raw)) + if envelope.Data.Summary == nil { + t.Fatalf("stdout missing data.summary; raw=%s", string(stdout)) } - if got.Summary == nil { - t.Fatalf("error.detail missing summary; raw=%s", string(raw)) - } - return got.Summary, got.Items + return envelope.Data.Summary, envelope.Data.Items +} + +// drivePullStdoutNote extracts the partial-failure "note" guidance from the +// stdout envelope. The human-readable note that used to live in the +// partial_failure ExitError message now rides on stdout alongside the +// summary + items payload. +func drivePullStdoutNote(t *testing.T, stdout []byte) string { + t.Helper() + var envelope struct { + Data struct { + Note string `json:"note"` + } `json:"data"` + } + if err := json.Unmarshal(stdout, &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout)) + } + return envelope.Data.Note } diff --git a/shortcuts/drive/drive_push.go b/shortcuts/drive/drive_push.go index 78c34e67c..63bbf7c3d 100644 --- a/shortcuts/drive/drive_push.go +++ b/shortcuts/drive/drive_push.go @@ -5,7 +5,6 @@ package drive import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -19,6 +18,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -112,26 +112,26 @@ var DrivePush = common.Shortcut{ localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) if localDir == "" { - return common.FlagErrorf("--local-dir is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir") } if folderToken == "" { - return common.FlagErrorf("--folder-token is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token") } if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir") } info, err := runtime.FileIO().Stat(localDir) if err != nil { - return common.WrapInputStatError(err) + return driveInputStatError(err) } if !info.IsDir() { - return output.ErrValidation("--local-dir is not a directory: %s", localDir) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir") } if runtime.Bool("delete-remote") && !runtime.Bool("yes") { - return output.ErrValidation("--delete-remote requires --yes (high-risk: deletes Drive files absent locally)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--delete-remote requires --yes (high-risk: deletes Drive files absent locally)").WithParam("--yes") } // Conditional scope pre-check: when --delete-remote --yes is set, the // run will issue DELETE /open-apis/drive/v1/files/ after the @@ -185,11 +185,11 @@ var DrivePush = common.Shortcut{ // FileIO.Open's SafeInputPath check still accepts. safeRoot, err := validate.SafeInputPath(localDir) if err != nil { - return output.ErrValidation("--local-dir: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir") } cwdCanonical, err := validate.SafeInputPath(".") if err != nil { - return output.ErrValidation("could not resolve cwd: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err) } fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir) @@ -217,7 +217,7 @@ var DrivePush = common.Shortcut{ // reruns. remoteFiles, remoteFolders, remoteFileGroups, err := drivePushRemoteViews(entries, duplicateRemote) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%s", err) + return errs.WrapInternal(err) } var uploaded, skipped, failed, deletedRemote int @@ -374,7 +374,7 @@ var DrivePush = common.Shortcut{ } } - runtime.Out(map[string]interface{}{ + payload := map[string]interface{}{ "summary": map[string]interface{}{ "uploaded": uploaded, "skipped": skipped, @@ -382,15 +382,15 @@ var DrivePush = common.Shortcut{ "deleted_remote": deletedRemote, }, "items": items, - }, nil) - // Bump the exit code on any item-level failure (upload, overwrite, - // folder, or delete) so callers / scripts / agents can react. The - // summary + items[] envelope was just written to stdout via Out(), - // so ErrBare here only affects the exit code — the structured - // per-item context is still in the stdout JSON. - if failed > 0 { - return output.ErrBare(output.ExitAPI) } + // On any item-level failure (upload, overwrite, folder, or delete) the + // command reports a partial failure: the summary + per-item items[] stay + // machine-readable on stdout (ok:false) and the process exits non-zero, + // so callers / scripts / agents can react. + if failed > 0 { + return runtime.OutPartialFailure(payload, nil) + } + runtime.Out(payload, nil) return nil }, } @@ -466,7 +466,7 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil return nil }) if err != nil { - return nil, nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err) + return nil, nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err) } dirs := make([]string, 0, len(dirsSet)) for d := range dirsSet { @@ -543,7 +543,7 @@ func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m } remoteFiles[rel] = chosen default: - return nil, nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote) + return nil, nil, nil, errs.NewInternalError(errs.SubtypeUnknown, "unsupported duplicate remote strategy %q", duplicateRemote) } } return remoteFiles, remoteFolders, fileGroups, nil @@ -567,7 +567,7 @@ func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext, return "", err } - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "POST", "/open-apis/drive/v1/files/create_folder", nil, @@ -581,7 +581,7 @@ func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext, } token := common.GetString(data, "token") if token == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "create_folder for %q returned no folder token", relDir) + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "create_folder for %q returned no folder token", relDir) } folderCache[relDir] = token return token, nil @@ -617,7 +617,7 @@ func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, fi func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) { f, err := runtime.FileIO().Open(file.OpenPath) if err != nil { - return "", "", common.WrapInputStatError(err) + return "", "", driveInputStatError(err) } defer f.Close() @@ -644,27 +644,22 @@ func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file if errors.As(err, &exitErr) { return "", "", err } - return "", "", output.ErrNetwork("upload failed: %v", err) + return "", "", wrapDriveNetworkErr(err, "upload failed: %v", err) } - var result map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err) - } - // Extract the token before the larkCode check: the backend can produce - // a partial-success response (code != 0 alongside a non-empty - // data.file_token) where bytes have already landed under that token. - // Returning "" here would force the caller to fall back to + // ClassifyAPIResponse returns the data even on a non-zero code, so the + // token is available on a partial-success response (code != 0 alongside a + // non-empty data.file_token) where bytes have already landed under that + // token. Returning "" would force the caller to fall back to // entry.FileToken and silently lose the token Drive actually used, // defeating the overwrite-error token-stability handling in Execute. - data, _ := result["data"].(map[string]interface{}) + data, err := runtime.ClassifyAPIResponse(apiResp) token := common.GetString(data, "file_token") - if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 { - msg, _ := result["msg"].(string) - return token, "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"]) + if err != nil { + return token, "", err } if token == "" { - return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") + return "", "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned") } version := common.GetString(data, "version") if version == "" { @@ -677,7 +672,7 @@ func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file // deployed backend hasn't shipped the field yet we surface the gap // rather than report a phantom success — callers can downgrade to // --if-exists=skip in the meantime. - return token, "", output.Errorf(output.ExitAPI, "api_error", "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath) + return token, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath) } return token, version, nil } @@ -692,7 +687,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, if existingToken != "" { prepareBody["file_token"] = existingToken } - prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody) + prepareResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody) if err != nil { return "", err } @@ -701,7 +696,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, blockSize := int64(common.GetFloat(prepareResult, "block_size")) blockNum := int(common.GetFloat(prepareResult, "block_num")) if uploadID == "" || blockSize <= 0 || blockNum <= 0 { - return "", output.Errorf(output.ExitAPI, "api_error", + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d", uploadID, blockSize, blockNum) } @@ -717,7 +712,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, // one Open + Close + path-validation per block). partFile, err := runtime.FileIO().Open(file.OpenPath) if err != nil { - return "", common.WrapInputStatError(err) + return "", driveInputStatError(err) } defer partFile.Close() @@ -744,21 +739,16 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, if errors.As(doErr, &exitErr) { return "", doErr } - return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, doErr) + return "", wrapDriveNetworkErr(doErr, "upload part %d/%d failed: %v", seq+1, blockNum, doErr) } - var partResult map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil { - return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err) - } - if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 { - msg, _ := partResult["msg"].(string) - return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"]) + if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil { + return "", err } fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize)) } - finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{ + finishResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{ "upload_id": uploadID, "block_num": blockNum, }) @@ -767,7 +757,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, } token := common.GetString(finishResult, "file_token") if token == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned") } return token, nil } @@ -776,7 +766,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, // never reached here because --delete-remote only iterates the type=file // subset of the remote listing. func drivePushDeleteFile(_ context.Context, runtime *common.RuntimeContext, fileToken string) error { - _, err := runtime.CallAPI( + _, err := runtime.CallAPITyped( "DELETE", fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(fileToken)), map[string]interface{}{"type": driveTypeFile}, diff --git a/shortcuts/drive/drive_push_test.go b/shortcuts/drive/drive_push_test.go index 3d5654ca2..fff10eebe 100644 --- a/shortcuts/drive/drive_push_test.go +++ b/shortcuts/drive/drive_push_test.go @@ -871,21 +871,19 @@ func TestDrivePushOverwriteWithoutVersionFails(t *testing.T) { "--if-exists", "overwrite", "--as", "bot", }, f, stdout) - // Item-level failures bump the exit code via output.ErrBare(ExitAPI), - // preserving the structured items[] envelope on stdout. Older behavior - // was to silently return nil; the assertion below pins the new contract. + // Item-level failures report a partial failure: an ok:false items[] + // envelope on stdout + a non-zero exit via the partial-failure signal. + // Older behavior was to silently return nil; the assertion below pins + // the new contract. if err == nil { t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) } - if exitErr.Code != output.ExitAPI { - t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, exitErr.Code) - } - if exitErr.Detail != nil { - t.Errorf("ErrBare should carry no Detail (the items[] envelope already covered the per-item error), got: %#v", exitErr.Detail) + if pfErr.Code != output.ExitAPI { + t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, pfErr.Code) } out := stdout.String() @@ -959,12 +957,19 @@ func TestDrivePushOverwritePartialSuccessSurfacesReturnedToken(t *testing.T) { if err == nil { t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI { - t.Fatalf("expected ExitAPI from output.ExitError, got %T %v", err, err) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI { + t.Fatalf("expected ExitAPI from *output.PartialFailureError, got %T %v", err, err) } out := stdout.String() + // Partial failure reports an ok:false result envelope on stdout (not a + // misleading ok:true) while still carrying BOTH the succeeded and failed + // items — consistent with the pre-change payload. The failed side is + // asserted via "failed": 1 and the succeeded side via tok_keep_partial. + if !strings.Contains(out, `"ok": false`) { + t.Errorf("partial failure must emit an ok:false result envelope, got: %s", out) + } if !strings.Contains(out, `"failed": 1`) { t.Errorf("expected failed=1, got: %s", out) } @@ -1042,9 +1047,9 @@ func TestDrivePushSkipsDeleteAfterUploadFailure(t *testing.T) { if err == nil { t.Fatalf("expected non-zero exit on overwrite failure, got nil\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI { - t.Fatalf("expected ExitAPI ExitError, got %v", err) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI { + t.Fatalf("expected ExitAPI *output.PartialFailureError, got %v", err) } out := stdout.String() @@ -1065,7 +1070,7 @@ func TestDrivePushSkipsDeleteAfterUploadFailure(t *testing.T) { // TestDrivePushExitsZeroOnCleanRun pins the inverse: a successful run // with no failures must NOT bump the exit code. Without this the -// ErrBare-on-failure path could regress to "always non-zero" silently. +// partial-failure path could regress to "always non-zero" silently. func TestDrivePushExitsZeroOnCleanRun(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) diff --git a/shortcuts/drive/drive_search.go b/shortcuts/drive/drive_search.go index e1b618fe6..a17872512 100644 --- a/shortcuts/drive/drive_search.go +++ b/shortcuts/drive/drive_search.go @@ -6,7 +6,6 @@ package drive import ( "context" "encoding/json" - "errors" "fmt" "io" "math" @@ -15,6 +14,7 @@ import ( "strings" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -219,13 +219,13 @@ func readDriveSearchSpec(runtime *common.RuntimeContext) driveSearchSpec { // that depends on the combination of flag values. func buildDriveSearchRequest(spec driveSearchSpec, userOpenID string, now time.Time) (map[string]interface{}, []string, error) { if spec.Mine && len(spec.CreatorIDs) > 0 { - return nil, nil, output.ErrValidation("cannot combine --mine and --creator-ids") + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --mine and --creator-ids") } if len(spec.FolderTokens) > 0 && len(spec.SpaceIDs) > 0 { - return nil, nil, output.ErrValidation("cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined") + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined") } if spec.Mine && userOpenID == "" { - return nil, nil, output.ErrValidation("--mine requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config") + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--mine requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config").WithParam("--mine") } if err := validateDocTypes(spec.DocTypes); err != nil { @@ -337,7 +337,7 @@ func parseDriveSearchPageSize(raw string) (int, error) { } n, err := strconv.Atoi(raw) if err != nil { - return 0, output.ErrValidation("--page-size must be a number, got %q", raw) + return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be a number, got %q", raw).WithParam("--page-size") } if n <= 0 { return 15, nil @@ -355,23 +355,23 @@ func parseDriveSearchPageSize(raw string) (int, error) { func validateDriveSearchIDs(spec driveSearchSpec) error { for _, id := range spec.CreatorIDs { if _, err := common.ValidateUserID(id); err != nil { - return output.ErrValidation("--creator-ids %q: %s", id, err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--creator-ids %q: %s", id, err).WithParam("--creator-ids") } } if n := len(spec.ChatIDs); n > driveSearchMaxChatIDs { - return output.ErrValidation("--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n).WithParam("--chat-ids") } for _, id := range spec.ChatIDs { if _, err := common.ValidateChatID(id); err != nil { - return output.ErrValidation("--chat-ids %q: %s", id, err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids %q: %s", id, err).WithParam("--chat-ids") } } if n := len(spec.SharerIDs); n > driveSearchMaxSharerIDs { - return output.ErrValidation("--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n).WithParam("--sharer-ids") } for _, id := range spec.SharerIDs { if _, err := common.ValidateUserID(id); err != nil { - return output.ErrValidation("--sharer-ids %q: %s", id, err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids %q: %s", id, err).WithParam("--sharer-ids") } } return nil @@ -382,7 +382,7 @@ func validateDocTypes(values []string) error { // values are already upper-cased by readDriveSearchSpec; compare as-is // so the filter we emit to the server matches what we validated. if _, ok := driveSearchDocTypeSet[v]; !ok { - return output.ErrValidation("--doc-types contains unknown value %q (allowed: doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut)", v) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-types contains unknown value %q (allowed: doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut)", v).WithParam("--doc-types") } } return nil @@ -417,13 +417,13 @@ func clampOpenedTimeWindow(spec *driveSearchSpec, now time.Time) (string, error) } sinceUnix, err := parseTimeValue(spec.OpenedSince, now) if err != nil { - return "", output.ErrValidation("invalid --opened-since %q: %s", spec.OpenedSince, err) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --opened-since %q: %s", spec.OpenedSince, err).WithParam("--opened-since") } var untilUnix int64 if spec.OpenedUntil != "" { untilUnix, err = parseTimeValue(spec.OpenedUntil, now) if err != nil { - return "", output.ErrValidation("invalid --opened-until %q: %s", spec.OpenedUntil, err) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --opened-until %q: %s", spec.OpenedUntil, err).WithParam("--opened-until") } } else { untilUnix = now.Unix() @@ -440,7 +440,7 @@ func clampOpenedTimeWindow(spec *driveSearchSpec, now time.Time) (string, error) } maxSecs := int64(driveSearchMaxOpenedSpanDays) * 24 * 3600 if spanSecs > maxSecs { - return "", output.ErrValidation( + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--opened-* window spans %d days, exceeds the %d-day (1-year) maximum; narrow the range or run multiple queries", spanSecs/86400, driveSearchMaxOpenedSpanDays, ) @@ -505,7 +505,7 @@ func buildTimeRangeFilter(key, since, until string, now time.Time) (map[string]i if since != "" { unix, err := parseTimeValue(since, now) if err != nil { - return nil, nil, output.ErrValidation("invalid --%s-since %q: %s", timeDimCLIName(key), since, err) + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --%s-since %q: %s", timeDimCLIName(key), since, err).WithParam(fmt.Sprintf("--%s-since", timeDimCLIName(key))) } if hourAggregated && unix%3600 != 0 { snapped := floorHour(unix) @@ -517,7 +517,7 @@ func buildTimeRangeFilter(key, since, until string, now time.Time) (map[string]i if until != "" { unix, err := parseTimeValue(until, now) if err != nil { - return nil, nil, output.ErrValidation("invalid --%s-until %q: %s", timeDimCLIName(key), until, err) + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --%s-until %q: %s", timeDimCLIName(key), until, err).WithParam(fmt.Sprintf("--%s-until", timeDimCLIName(key))) } if hourAggregated && unix%3600 != 0 { snapped := ceilHour(unix) @@ -571,7 +571,7 @@ var driveSearchRelativeRe = regexp.MustCompile(`^(\d+)([dmy])$`) func parseTimeValue(input string, now time.Time) (int64, error) { s := strings.TrimSpace(input) if s == "" { - return 0, fmt.Errorf("empty value") + return 0, fmt.Errorf("empty value") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError } if m := driveSearchRelativeRe.FindStringSubmatch(s); m != nil { @@ -616,34 +616,27 @@ func parseTimeValue(input string, now time.Time) (int64, error) { } } - return 0, fmt.Errorf("expected relative (7d/1m/1y), date (YYYY-MM-DD[ HH:MM:SS]), RFC3339, or unix seconds") + return 0, fmt.Errorf("expected relative (7d/1m/1y), date (YYYY-MM-DD[ HH:MM:SS]), RFC3339, or unix seconds") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError } func callDriveSearchAPI(runtime *common.RuntimeContext, reqBody map[string]interface{}) (map[string]interface{}, error) { - data, err := runtime.CallAPI("POST", "/open-apis/search/v2/doc_wiki/search", nil, reqBody) + data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, reqBody) if err != nil { return nil, enrichDriveSearchError(err) } return data, nil } -// enrichDriveSearchError adds a +search-specific hint for known opaque Lark -// codes; other errors pass through unchanged. +// enrichDriveSearchError adds a +search-specific hint for a known opaque Lark +// code; other errors pass through unchanged. The hint is appended in place on +// the typed Problem, preserving its category / subtype / code / log_id. func enrichDriveSearchError(err error) error { - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { + p, ok := errs.ProblemOf(err) + if !ok || p.Code != driveSearchErrUserNotVisible { return err } - if exitErr.Detail.Code != driveSearchErrUserNotVisible { - return err - } - detail := *exitErr.Detail - detail.Hint = "one or more open_ids in --creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids" - return &output.ExitError{ - Code: exitErr.Code, - Detail: &detail, - Err: exitErr.Err, - } + p.Hint = "one or more open_ids in --creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids" + return err } func cloneDriveSearchFilter(src map[string]interface{}) map[string]interface{} { diff --git a/shortcuts/drive/drive_search_test.go b/shortcuts/drive/drive_search_test.go index a26faf3e6..7e9721e02 100644 --- a/shortcuts/drive/drive_search_test.go +++ b/shortcuts/drive/drive_search_test.go @@ -13,6 +13,8 @@ import ( "testing" "time" + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/output" ) @@ -258,6 +260,19 @@ func TestValidateDriveSearchIDs(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "--creator-ids") { t.Fatalf("expected --creator-ids error, got: %v", err) } + var vErr *errs.ValidationError + if !errors.As(err, &vErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if vErr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument) + } + if vErr.Param != "--creator-ids" { + t.Fatalf("Param = %q, want --creator-ids", vErr.Param) + } + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation) + } }) t.Run("bad chat id format", func(t *testing.T) { @@ -625,51 +640,39 @@ func TestEnrichDriveSearchError(t *testing.T) { } }) - t.Run("ExitError without Detail passes through", func(t *testing.T) { + t.Run("typed error with non-matching code passes through", func(t *testing.T) { t.Parallel() - orig := &output.ExitError{Code: 1} - if got := enrichDriveSearchError(orig); got != orig { - t.Fatalf("ExitError without Detail should pass through unchanged") - } - }) - - t.Run("ExitError with non-matching code passes through", func(t *testing.T) { - t.Parallel() - orig := &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{Code: 12345, Message: "other"}, - } + orig := errclass.BuildAPIError( + map[string]any{"code": float64(12345), "msg": "other"}, + errclass.ClassifyContext{}, + ) if got := enrichDriveSearchError(orig); got != orig { t.Fatalf("non-matching code should pass through unchanged") } }) - t.Run("matching code rewrites Hint without mutating original", func(t *testing.T) { + t.Run("matching code decorates the typed error's hint in place", func(t *testing.T) { t.Parallel() - orig := &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{ - Code: driveSearchErrUserNotVisible, - Message: "[99992351] user not visible", - Hint: "", - }, - } + orig := errclass.BuildAPIError( + map[string]any{"code": float64(driveSearchErrUserNotVisible), "msg": "[99992351] user not visible"}, + errclass.ClassifyContext{}, + ) + // Terminal decoration of an upstream error: the hint is set in place on + // the existing typed Problem and that same error is returned (no new + // error is constructed). enriched := enrichDriveSearchError(orig) - eErr, ok := enriched.(*output.ExitError) + if enriched != orig { + t.Fatal("should decorate and return the upstream error, not construct a new one") + } + p, ok := errs.ProblemOf(enriched) if !ok { - t.Fatalf("expected *output.ExitError, got %T", enriched) + t.Fatalf("expected a typed errs.* error, got %T", enriched) } - if eErr == orig { - t.Fatal("should return a new ExitError, not mutate the original") + if !strings.Contains(p.Hint, "--creator-ids") { + t.Fatalf("hint should mention --creator-ids, got %q", p.Hint) } - if orig.Detail.Hint != "" { - t.Fatal("original Detail.Hint must remain unchanged") - } - if !strings.Contains(eErr.Detail.Hint, "--creator-ids") { - t.Fatalf("hint should mention --creator-ids, got %q", eErr.Detail.Hint) - } - if eErr.Detail.Message != orig.Detail.Message { - t.Fatalf("Message should be preserved, got %q", eErr.Detail.Message) + if p.Message != "[99992351] user not visible" { + t.Fatalf("Message should be preserved, got %q", p.Message) } }) } @@ -739,6 +742,18 @@ func TestBuildDriveSearchRequest(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "--mine") { t.Fatalf("expected exclusion error, got: %v", err) } + // Mutual-exclusion error: typed validation, but no single attributable + // flag, so Param stays empty. + var vErr *errs.ValidationError + if !errors.As(err, &vErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if vErr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument) + } + if vErr.Param != "" { + t.Fatalf("Param = %q, want empty for mutual-exclusion error", vErr.Param) + } }) t.Run("--folder-tokens + --space-ids mutually exclusive", func(t *testing.T) { diff --git a/shortcuts/drive/drive_secure_label.go b/shortcuts/drive/drive_secure_label.go new file mode 100644 index 000000000..a7b8f95db --- /dev/null +++ b/shortcuts/drive/drive_secure_label.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + secureLabelReadScope = "drive:file.meta.sec_label.read_only" + secureLabelUpdateScope = "docs:secure_label:write_only" +) + +var secureLabelTypes = permApplyTypes + +// DriveSecureLabelList lists secure labels available to the current user. +var DriveSecureLabelList = common.Shortcut{ + Service: "drive", + Command: "+secure-label-list", + Description: "List secure labels available to the current user", + Risk: "read", + Scopes: []string{secureLabelReadScope}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"}, + {Name: "page-token", Desc: "pagination token from previous response"}, + {Name: "lang", Desc: "label language", Enum: []string{"zh", "en", "ja"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + pageSize := runtime.Int("page-size") + if pageSize < 1 || pageSize > 10 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 10").WithParam("--page-size") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Desc("List secure labels available to the current user"). + GET("/open-apis/drive/v2/my_secure_labels"). + Params(buildSecureLabelListParams(runtime)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + data, err := runtime.CallAPITyped("GET", + "/open-apis/drive/v2/my_secure_labels", + buildSecureLabelListParams(runtime), + nil, + ) + if err != nil { + return err + } + runtime.OutFormat(data, nil, nil) + return nil + }, +} + +// DriveSecureLabelUpdate updates the secure label on a Drive file/document. +var DriveSecureLabelUpdate = common.Shortcut{ + Service: "drive", + Command: "+secure-label-update", + Description: "Update the secure label on a Drive file or document", + Risk: "write", + Scopes: []string{secureLabelUpdateScope}, + AuthTypes: []string{"user"}, + Flags: []common.Flag{ + {Name: "token", Desc: "target file token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true}, + {Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: secureLabelTypes}, + {Name: "label-id", Desc: "secure label ID to set", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + Desc("Update Drive secure label"). + PATCH("/open-apis/drive/v2/files/:file_token/secure_label"). + Params(map[string]interface{}{"type": docType}). + Body(map[string]interface{}{"id": runtime.Str("label-id")}). + Set("file_token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")) + if err != nil { + return err + } + body := map[string]interface{}{"id": runtime.Str("label-id")} + data, err := runtime.CallAPITyped("PATCH", + fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)), + map[string]interface{}{"type": docType}, + body, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{"page_size": runtime.Int("page-size")} + if pageToken := runtime.Str("page-token"); pageToken != "" { + params["page_token"] = pageToken + } + if lang := runtime.Str("lang"); lang != "" { + params["lang"] = lang + } + return params +} + +func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) { + return resolvePermApplyTarget(raw, explicitType) +} diff --git a/shortcuts/drive/drive_secure_label_test.go b/shortcuts/drive/drive_secure_label_test.go new file mode 100644 index 000000000..6132c4357 --- /dev/null +++ b/shortcuts/drive/drive_secure_label_test.go @@ -0,0 +1,164 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestDriveSecureLabelList_DryRun(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveSecureLabelList, []string{ + "+secure-label-list", + "--page-size", "5", + "--page-token", "page_1", + "--lang", "zh", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{ + "/open-apis/drive/v2/my_secure_labels", + `"GET"`, + `"page_size": 5`, + `"page_token": "page_1"`, + `"lang": "zh"`, + } { + if !strings.Contains(out, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, out) + } + } +} + +func TestDriveSecureLabelList_ValidatePageSize(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveSecureLabelList, []string{ + "+secure-label-list", + "--page-size", "11", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "page-size") { + t.Fatalf("expected page-size validation error, got: %v", err) + } +} + +func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v2/my_secure_labels?page_size=10", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"id": "7217780879644737540", "name": "L1"}, + }, + }, + }, + }) + + err := mountAndRunDrive(t, DriveSecureLabelList, []string{ + "+secure-label-list", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"L1"`) { + t.Fatalf("stdout missing label:\n%s", stdout.String()) + } +} + +func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{ + "+secure-label-update", + "--token", "https://example.feishu.cn/docx/doxTok123?from=share", + "--label-id", "7217780879644737539", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{ + "/open-apis/drive/v2/files/doxTok123/secure_label", + `"PATCH"`, + `"docx"`, + `"id": "7217780879644737539"`, + `"file_token": "doxTok123"`, + } { + if !strings.Contains(out, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, out) + } + } +} + +func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/drive/v2/files/doxTok123/secure_label?type=docx", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{ + "+secure-label-update", + "--token", "doxTok123", + "--type", "docx", + "--label-id", "7217780879644737539", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + if body["id"] != "7217780879644737539" { + t.Fatalf("id = %v, want label id", body["id"]) + } +} + +func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/drive/v2/files/doxTok123/secure_label", + Status: 403, + Body: map[string]interface{}{ + "code": 1063013, "msg": "Security label downgrade requires approval", + }, + }) + + targetURL := "https://example.feishu.cn/docx/doxTok123" + err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{ + "+secure-label-update", + "--token", targetURL, + "--label-id", "7217780879644737539", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected 1063013 error") + } + if !strings.Contains(err.Error(), "Security label downgrade requires approval") { + t.Fatalf("expected raw API error message, got: %v", err) + } +} diff --git a/shortcuts/drive/drive_status.go b/shortcuts/drive/drive_status.go index b3e9470c0..45554f384 100644 --- a/shortcuts/drive/drive_status.go +++ b/shortcuts/drive/drive_status.go @@ -17,7 +17,7 @@ import ( 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/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -75,27 +75,27 @@ var DriveStatus = common.Shortcut{ localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) if localDir == "" { - return common.FlagErrorf("--local-dir is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir") } if folderToken == "" { - return common.FlagErrorf("--folder-token is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token") } if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } // Path safety (absolute paths, traversal, symlink escape) is enforced // upfront by the framework helper so the error message references the // correct flag name; FileIO().Stat below would do the same check, but // surface --file in its hint. if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir") } info, err := runtime.FileIO().Stat(localDir) if err != nil { - return common.WrapInputStatError(err) + return driveInputStatError(err) } if !info.IsDir() { - return output.ErrValidation("--local-dir is not a directory: %s", localDir) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir") } // Conditional scope pre-check: quick mode only compares local mtime with // Drive modified_time, so it must not be blocked on the download grant. @@ -144,11 +144,11 @@ var DriveStatus = common.Shortcut{ // only possible under a Validate↔Execute race. safeRoot, err := validate.SafeInputPath(localDir) if err != nil { - return output.ErrValidation("--local-dir: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir") } cwdCanonical, err := validate.SafeInputPath(".") if err != nil { - return output.ErrValidation("could not resolve cwd: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err) } fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir) @@ -263,7 +263,7 @@ func walkLocalForStatus(root, cwdCanonical string) (map[string]driveStatusLocalF return nil }) if err != nil { - return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err) + return nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err) } return files, nil } @@ -276,12 +276,12 @@ func driveStatusShouldTreatAsUnchangedQuick(remoteModified string, local time.Ti func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) { f, err := runtime.FileIO().Open(path) if err != nil { - return "", common.WrapInputStatError(err) + return "", driveInputStatError(err) } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { - return "", output.Errorf(output.ExitInternal, "io", "hash %s: %s", path, err) + return "", errs.NewInternalError(errs.SubtypeFileIO, "hash %s: %s", path, err).WithCause(err) } return hex.EncodeToString(h.Sum(nil)), nil } @@ -292,12 +292,12 @@ func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fi ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)), }) if err != nil { - return "", output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err) + return "", wrapDriveNetworkErr(err, "download %s: %s", common.MaskToken(fileToken), err) } defer resp.Body.Close() h := sha256.New() if _, err := io.Copy(h, resp.Body); err != nil { - return "", output.ErrNetwork("hash remote %s: %s", common.MaskToken(fileToken), err) + return "", wrapDriveNetworkErr(err, "hash remote %s: %s", common.MaskToken(fileToken), err) } return hex.EncodeToString(h.Sum(nil)), nil } diff --git a/shortcuts/drive/drive_status_test.go b/shortcuts/drive/drive_status_test.go index b0ea305fe..d00498460 100644 --- a/shortcuts/drive/drive_status_test.go +++ b/shortcuts/drive/drive_status_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" @@ -327,21 +328,28 @@ func TestDriveStatusExactRejectsMissingDownloadScope(t *testing.T) { if err == nil { t.Fatal("expected missing_scope error for exact mode without drive:file:download") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected structured exit error, got %T", err) + var permErr *errs.PermissionError + if !errors.As(err, &permErr) { + t.Fatalf("expected *errs.PermissionError, got %T", err) } - if exitErr.Detail == nil || exitErr.Detail.Type != "missing_scope" { - t.Fatalf("expected missing_scope detail, got %#v", exitErr.Detail) + if permErr.Subtype != errs.SubtypeMissingScope { + t.Fatalf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope) } if !strings.Contains(err.Error(), "missing required scope(s): drive:file:download") { t.Fatalf("unexpected error: %v", err) } - if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Hint, "auth login --scope") { - t.Fatalf("missing scope hint not found in detail: %#v", exitErr.Detail) + if !strings.Contains(permErr.Hint, "auth login --scope") { + t.Fatalf("missing scope hint not found: %q", permErr.Hint) } - if !strings.Contains(err.Error(), "drive:file:download") { - t.Fatalf("error should mention drive:file:download: %v", err) + foundScope := false + for _, s := range permErr.MissingScopes { + if s == "drive:file:download" { + foundScope = true + break + } + } + if !foundScope { + t.Fatalf("MissingScopes must include drive:file:download, got %v", permErr.MissingScopes) } } @@ -814,12 +822,15 @@ func TestWalkLocalForStatusMissingRootReturnsInternalError(t *testing.T) { if err == nil { t.Fatal("expected walkLocalForStatus() to fail for missing root") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected structured ExitError, got %T", err) + var internalErr *errs.InternalError + if !errors.As(err, &internalErr) { + t.Fatalf("expected *errs.InternalError, got %T", err) } - if exitErr.Detail == nil || exitErr.Detail.Type != "io" { - t.Fatalf("expected io error detail, got %#v", exitErr.Detail) + if internalErr.Subtype != errs.SubtypeFileIO { + t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO) + } + if code := output.ExitCodeOf(err); code != output.ExitInternal { + t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal) } if !strings.Contains(err.Error(), "walk") { t.Fatalf("expected walk-related error, got: %v", err) diff --git a/shortcuts/drive/drive_sync.go b/shortcuts/drive/drive_sync.go index 3c512cecf..ecb97e52d 100644 --- a/shortcuts/drive/drive_sync.go +++ b/shortcuts/drive/drive_sync.go @@ -13,7 +13,7 @@ import ( "path/filepath" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -72,23 +72,23 @@ var DriveSync = common.Shortcut{ localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) if localDir == "" { - return common.FlagErrorf("--local-dir is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir") } if folderToken == "" { - return common.FlagErrorf("--folder-token is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token") } if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir") } info, err := runtime.FileIO().Stat(localDir) if err != nil { - return common.WrapInputStatError(err) + return driveInputStatError(err) } if !info.IsDir() { - return output.ErrValidation("--local-dir is not a directory: %s", localDir) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir") } return nil }, @@ -118,15 +118,15 @@ var DriveSync = common.Shortcut{ safeRoot, err := validate.SafeInputPath(localDir) if err != nil { - return output.ErrValidation("--local-dir: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir") } cwdCanonical, err := validate.SafeInputPath(".") if err != nil { - return output.ErrValidation("could not resolve cwd: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err) } rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot) if err != nil { - return output.ErrValidation("--local-dir resolves outside cwd: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir resolves outside cwd: %s", err).WithParam("--local-dir") } // --- Phase 1: Compute diff (same logic as +status) --- @@ -176,18 +176,18 @@ var DriveSync = common.Shortcut{ } } if len(typeConflicts) > 0 { - return output.ErrValidation("+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; ")) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; ")) } // Build the exact remote-file views that later execution will use so the // diff phase classifies files against the same duplicate-resolution choice. pullRemoteFiles, _, err := drivePullRemoteViews(entries, duplicateRemote) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%s", err) + return errs.WrapInternal(err) } remoteEntriesForPush, remoteFolders, _, err := drivePushRemoteViews(entries, duplicateRemote) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%s", err) + return errs.WrapInternal(err) } remoteFiles := driveSyncStatusRemoteFiles(pullRemoteFiles) @@ -240,43 +240,19 @@ var DriveSync = common.Shortcut{ conflictResolutions := make(map[string]string, len(modified)) if onConflict == driveSyncOnConflictAsk && len(modified) > 0 && runtime.IO().In == nil { - return output.ErrValidation("--on-conflict=ask requires interactive stdin when modified files exist") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--on-conflict=ask requires interactive stdin when modified files exist").WithParam("--on-conflict") } for _, entry := range modified { resolved := onConflict if resolved == driveSyncOnConflictAsk { resolved, err = driveSyncAskConflict(entry.RelPath, runtime) if err != nil { - payload := map[string]interface{}{ - "detection": detection, - "diff": map[string]interface{}{ - "new_local": emptyIfNil(newLocal), - "new_remote": emptyIfNil(newRemote), - "modified": emptyIfNil(modified), - "unchanged": emptyIfNil(unchanged), - }, - "summary": map[string]interface{}{ - "pulled": 0, - "pushed": 0, - "skipped": 0, - "failed": 1, - }, - "items": []driveSyncItem{{ - RelPath: entry.RelPath, - FileToken: entry.FileToken, - Action: "failed", - Direction: "conflict", - Error: err.Error(), - }}, - } - return &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "partial_failure", - Message: fmt.Sprintf("cannot collect conflict decisions for +sync: %v", err), - Detail: payload, - }, - } + // Phase-1 setup abort: no sync operation ran yet, so this + // is not a batch partial-failure. driveSyncAskConflict + // already returns a typed *errs.ValidationError; propagate + // it unchanged rather than re-wrapping it as a synthetic + // partial_failure payload. + return err } } conflictResolutions[entry.RelPath] = resolved @@ -521,17 +497,12 @@ var DriveSync = common.Shortcut{ } if failed > 0 { - msg := fmt.Sprintf("%d item(s) failed during +sync", failed) - return &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "partial_failure", - Message: msg, - Detail: payload, - }, - } + payload["note"] = fmt.Sprintf("%d item(s) failed during +sync", failed) } + if failed > 0 { + return runtime.OutPartialFailure(payload, nil) + } runtime.Out(payload, nil) return nil }, @@ -555,7 +526,7 @@ func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (string, error) { fmt.Fprintf(runtime.IO().ErrOut, "CONFLICT: both sides modified %q. Choose: [R]emote-wins / [L]ocal-wins / [K]eep-both / [S]kip (default: R): ", relPath) if runtime.IO().In == nil { - return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath).WithParam("--on-conflict") } reader, ok := runtime.IO().In.(*bufio.Reader) if !ok { @@ -564,12 +535,12 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin } line, err := reader.ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { - return "", output.ErrValidation("cannot read conflict choice for %q: %s", relPath, err) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read conflict choice for %q: %s", relPath, err).WithParam("--on-conflict") } answer := strings.TrimSpace(strings.ToLower(line)) if answer == "" { if errors.Is(err, io.EOF) { - return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath).WithParam("--on-conflict") } return driveSyncOnConflictRemoteWins, nil } @@ -583,7 +554,7 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin case "r", "remote", "remote-wins": return driveSyncOnConflictRemoteWins, nil default: - return "", output.ErrValidation("invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line)) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line)).WithParam("--on-conflict") } } @@ -635,16 +606,16 @@ func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderC func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error { if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated. if info.IsDir() { - return output.Errorf(output.ExitInternal, "rollback", "original path became a directory during rollback: %s", oldAbsPath) + return errs.NewInternalError(errs.SubtypeFileIO, "original path became a directory during rollback: %s", oldAbsPath) } if err := os.Remove(oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated. - return output.Errorf(output.ExitInternal, "rollback", "remove partial restored path %q: %s", oldAbsPath, err) + return errs.NewInternalError(errs.SubtypeFileIO, "remove partial restored path %q: %s", oldAbsPath, err).WithCause(err) } } else if !os.IsNotExist(err) { - return output.Errorf(output.ExitInternal, "rollback", "stat original path %q during rollback: %s", oldAbsPath, err) + return errs.NewInternalError(errs.SubtypeFileIO, "stat original path %q during rollback: %s", oldAbsPath, err).WithCause(err) } if err := os.Rename(newAbsPath, oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated. - return output.Errorf(output.ExitInternal, "rollback", "restore renamed local file %q: %s", oldAbsPath, err) + return errs.NewInternalError(errs.SubtypeFileIO, "restore renamed local file %q: %s", oldAbsPath, err).WithCause(err) } return nil } diff --git a/shortcuts/drive/drive_sync_test.go b/shortcuts/drive/drive_sync_test.go index 7364397eb..1e1ea5afc 100644 --- a/shortcuts/drive/drive_sync_test.go +++ b/shortcuts/drive/drive_sync_test.go @@ -18,6 +18,7 @@ import ( "testing" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -1434,14 +1435,15 @@ func TestDriveSyncAskConflictEOFDuringExecuteReportsFailedItem(t *testing.T) { if err == nil { t.Fatalf("expected EOF failure during ask execution\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) + // Collecting conflict decisions runs in the Phase-1 setup pass, before + // any sync operation executes, so the EOF abort propagates the typed + // *errs.ValidationError unchanged rather than a synthetic partial_failure. + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) - if len(items) == 0 || !strings.Contains(items[0].Error, "stdin reached EOF") { - t.Fatalf("expected failed ask item, got detail: %#v", exitErr.Detail.Detail) + if !strings.Contains(validationErr.Error(), "stdin reached EOF") { + t.Fatalf("expected EOF failure, got: %v", validationErr) } data, readErr := os.ReadFile("local/a.txt") if readErr != nil { @@ -1503,12 +1505,15 @@ func TestDriveSyncAskConflictEOFDuringPlanningPreventsAnyWrites(t *testing.T) { if err == nil { t.Fatalf("expected EOF failure during ask planning\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) } - if exitErr.Detail.Type != "partial_failure" || !strings.Contains(exitErr.Error(), "stdin reached EOF") { - t.Fatalf("expected planning failure detail mentioning EOF, got: %#v", exitErr.Detail) + if validationErr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument) + } + if !strings.Contains(validationErr.Error(), "stdin reached EOF") { + t.Fatalf("expected planning failure mentioning EOF, got: %v", validationErr) } if data, readErr := os.ReadFile("local/a.txt"); readErr != nil || string(data) != "local-a" { t.Fatalf("a.txt should remain untouched, readErr=%v content=%q", readErr, string(data)) @@ -1706,14 +1711,10 @@ func TestDriveSyncReportsNewRemoteDownloadFailure(t *testing.T) { if err == nil { t.Fatalf("expected download failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || items[0].Direction != "pull" || !strings.Contains(items[0].Error, "save failed") { - t.Fatalf("expected failed pull item, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected failed pull item, got detail: %#v", stdout.String()) } } @@ -1758,14 +1759,10 @@ func TestDriveSyncReportsNewLocalEnsureFailure(t *testing.T) { if err == nil { t.Fatalf("expected ensure failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "create parent failed") { - t.Fatalf("expected failed push item, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected failed push item, got detail: %#v", stdout.String()) } } @@ -1810,14 +1807,10 @@ func TestDriveSyncReportsNewLocalUploadFailure(t *testing.T) { if err == nil { t.Fatalf("expected upload failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "upload failed") { - t.Fatalf("expected failed upload item, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected failed upload item, got detail: %#v", stdout.String()) } } @@ -1875,14 +1868,10 @@ func TestDriveSyncLocalWinsReportsUploadFailure(t *testing.T) { if err == nil { t.Fatalf("expected local-wins upload failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "overwrite failed") { - t.Fatalf("expected failed overwrite item, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected failed overwrite item, got detail: %#v", stdout.String()) } } @@ -1965,30 +1954,13 @@ func TestDriveSyncKeepBothReportsRenameFailure(t *testing.T) { if err == nil { t.Fatalf("expected keep-both suffix exhaustion error\nstdout: %s", stdout.String()) } - // The error may be a plain ExitError (no Detail.Detail) or a - // partial_failure with items. Either way it must mention the - // suffix exhaustion. - errMsg := err.Error() - // The suffix exhaustion message may be in the top-level error or - // inside a partial_failure detail item. Check both. - foundSuffixError := strings.Contains(errMsg, "could not generate a unique rel_path") - if !foundSuffixError { - var exitErr *output.ExitError - if errors.As(err, &exitErr) && exitErr.Detail != nil { - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) - for _, item := range items { - if strings.Contains(item.Error, "could not generate a unique rel_path") { - foundSuffixError = true - break - } - } - if !foundSuffixError { - t.Fatalf("expected suffix exhaustion error, got: %s; detail: %#v", errMsg, exitErr.Detail.Detail) - } - } else { - t.Fatalf("expected suffix exhaustion error, got: %s", errMsg) - } + // The suffix-exhaustion failure is an item-level conflict failure, so + // it surfaces as the partial-failure signal: a typed PartialFailureError + // on the error channel and the ok:false items[] payload (carrying the + // suffix message) on stdout via OutPartialFailure. + assertDriveSyncPartialFailure(t, err) + if !strings.Contains(stdout.String(), "could not generate a unique rel_path") { + t.Fatalf("expected suffix exhaustion error in stdout items, got: %s", stdout.String()) } } @@ -2341,14 +2313,10 @@ func TestDriveSyncRemoteWinsReportsModifiedPullFailure(t *testing.T) { if err == nil { t.Fatalf("expected modified pull failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || items[0].Direction != "pull" || !strings.Contains(items[0].Error, "save failed") { - t.Fatalf("expected failed modified pull item, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected failed modified pull item, got detail: %#v", stdout.String()) } } @@ -2411,14 +2379,10 @@ func TestDriveSyncKeepBothReportsRollbackFailureAfterPullError(t *testing.T) { if err == nil { t.Fatalf("expected keep-both rollback failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || !strings.Contains(items[0].Error, "rollback failed") { - t.Fatalf("expected rollback failure in item error, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected rollback failure in item error, got detail: %#v", stdout.String()) } } @@ -2500,14 +2464,10 @@ func TestDriveSyncLocalWinsNestedFileReportsParentEnsureFailure(t *testing.T) { if err == nil { t.Fatalf("expected parent ensure failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || !strings.Contains(items[0].Error, "create parent failed") { - t.Fatalf("expected failed item with create_folder error, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected failed item with create_folder error, got detail: %#v", stdout.String()) } } @@ -2704,7 +2664,7 @@ func TestDriveSyncKeepBothReportsSuffixError(t *testing.T) { // TestDriveSyncKeepBothRollbackSucceedsOnPullFailure verifies the full // keep-both rollback path: when the pull download fails after the local // file has been renamed, the rollback restores the original file and -// the error is reported as a partial_failure. +// the failure is reported via the partial-failure signal. func TestDriveSyncKeepBothRollbackSucceedsOnPullFailure(t *testing.T) { syncTestConfig := &core.CliConfig{ AppID: "drive-sync-keep-both-rollback-pull-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, @@ -2762,14 +2722,10 @@ func TestDriveSyncKeepBothRollbackSucceedsOnPullFailure(t *testing.T) { if err == nil { t.Fatalf("expected keep-both pull failure with rollback\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || !strings.Contains(items[0].Error, "save failed") { - t.Fatalf("expected save failure in item, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected save failure in item, got detail: %#v", stdout.String()) } // Rollback should have restored the original file. @@ -2978,14 +2934,10 @@ func TestDriveSyncLocalWinsUsesReturnedTokenOnUploadFailure(t *testing.T) { if err == nil { t.Fatalf("expected local-wins upload failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 { - t.Fatalf("expected failed item, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected failed item, got detail: %#v", stdout.String()) } // The reported token should be the new one from the partial-success // response, not the stale existingToken ("tok_a"). @@ -3095,3 +3047,39 @@ func TestDriveSyncRejectsLocalDirVsRemoteFileTypeConflict(t *testing.T) { t.Fatalf("error should mention local directory, got: %v", err) } } + +// assertDriveSyncPartialFailure asserts that err is the typed partial-failure +// exit signal +sync returns on any item-level failure. The structured +// {detection, diff, summary, items, note} payload rides on stdout as an +// ok:false envelope via runtime.OutPartialFailure (in alignment with +// +push/+pull), so this helper only checks the exit-code signal; callers read +// the payload from stdout. +func assertDriveSyncPartialFailure(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected partial-failure exit signal, got nil") + } + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) + } + if pfErr.Code != output.ExitAPI { + t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI) + } +} + +// driveSyncStdoutItems extracts the items[] payload from the stdout envelope +// written by runtime.Out. The per-item failure context that used to live in +// the partial_failure ExitError detail now rides on stdout. +func driveSyncStdoutItems(t *testing.T, stdout []byte) []driveSyncItem { + t.Helper() + var envelope struct { + Data struct { + Items []driveSyncItem `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout, &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout)) + } + return envelope.Data.Items +} diff --git a/shortcuts/drive/drive_task_result.go b/shortcuts/drive/drive_task_result.go index d506e1b17..1bdb74cdf 100644 --- a/shortcuts/drive/drive_task_result.go +++ b/shortcuts/drive/drive_task_result.go @@ -9,8 +9,8 @@ import ( "fmt" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/credential" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -43,34 +43,34 @@ var DriveTaskResult = common.Shortcut{ "wiki_delete_node": true, } if !validScenarios[scenario] { - return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario).WithParam("--scenario") } // Validate required params based on scenario switch scenario { case "import", "export": if runtime.Str("ticket") == "" { - return output.ErrValidation("--ticket is required for %s scenario", scenario) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--ticket is required for %s scenario", scenario).WithParam("--ticket") } if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--ticket") } case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node": if runtime.Str("task-id") == "" { - return output.ErrValidation("--task-id is required for %s scenario", scenario) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required for %s scenario", scenario).WithParam("--task-id") } if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id") } } // For export scenario, file-token is required if scenario == "export" && runtime.Str("file-token") == "" { - return output.ErrValidation("--file-token is required for export scenario") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token is required for export scenario").WithParam("--file-token") } if scenario == "export" { if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } } @@ -261,9 +261,10 @@ func requireDriveScopes(storedScopes string, required []string) error { return nil } - return output.ErrWithHint(output.ExitAuth, "missing_scope", - fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))) + return errs.NewPermissionError(errs.SubtypeMissingScope, + "missing required scope(s): %s", strings.Join(missing, ", ")). + WithMissingScopes(missing...). + WithHint("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")) } func missingDriveScopes(storedScopes string, required []string) []string { @@ -408,10 +409,10 @@ func queryWikiMoveTask(runtime *common.RuntimeContext, taskID string) (map[strin func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiMoveTaskQueryStatus, error) { if err := validate.ResourceName(taskID, "--task-id"); err != nil { - return wikiMoveTaskQueryStatus{}, output.ErrValidation("%s", err) + return wikiMoveTaskQueryStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id") } - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)), map[string]interface{}{"task_type": "move"}, @@ -426,7 +427,7 @@ func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiM func parseWikiMoveTaskQueryStatus(taskID string, task map[string]interface{}) (wikiMoveTaskQueryStatus, error) { if task == nil { - return wikiMoveTaskQueryStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task") + return wikiMoveTaskQueryStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task") } status := wikiMoveTaskQueryStatus{ @@ -490,10 +491,10 @@ func appendWikiMoveNodeFields(out, node map[string]interface{}) { // rather than the per-node array used by wiki move. func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) { if err := validate.ResourceName(taskID, "--task-id"); err != nil { - return nil, output.ErrValidation("%s", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id") } - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)), map[string]interface{}{"task_type": "delete_space"}, @@ -505,7 +506,7 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma task := common.GetMap(data, "task") if task == nil { - return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task") + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task") } resolvedTaskID := common.GetString(task, "task_id") @@ -558,10 +559,10 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma // keep drive from depending on shortcuts/wiki. func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) { if err := validate.ResourceName(taskID, "--task-id"); err != nil { - return nil, output.ErrValidation("%s", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id") } - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)), map[string]interface{}{"task_type": "delete_node"}, @@ -573,7 +574,7 @@ func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map task := common.GetMap(data, "task") if task == nil { - return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task") + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task") } resolvedTaskID := common.GetString(task, "task_id") diff --git a/shortcuts/drive/drive_task_result_test.go b/shortcuts/drive/drive_task_result_test.go index 79e43d76d..b97fc0ef8 100644 --- a/shortcuts/drive/drive_task_result_test.go +++ b/shortcuts/drive/drive_task_result_test.go @@ -13,10 +13,12 @@ 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/credential" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -86,6 +88,16 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) } + var vErr *errs.ValidationError + if !errors.As(err, &vErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if vErr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument) + } + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation) + } }) } } @@ -428,6 +440,16 @@ func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T if err == nil || !strings.Contains(err.Error(), "missing required scope(s): wiki:space:read") { t.Fatalf("expected missing wiki scope error, got %v", err) } + var permErr *errs.PermissionError + if !errors.As(err, &permErr) { + t.Fatalf("expected *errs.PermissionError, got %T", err) + } + if permErr.Subtype != errs.SubtypeMissingScope { + t.Fatalf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope) + } + if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != "wiki:space:read" { + t.Fatalf("MissingScopes = %v, want [wiki:space:read]", permErr.MissingScopes) + } }) t.Run(scenario+"/accepts wiki scope", func(t *testing.T) { t.Parallel() @@ -663,6 +685,19 @@ func TestParseWikiMoveTaskQueryStatusRejectsMissingTask(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "missing task") { t.Fatalf("expected missing task error, got %v", err) } + // A successful API call (code==0) that omits the `task` field is a + // malformed RESPONSE, not a user error: classify as internal / + // invalid_response (exit 5), not an API business error (exit 1). + var iErr *errs.InternalError + if !errors.As(err, &iErr) { + t.Fatalf("expected *errs.InternalError, got %T", err) + } + if iErr.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Subtype = %q, want %q", iErr.Subtype, errs.SubtypeInvalidResponse) + } + if got := output.ExitCodeOf(err); got != output.ExitInternal { + t.Fatalf("exit code = %d, want ExitInternal (%d)", got, output.ExitInternal) + } } func TestWikiMoveTaskQueryStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) { diff --git a/shortcuts/drive/drive_upload.go b/shortcuts/drive/drive_upload.go index c2688208f..f74220bc5 100644 --- a/shortcuts/drive/drive_upload.go +++ b/shortcuts/drive/drive_upload.go @@ -5,7 +5,6 @@ package drive import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -15,6 +14,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -151,7 +151,7 @@ var DriveUpload = common.Shortcut{ info, err := runtime.FileIO().Stat(spec.FilePath) if err != nil { - return common.WrapInputStatError(err) + return driveInputStatError(err) } fileSize := info.Size() @@ -194,13 +194,13 @@ var DriveUpload = common.Shortcut{ func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpec) error { if driveUploadFlagExplicitlyEmpty(runtime, "file-token") { - return common.FlagErrorf("--file-token cannot be empty; omit --file-token for a new upload or pass an existing file token to overwrite") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token cannot be empty; omit --file-token for a new upload or pass an existing file token to overwrite").WithParam("--file-token") } if driveUploadFlagExplicitlyEmpty(runtime, "folder-token") { - return common.FlagErrorf("--folder-token cannot be empty; omit --folder-token to upload into Drive root folder or pass a folder token") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token cannot be empty; omit --folder-token to upload into Drive root folder or pass a folder token").WithParam("--folder-token") } if driveUploadFlagExplicitlyEmpty(runtime, "wiki-token") { - return common.FlagErrorf("--wiki-token cannot be empty; omit --wiki-token to upload into Drive root folder or pass a wiki node token") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--wiki-token cannot be empty; omit --wiki-token to upload into Drive root folder or pass a wiki node token").WithParam("--wiki-token") } targets := 0 @@ -211,21 +211,21 @@ func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpe targets++ } if targets > 1 { - return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token and --wiki-token are mutually exclusive") } if spec.FolderToken != "" { if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } } if spec.WikiToken != "" { if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--wiki-token") } } if spec.FileToken != "" { if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } } return nil @@ -240,7 +240,7 @@ func driveUploadFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName str func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64, existingFileToken string) (driveUploadResult, error) { f, err := runtime.FileIO().Open(filePath) if err != nil { - return driveUploadResult{}, common.WrapInputStatError(err) + return driveUploadResult{}, driveInputStatError(err) } defer f.Close() @@ -265,23 +265,16 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file if errors.As(err, &exitErr) { return driveUploadResult{}, err } - return driveUploadResult{}, output.ErrNetwork("upload failed: %v", err) + return driveUploadResult{}, wrapDriveNetworkErr(err, "upload failed: %v", err) } - var result map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err) + data, err := runtime.ClassifyAPIResponse(apiResp) + if err != nil { + return driveUploadResult{}, err } - - if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 { - msg, _ := result["msg"].(string) - return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"]) - } - - data, _ := result["data"].(map[string]interface{}) fileToken := common.GetString(data, "file_token") if fileToken == "" { - return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") + return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned") } return driveUploadResult{ FileToken: fileToken, @@ -304,7 +297,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file if existingFileToken != "" { prepareBody["file_token"] = existingFileToken } - prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody) + prepareResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody) if err != nil { return driveUploadResult{}, err } @@ -316,7 +309,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file blockNum := int(blockNumF) if uploadID == "" || blockSize <= 0 || blockNum <= 0 { - return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", + return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d", uploadID, blockSize, blockNum) } @@ -334,7 +327,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file partFile, err := runtime.FileIO().Open(filePath) if err != nil { - return driveUploadResult{}, common.WrapInputStatError(err) + return driveUploadResult{}, driveInputStatError(err) } fd := larkcore.NewFormdata() @@ -354,16 +347,11 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file if errors.As(err, &exitErr) { return driveUploadResult{}, err } - return driveUploadResult{}, output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, err) + return driveUploadResult{}, wrapDriveNetworkErr(err, "upload part %d/%d failed: %v", seq+1, blockNum, err) } - var partResult map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil { - return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err) - } - if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 { - msg, _ := partResult["msg"].(string) - return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"]) + if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil { + return driveUploadResult{}, err } fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize)) @@ -374,14 +362,14 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file "upload_id": uploadID, "block_num": blockNum, } - finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody) + finishResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody) if err != nil { return driveUploadResult{}, err } fileToken := common.GetString(finishResult, "file_token") if fileToken == "" { - return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned") + return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned") } return driveUploadResult{ diff --git a/shortcuts/drive/drive_version.go b/shortcuts/drive/drive_version.go index bcf3fcf0c..e560dde49 100644 --- a/shortcuts/drive/drive_version.go +++ b/shortcuts/drive/drive_version.go @@ -16,8 +16,8 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -34,10 +34,10 @@ type driveVersionHistorySpec struct { func validateDriveNumericValue(value, flagName, valueLabel string) error { value = strings.TrimSpace(value) if value == "" { - return output.ErrValidation("%s cannot be empty", flagName) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be empty", flagName).WithParam(flagName) } if !driveVersionNumberRe.MatchString(value) { - return output.ErrValidation("%s must be a numeric %s", flagName, valueLabel) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must be a numeric %s", flagName, valueLabel).WithParam(flagName) } return nil } @@ -52,10 +52,10 @@ func validateDriveCursorValue(value, flagName string) error { func validateDriveVersionHistorySpec(spec driveVersionHistorySpec) error { if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } if spec.Limit < 1 || spec.Limit > 200 { - return output.ErrValidation("invalid --limit %d: must be between 1 and 200", spec.Limit) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --limit %d: must be between 1 and 200", spec.Limit).WithParam("--limit") } if spec.Cursor != "" { if err := validateDriveCursorValue(spec.Cursor, "--cursor"); err != nil { @@ -180,7 +180,7 @@ var DriveVersionHistory = common.Shortcut{ Cursor: strings.TrimSpace(runtime.Str("cursor")), } - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( http.MethodGet, fmt.Sprintf("/open-apis/drive/v1/files/%s/history", validate.EncodePathSegment(spec.FileToken)), driveVersionHistoryParams(spec), @@ -214,7 +214,7 @@ type driveVersionGetSpec struct { func validateDriveVersionGetSpec(runtime *common.RuntimeContext, spec driveVersionGetSpec) error { if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } if err := validateDriveVersionValue(spec.Version, "--version"); err != nil { return err @@ -223,7 +223,7 @@ func validateDriveVersionGetSpec(runtime *common.RuntimeContext, spec driveVersi return nil } if _, err := validate.SafeOutputPath(spec.Output); err != nil { - return output.ErrValidation("unsafe output path: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output") } return nil } @@ -299,7 +299,7 @@ var DriveVersionGet = common.Shortcut{ }, }) if err != nil { - return output.ErrNetwork("download failed: %s", err) + return wrapDriveNetworkErr(err, "download failed: %s", err) } defer resp.Body.Close() @@ -315,10 +315,10 @@ var DriveVersionGet = common.Shortcut{ outputPath, _ = common.AutoAppendDownloadExtension(outputPath, resp.Header, "") } if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil { - return output.ErrValidation("unsafe output path: %s", resolveErr) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output") } if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !spec.Overwrite { - return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output") } result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ @@ -326,7 +326,7 @@ var DriveVersionGet = common.Shortcut{ ContentLength: resp.ContentLength, }, resp.Body) if err != nil { - return common.WrapSaveErrorByCategory(err, "io") + return driveSaveError(err) } savedPath, _ := runtime.ResolveSavePath(outputPath) @@ -354,7 +354,7 @@ type driveVersionMutationSpec struct { func validateDriveVersionMutationSpec(spec driveVersionMutationSpec) error { if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } return validateDriveVersionValue(spec.Version, "--version") } @@ -392,7 +392,7 @@ var DriveVersionRevert = common.Shortcut{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Version: strings.TrimSpace(runtime.Str("version")), } - if _, err := runtime.CallAPI( + if _, err := runtime.CallAPITyped( http.MethodPost, fmt.Sprintf("/open-apis/drive/v1/files/%s/revert", validate.EncodePathSegment(spec.FileToken)), nil, @@ -439,7 +439,7 @@ var DriveVersionDelete = common.Shortcut{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Version: strings.TrimSpace(runtime.Str("version")), } - if _, err := runtime.CallAPI( + if _, err := runtime.CallAPITyped( http.MethodPost, fmt.Sprintf("/open-apis/drive/v1/files/%s/version_del", validate.EncodePathSegment(spec.FileToken)), nil, diff --git a/shortcuts/drive/drive_version_test.go b/shortcuts/drive/drive_version_test.go index 309243ad4..34d595766 100644 --- a/shortcuts/drive/drive_version_test.go +++ b/shortcuts/drive/drive_version_test.go @@ -5,14 +5,17 @@ package drive import ( "encoding/json" + "errors" "net/http" "os" "path/filepath" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -53,6 +56,16 @@ func TestValidateDriveVersionHistorySpec(t *testing.T) { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) } + var vErr *errs.ValidationError + if !errors.As(err, &vErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if vErr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument) + } + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation) + } }) } } @@ -255,6 +268,13 @@ func TestDriveVersionGetRejectsExistingFileWithoutOverwrite(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "output file already exists") { t.Fatalf("expected output exists error, got %v", err) } + var vErr *errs.ValidationError + if !errors.As(err, &vErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if vErr.Subtype != errs.SubtypeInvalidArgument || vErr.Param != "--output" { + t.Fatalf("typed shape = subtype %q param %q, want invalid_argument/--output", vErr.Subtype, vErr.Param) + } } func TestDriveVersionGetOverwritesExistingFileWhenRequested(t *testing.T) { diff --git a/shortcuts/drive/list_remote.go b/shortcuts/drive/list_remote.go index 5f773b02c..2eee7c62c 100644 --- a/shortcuts/drive/list_remote.go +++ b/shortcuts/drive/list_remote.go @@ -11,9 +11,10 @@ import ( "path" "sort" "strconv" + "strings" "time" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -85,7 +86,7 @@ func listRemoteFolderEntries(ctx context.Context, runtime *common.RuntimeContext if pageToken != "" { params["page_token"] = pageToken } - result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil) + result, err := runtime.CallAPITyped("GET", "/open-apis/drive/v1/files", params, nil) if err != nil { return nil, err } @@ -176,24 +177,27 @@ func duplicateRemoteFilePaths(entries []driveRemoteEntry) []driveDuplicateRemote return duplicates } -// Deprecated: duplicateRemotePathError produces a legacy *output.ExitError -// that predates the typed error contract introduced by errs/. New code MUST -// NOT use it — duplicate-path signals should move to a typed -// *errs.ValidationError (with duplicates metadata as a typed extension -// field) when the drive shortcut migrates to typed errors. This helper is -// retained only while existing call sites are migrated; it will be removed -// once they have moved to the typed surface. -func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) *output.ExitError { - return &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "duplicate_remote_path", - Message: "multiple Drive entries map to the same rel_path", - Detail: map[string]interface{}{ - "duplicates_remote": duplicates, - }, - }, +// duplicateRemotePathError reports that multiple Drive entries resolve to the +// same rel_path. Each colliding rel_path becomes one InvalidParam whose Name is +// the rel_path and whose Reason enumerates the colliding entries (type + +// file_token), so an AI agent reading the typed envelope can identify exactly +// which Drive objects collide without re-listing the folder. +func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) error { + params := make([]errs.InvalidParam, 0, len(duplicates)) + for _, d := range duplicates { + descriptions := make([]string, 0, len(d.Entries)) + for _, entry := range d.Entries { + descriptions = append(descriptions, fmt.Sprintf("%s %s", entry.Type, entry.FileToken)) + } + params = append(params, errs.InvalidParam{ + Name: d.RelPath, + Reason: fmt.Sprintf("%d Drive entries collide here: %s", len(d.Entries), strings.Join(descriptions, ", ")), + }) } + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "%d rel_path(s) map to multiple Drive entries", len(duplicates)). + WithHint("resolve the duplicate remote files first: re-run +pull with --on-duplicate-remote=rename (downloads each with a hashed suffix), or use --on-duplicate-remote=newest|oldest (supported by +pull/+sync/+push) to pick one, or delete the extra remote files; a plain retry will not help"). + WithParams(params...) } const ( @@ -300,7 +304,7 @@ func compareDriveRemoteModifiedToLocal(remoteModified string, local time.Time) ( func chooseRemoteFile(files []driveRemoteEntry, strategy string) (driveRemoteEntry, error) { if len(files) == 0 { - return driveRemoteEntry{}, fmt.Errorf("no Drive entries available for strategy %q", strategy) + return driveRemoteEntry{}, errs.NewInternalError(errs.SubtypeUnknown, "no Drive entries available for strategy %q", strategy) } candidates := append([]driveRemoteEntry(nil), files...) sortRemoteFiles(candidates, strategy) @@ -385,7 +389,7 @@ func relPathWithUniqueFileTokenSuffix(relPath, fileToken string, occupied map[st return candidate, nil } } - return "", fmt.Errorf("could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq) + return "", errs.NewInternalError(errs.SubtypeUnknown, "could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq) } // joinRelDrive joins a rel_path base with an entry name using "/". diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index dcf231e7c..91df7cc55 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -28,6 +28,8 @@ func Shortcuts() []common.Shortcut { DriveSync, DriveTaskResult, DriveApplyPermission, + DriveSecureLabelList, + DriveSecureLabelUpdate, DriveSearch, DriveInspect, } diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 3707fc096..6f170ce3e 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -31,6 +31,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+sync", "+task_result", "+apply-permission", + "+secure-label-list", + "+secure-label-update", "+search", "+inspect", } diff --git a/shortcuts/im/builders_test.go b/shortcuts/im/builders_test.go index ca78fc3d1..c32bd6a32 100644 --- a/shortcuts/im/builders_test.go +++ b/shortcuts/im/builders_test.go @@ -729,6 +729,18 @@ func TestShortcutDryRunShapes(t *testing.T) { } }) + t.Run("ImMessagesSend dry run warns chat membership is not verified", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "text": "hello", + }, nil) + got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime)) + if !strings.Contains(got, "Bot/user membership in the target chat is not verified") || + !strings.Contains(got, "Bot/User can NOT be out of the chat") { + t.Fatalf("ImMessagesSend.DryRun() missing membership warning: %s", got) + } + }) + t.Run("ImMessagesSend dry run uses placeholder media key for url input", func(t *testing.T) { runtime := newTestRuntimeContext(t, map[string]string{ "chat-id": "oc_123", @@ -742,6 +754,19 @@ func TestShortcutDryRunShapes(t *testing.T) { } }) + t.Run("ImMessagesSend dry run preserves media and membership descriptions", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "image": "https://example.com/a.png", + }, nil) + mediaDesc := `"description":"dry-run uses placeholder media keys for --image URL input; execution uploads it before sending"` + membershipDesc := `"desc":"NOTE: dry-run validates request shape only. Bot/user membership in the target chat is not verified; the real send may fail with ` + "`Bot/User can NOT be out of the chat`" + `."` + got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime)) + if !strings.Contains(got, mediaDesc) || !strings.Contains(got, membershipDesc) { + t.Fatalf("ImMessagesSend.DryRun() should preserve both descriptions: %s", got) + } + }) + t.Run("ImMessagesMGet dry run expands message ids", func(t *testing.T) { runtime := newTestRuntimeContext(t, map[string]string{ "message-ids": "om_1,om_2", diff --git a/shortcuts/im/convert_lib/card.go b/shortcuts/im/convert_lib/card.go index 10c2c8577..a418565b5 100644 --- a/shortcuts/im/convert_lib/card.go +++ b/shortcuts/im/convert_lib/card.go @@ -50,11 +50,12 @@ var cardChartTypeNames = map[string]string{ type interactiveConverter struct{} func (interactiveConverter) Convert(ctx *ConvertContext) string { - return convertCard(ctx.RawContent) + return convertCard(ctx.RawContent, ctx.Mentions) } // convertCard converts a raw interactive/card message content JSON to human-readable string. -func convertCard(raw string) string { +// mentions is the raw mentions array from the API response; pass nil when not available. +func convertCard(raw string, mentions []interface{}) string { var parsed cardObj if err := json.Unmarshal([]byte(raw), &parsed); err != nil { return "[interactive card]" @@ -63,11 +64,19 @@ func convertCard(raw string) string { // raw_card_content format: outer JSON has "json_card" string field if jsonCard, ok := parsed["json_card"].(string); ok { c := &cardConverter{mode: cardModeConcise} - if att, ok := parsed["json_attachment"].(string); ok && att != "" { - var attObj cardObj - if json.Unmarshal([]byte(att), &attObj) == nil { - c.attachment = attObj + switch att := parsed["json_attachment"].(type) { + case string: + if att != "" { + var attObj cardObj + if json.Unmarshal([]byte(att), &attObj) == nil { + c.attachment = attObj + } } + case cardObj: + c.attachment = att + } + if len(mentions) > 0 { + c.mentionsByKey = buildMentionsByKey(mentions) } schema := 0 if s, ok := parsed["card_schema"].(float64); ok { @@ -84,6 +93,22 @@ func convertCard(raw string) string { return convertLegacyCard(parsed) } +// buildMentionsByKey indexes the mentions array by key for O(1) lookup in convertAt. +func buildMentionsByKey(mentions []interface{}) map[string]map[string]interface{} { + m := make(map[string]map[string]interface{}, len(mentions)) + for _, raw := range mentions { + item, ok := raw.(map[string]interface{}) + if !ok { + continue + } + key, _ := item["key"].(string) + if key != "" { + m[key] = item + } + } + return m +} + // ── Legacy converter ────────────────────────────────────────────────────────── func convertLegacyCard(parsed cardObj) string { @@ -158,8 +183,9 @@ func legacyExtractTexts(elements []interface{}, out *[]string) { // ── CardConverter ───────────────────────────────────────────────────────────── type cardConverter struct { - mode cardMode - attachment cardObj + mode cardMode + attachment cardObj + mentionsByKey map[string]map[string]interface{} } func (c *cardConverter) convert(jsonCard string, hintSchema int) string { @@ -1403,26 +1429,52 @@ func (c *cardConverter) convertAt(prop cardObj) string { } userName := "" actualUserID := "" + fromMentions := false if c.attachment != nil { if atUsers, ok := c.attachment["at_users"].(cardObj); ok { if userInfo, ok := atUsers[userID].(cardObj); ok { userName, _ = userInfo["content"].(string) actualUserID, _ = userInfo["user_id"].(string) + // When the backend populates mention_key (raw_card_content path), use + // mentions[] for the canonical name and the reading-app open_id, which is + // more accurate than the origKey-stored user_id in at_users. + if mentionKey, _ := userInfo["mention_key"].(string); mentionKey != "" { + if mention, ok := c.mentionsByKey[mentionKey]; ok { + if name, _ := mention["name"].(string); name != "" { + userName = name + } + if id := extractMentionOpenId(mention["id"]); id != "" { + actualUserID = id + fromMentions = true + } + } + } } } } if userName != "" { if c.mode == cardModeDetailed { if actualUserID != "" { - return fmt.Sprintf("@%s(user_id:%s)", userName, actualUserID) + label := "user_id" + if fromMentions { + label = "open_id" + } + return fmt.Sprintf("@%s(%s:%s)", userName, label, actualUserID) } return fmt.Sprintf("@%s(open_id:%s)", userName, userID) } - return "@" + userName + if fromMentions && actualUserID != "" { + return fmt.Sprintf("@%s(%s)", userName, actualUserID) + } + return fmt.Sprintf("@%s(%s)", userName, userID) } if c.mode == cardModeDetailed { if actualUserID != "" { - return fmt.Sprintf("@user(user_id:%s)", actualUserID) + label := "user_id" + if fromMentions { + label = "open_id" + } + return fmt.Sprintf("@user(%s:%s)", label, actualUserID) } return fmt.Sprintf("@user(open_id:%s)", userID) } diff --git a/shortcuts/im/convert_lib/card_test.go b/shortcuts/im/convert_lib/card_test.go index bb011f987..49f63644b 100644 --- a/shortcuts/im/convert_lib/card_test.go +++ b/shortcuts/im/convert_lib/card_test.go @@ -27,14 +27,14 @@ func newTestCardConverter(mode cardMode) *cardConverter { func TestConvertCard(t *testing.T) { rawCard := `{"json_card":"{\"schema\":1,\"header\":{\"title\":{\"content\":\"Card Title\"}},\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"hello\"}},{\"tag\":\"button\",\"property\":{\"text\":{\"content\":\"Open\"},\"actions\":[{\"type\":\"open_url\",\"action\":{\"url\":\"https://example.com\"}}]}}]}}","json_attachment":"{\"persons\":{\"ou_1\":{\"content\":\"Alice\"}}}"}` - got := convertCard(rawCard) + got := convertCard(rawCard, nil) want := "\nhello\n[Open](https://example.com)\n" if got != want { t.Fatalf("convertCard(json_card) = %q, want %q", got, want) } legacy := `{"header":{"title":{"content":"Legacy Card"}},"elements":[{"tag":"div","text":{"content":"legacy body"}}]}` - gotLegacy := convertCard(legacy) + gotLegacy := convertCard(legacy, nil) wantLegacy := "**Legacy Card**\nlegacy body" if gotLegacy != wantLegacy { t.Fatalf("convertCard(legacy) = %q, want %q", gotLegacy, wantLegacy) @@ -243,6 +243,75 @@ func TestCardConverterMethods(t *testing.T) { } } +func TestConvertAtWithMentions(t *testing.T) { + mentions := []interface{}{ + map[string]interface{}{ + "key": "@_user_1", + "id": "ou_6b64bef911a5a3ea763df8ffd9258f59", + "name": "燕忠毅", + }, + } + attachment := cardObj{ + "at_users": cardObj{ + "cde8a6c8": cardObj{ + "user_id": "754700000001", + "content": "燕忠毅", + "mention_key": "@_user_1", + }, + }, + } + + // Concise mode: should show @Name(open_id) when mention resolves. + concise := &cardConverter{ + mode: cardModeConcise, + attachment: attachment, + mentionsByKey: buildMentionsByKey(mentions), + } + if got := concise.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(ou_6b64bef911a5a3ea763df8ffd9258f59)" { + t.Fatalf("convertAt(concise with mentions) = %q", got) + } + + // Detailed mode: label should be open_id when resolved from mentions. + detailed := &cardConverter{ + mode: cardModeDetailed, + attachment: attachment, + mentionsByKey: buildMentionsByKey(mentions), + } + if got := detailed.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(open_id:ou_6b64bef911a5a3ea763df8ffd9258f59)" { + t.Fatalf("convertAt(detailed with mentions) = %q", got) + } + + // No mention_key: falls back to at_users.user_id with user_id label (existing behavior). + noMentionKey := &cardConverter{ + mode: cardModeDetailed, + attachment: cardObj{ + "at_users": cardObj{ + "ou_at": cardObj{"content": "Bob", "user_id": "u_bob"}, + }, + }, + } + if got := noMentionKey.convertAt(cardObj{"userID": "ou_at"}); got != "@Bob(user_id:u_bob)" { + t.Fatalf("convertAt(fallback no mention_key) = %q", got) + } + + // mention_key present but mentionsByKey nil: still falls back gracefully. + nilMentions := &cardConverter{ + mode: cardModeDetailed, + attachment: cardObj{ + "at_users": cardObj{ + "cde8a6c8": cardObj{ + "user_id": "754700000001", + "content": "燕忠毅", + "mention_key": "@_user_1", + }, + }, + }, + } + if got := nilMentions.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(user_id:754700000001)" { + t.Fatalf("convertAt(fallback nil mentionsByKey) = %q", got) + } +} + func TestCardConverterExtractTextHelpers(t *testing.T) { c := newTestCardConverter(cardModeDetailed) diff --git a/shortcuts/im/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go index 894df34d2..1ede9b79e 100644 --- a/shortcuts/im/convert_lib/content_convert.go +++ b/shortcuts/im/convert_lib/content_convert.go @@ -25,6 +25,9 @@ type ContentConverter interface { type ConvertContext struct { RawContent string MentionMap map[string]string + // Mentions is the raw mentions array from the API response. + // Used by interactive card converter to resolve @user references via mention_key. + Mentions []interface{} // MessageID and Runtime are used by merge_forward to fetch and expand sub-messages via API. // For other message types these can be zero values. MessageID string @@ -32,6 +35,14 @@ type ConvertContext struct { // SenderNames is a shared cache of open_id -> display name, accumulated across messages // to avoid redundant contact API calls. May be nil. SenderNames map[string]string + // MergeForwardSubItems is an optional pre-fetched cache of merge_forward + // sub-message lists, keyed by merge_forward message_id. When set, the + // merge_forward converter uses the cached entry instead of issuing its + // own GET; populated by callers via PrefetchMergeForwardSubItems before + // the FormatMessageItem loop. nil means "no prefetch — fall back to the + // per-message inline GET", which keeps non-shortcut callers (events, + // ad-hoc tests) working unchanged. + MergeForwardSubItems map[string][]map[string]interface{} } // converters maps message types to their ContentConverter implementations. @@ -85,6 +96,7 @@ func FormatEventMessage(msgType, rawContent, messageID string, mentions []interf content := ConvertBodyContent(msgType, &ConvertContext{ RawContent: rawContent, MentionMap: BuildMentionKeyMap(mentions), + Mentions: mentions, MessageID: messageID, }) @@ -119,6 +131,20 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, if len(senderNames) > 0 { nameCache = senderNames[0] } + return formatMessageItem(m, runtime, nameCache, nil) +} + +// FormatMessageItemWithMergePrefetch is like FormatMessageItem but threads a +// pre-fetched merge_forward sub-message map (typically built via +// PrefetchMergeForwardSubItems) through to the merge_forward converter so it +// can skip its own per-message GET. Shortcuts that iterate a page of raw +// items should pre-fetch once and call this variant in the loop to avoid the +// N × ~1s serial-merge_forward stall in the original code path. +func FormatMessageItemWithMergePrefetch(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) map[string]interface{} { + return formatMessageItem(m, runtime, nameCache, mergePrefetch) +} + +func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) map[string]interface{} { msgType, _ := m["msg_type"].(string) messageId, _ := m["message_id"].(string) mentions, _ := m["mentions"].([]interface{}) @@ -129,11 +155,13 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, if body, ok := m["body"].(map[string]interface{}); ok { rawContent, _ := body["content"].(string) content = ConvertBodyContent(msgType, &ConvertContext{ - RawContent: rawContent, - MentionMap: BuildMentionKeyMap(mentions), - MessageID: messageId, - Runtime: runtime, - SenderNames: nameCache, + RawContent: rawContent, + MentionMap: BuildMentionKeyMap(mentions), + Mentions: mentions, + MessageID: messageId, + Runtime: runtime, + SenderNames: nameCache, + MergeForwardSubItems: mergePrefetch, }) } @@ -155,6 +183,20 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, } // Preserve API-provided fields (even if this formatter doesn't otherwise use them). + // update_time is only meaningful when the message was actually edited; + // the server echoes update_time == create_time for unedited messages, which + // would otherwise make every output look "updated" to downstream consumers. + if updated { + if v, ok := m["update_time"]; ok && v != nil { + if s, isStr := v.(string); isStr { + if strings.TrimSpace(s) != "" { + msg["update_time"] = common.FormatTime(s) + } + } else { + msg["update_time"] = common.FormatTime(v) + } + } + } if v, ok := m["chat_id"]; ok { msg["chat_id"] = v } diff --git a/shortcuts/im/convert_lib/content_media_misc_test.go b/shortcuts/im/convert_lib/content_media_misc_test.go index 85f26216d..3d0dbdaea 100644 --- a/shortcuts/im/convert_lib/content_media_misc_test.go +++ b/shortcuts/im/convert_lib/content_media_misc_test.go @@ -95,6 +95,61 @@ func TestFormatMessageItem(t *testing.T) { } } +func TestFormatMessageItem_UpdateTime_Present(t *testing.T) { + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_edit", + "updated": true, + "create_time": "1710500000", + "update_time": "1710600000", + "sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"}, + "body": map[string]interface{}{"content": `{"text":"edited"}`}, + } + + got := FormatMessageItem(raw, nil) + want := common.FormatTime("1710600000") + if got["update_time"] != want { + t.Fatalf("FormatMessageItem() update_time = %#v, want %#v", got["update_time"], want) + } +} + +func TestFormatMessageItem_UpdateTime_Absent(t *testing.T) { + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_no_edit", + "updated": false, + "create_time": "1710500000", + "sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"}, + "body": map[string]interface{}{"content": `{"text":"hi"}`}, + } + + got := FormatMessageItem(raw, nil) + if _, ok := got["update_time"]; ok { + t.Fatalf("FormatMessageItem() should not include update_time when absent, got = %#v", got["update_time"]) + } +} + +// TestFormatMessageItem_UpdateTime_UnchangedMessage: real API behavior — even +// for unedited messages, server returns update_time == create_time. We must +// NOT echo it through, otherwise every message looks "edited" to consumers. +// Gate the output on updated==true. +func TestFormatMessageItem_UpdateTime_UnchangedMessage(t *testing.T) { + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_unchanged", + "updated": false, + "create_time": "1710500000", + "update_time": "1710500000", // server echoes create_time + "sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"}, + "body": map[string]interface{}{"content": `{"text":"hi"}`}, + } + + got := FormatMessageItem(raw, nil) + if v, ok := got["update_time"]; ok { + t.Fatalf("FormatMessageItem() must skip update_time for unedited message, got = %#v", v) + } +} + func TestResolveAppLinkDomain(t *testing.T) { if got := resolveAppLinkDomain(core.BrandFeishu); got != "applink.feishu.cn" { t.Fatalf("resolveAppLinkDomain(feishu) = %q", got) diff --git a/shortcuts/im/convert_lib/merge.go b/shortcuts/im/convert_lib/merge.go index cf118fb21..aece5f73b 100644 --- a/shortcuts/im/convert_lib/merge.go +++ b/shortcuts/im/convert_lib/merge.go @@ -4,11 +4,11 @@ package convertlib import ( - "encoding/json" "fmt" "net/http" "sort" "strings" + "sync" "time" "github.com/larksuite/cli/internal/validate" @@ -16,28 +16,53 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) +// mergeForwardPrefetchConcurrency caps in-flight merge_forward sub-message +// fetches when a shortcut pre-scans a page for merge_forward messages and +// prefetches their children concurrently. Each call is one ~700ms-1s +// GET /open-apis/im/v1/messages/{id} per merge_forward — strictly serial in +// FormatMessageItem before this change, which turned page-size 50 + 5 +// merge_forward messages into ~8.5s of stall (measured on a real chat). +// GET /open-apis/im/v1/messages/{id} has no published per-app rate-limit at +// these levels, so we set this higher than the reactions batch_query cap +// (which sits at 4 to stay well under the gateway-layer 50/s + 1000/min +// explicit ceiling on the reactions endpoint). +const mergeForwardPrefetchConcurrency = 8 + type mergeForwardConverter struct{} -// Convert expands merge_forward sub-messages into a tree when runtime is available, -// otherwise falls back to a summary string. +// Convert expands merge_forward sub-messages into a tree when runtime is +// available (or a pre-fetched cache was supplied), otherwise falls back to a +// summary string. +// +// When ctx.MergeForwardSubItems is non-nil (set by callers that pre-fetched +// the page's merge_forward children concurrently via +// PrefetchMergeForwardSubItems), Convert uses the cached items and skips the +// HTTP fetch entirely — this is how the shortcut layer turns N serial +// per-merge_forward GETs into one bounded-concurrency fan-out before the +// FormatMessageItem loop runs. func (mergeForwardConverter) Convert(ctx *ConvertContext) string { - // When runtime is available, fetch sub-messages via API and expand into a tree. - // merge_forward body.content is typically a plain-text placeholder (e.g. "Merged and Forwarded Message"), - // not JSON with create_message_ids, so we must rely on the API to get actual sub-messages. + // Fast path: caller pre-fetched this merge_forward's sub-tree. + if ctx.MergeForwardSubItems != nil && ctx.MessageID != "" { + if cached, ok := ctx.MergeForwardSubItems[ctx.MessageID]; ok { + return renderMergeForwardTree(ctx, cached) + } + } + // Slow path: no pre-fetch; fall back to a per-merge_forward GET. Kept so + // callers that don't pre-fetch (e.g. event subscribers, ad-hoc Convert + // invocations in tests) still produce correct output, just serially. + // merge_forward body.content is typically a plain-text placeholder, not + // JSON with create_message_ids, so we must rely on the API to get actual + // sub-messages. if ctx.Runtime != nil && ctx.MessageID != "" { subItems, err := fetchMergeForwardSubMessages(ctx.MessageID, ctx.Runtime) if err != nil { return fmt.Sprintf("[Merged forward: fetch failed: %s]", err) } if len(subItems) > 0 { - // Resolve sender names using shared cache to avoid redundant API calls across merge_forward messages - nameMap := ResolveSenderNames(ctx.Runtime, subItems, ctx.SenderNames) - AttachSenderNames(subItems, nameMap) - childrenMap := BuildMergeForwardChildrenMap(subItems, ctx.MessageID) - return FormatMergeForwardSubTree(ctx.MessageID, childrenMap) + return renderMergeForwardTree(ctx, subItems) } } - // Fallback: try to extract message IDs from content (some older formats include them) + // Final fallback: try to extract message IDs from content (some older formats include them) ids := ParseMergeForwardIDs(ctx.RawContent) if len(ids) > 0 { return fmt.Sprintf("[Merged forward: %d messages]", len(ids)) @@ -45,31 +70,158 @@ func (mergeForwardConverter) Convert(ctx *ConvertContext) string { return "[Merged forward]" } -// fetchMergeForwardSubMessages fetches all sub-messages in a merge_forward container -// via a single API call. Returns a flat list of raw message items with upper_message_id -// for tree reconstruction. +// renderMergeForwardTree resolves sender names for the supplied sub-items and +// produces the formatted forwarded-messages tree. Shared by the prefetch fast +// path and the inline fetch fallback so both produce identical output. +func renderMergeForwardTree(ctx *ConvertContext, subItems []map[string]interface{}) string { + nameMap := ResolveSenderNames(ctx.Runtime, subItems, ctx.SenderNames) + AttachSenderNames(subItems, nameMap) + childrenMap := BuildMergeForwardChildrenMap(subItems, ctx.MessageID) + return FormatMergeForwardSubTree(ctx.MessageID, childrenMap) +} + +// PrefetchMergeForwardSubItems scans rawItems for merge_forward messages, +// concurrently fetches each one's flat sub-message list, and returns a map +// keyed by the merge_forward message_id. Callers thread the returned map +// through FormatMessageItemWithMergePrefetch (or directly into a +// ConvertContext.MergeForwardSubItems) so the per-item conversion loop can +// reuse cached sub-trees instead of issuing its own serial GET. +// +// Each fetch is independent (different message_id, different sub-tree), so +// concurrent goroutines never contend on shared mutable state — the result +// map is written under a mutex purely to make the map safe for concurrent +// inserts. +// +// On fetch failure: emit a stderr warning and intentionally do NOT insert +// the failed id into the result map. The downstream +// mergeForwardConverter.Convert path keys off "is this id present in the +// prefetch?" — by leaving the key absent on failure, Convert falls through +// to its inline-fetch slow path, which (a) gets a second attempt at the +// GET, and (b) if that ALSO fails, surfaces the real "[Merged forward: +// fetch failed: ...]" string the user used to see in stdout. Inserting nil +// would have silently produced an empty tree instead, +// dropping the failure signal from the user-visible output. +// +// When nameCache is non-nil, this function also runs one batched +// ResolveSenderNames across every sub-item it fetched, populating the cache +// before returning. Without this step, each per-merge_forward render in the +// caller's loop would issue its own contact API request for any uncached +// sender, re-introducing an N × ~400ms serial stall (measured at 5 +// merge_forwards × ~400ms = ~2s in production traces). Pre-populating the +// cache makes those per-render ResolveSenderNames calls effective no-ops. +func PrefetchMergeForwardSubItems(runtime *common.RuntimeContext, rawItems []interface{}, nameCache map[string]string) map[string][]map[string]interface{} { + if runtime == nil || len(rawItems) == 0 { + return nil + } + var ids []string + for _, item := range rawItems { + m, _ := item.(map[string]interface{}) + if m == nil { + continue + } + if mt, _ := m["msg_type"].(string); mt != "merge_forward" { + continue + } + id, _ := m["message_id"].(string) + if id == "" { + continue + } + ids = append(ids, id) + } + if len(ids) == 0 { + return nil + } + + result := make(map[string][]map[string]interface{}, len(ids)) + if len(ids) == 1 { + // Single-message fast path: no goroutine overhead. Matches the + // pre-existing serial behavior bit-for-bit when only one + // merge_forward is present. + items, err := fetchMergeForwardSubMessages(ids[0], runtime) + if err != nil { + fmt.Fprintf(runtime.IO().ErrOut, "warning: merge_forward_prefetch_failed: %s: %v\n", ids[0], err) + // Leave the key absent so Convert falls back to its inline GET + // path and surfaces "[Merged forward: fetch failed: ...]" if + // the retry also fails. See function godoc. + } else { + result[ids[0]] = items + } + batchResolveMergeForwardSenders(runtime, result, nameCache) + return result + } + + var mu sync.Mutex + sem := make(chan struct{}, mergeForwardPrefetchConcurrency) + var wg sync.WaitGroup + for _, id := range ids { + // Add before the semaphore acquire — sync.WaitGroup godoc + // recommends Add precede the goroutine-spawning event. + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + items, err := fetchMergeForwardSubMessages(id, runtime) + mu.Lock() + if err != nil { + fmt.Fprintf(runtime.IO().ErrOut, "warning: merge_forward_prefetch_failed: %s: %v\n", id, err) + // Leave the key absent — see fast-path comment above. + } else { + result[id] = items + } + mu.Unlock() + }() + } + wg.Wait() + batchResolveMergeForwardSenders(runtime, result, nameCache) + return result +} + +// batchResolveMergeForwardSenders gathers every sub-item across every +// prefetched merge_forward and runs a single ResolveSenderNames call against +// nameCache. No-op when nameCache is nil (callers that pre-fetched without +// caring about sender resolution, e.g. event subscribers that render on the +// fly) or when nothing was fetched. +func batchResolveMergeForwardSenders(runtime *common.RuntimeContext, prefetch map[string][]map[string]interface{}, nameCache map[string]string) { + if nameCache == nil || len(prefetch) == 0 { + return + } + var allSubItems []map[string]interface{} + for _, items := range prefetch { + allSubItems = append(allSubItems, items...) + } + if len(allSubItems) == 0 { + return + } + ResolveSenderNames(runtime, allSubItems, nameCache) +} + +// fetchMergeForwardSubMessages fetches all sub-messages in a merge_forward +// container via a single API call. Returns a flat list of raw message items +// with upper_message_id for tree reconstruction. +// +// Uses DoAPIJSON so the response envelope's code/msg are checked and surfaced +// — earlier this used the low-level DoAPI and reported every non-zero code +// as a generic "empty data" error, hiding the real failure (e.g. a server +// "code: 2200 Internal Error" with its log_id would show up as just "empty +// data" in the output). func fetchMergeForwardSubMessages(messageID string, runtime *common.RuntimeContext) ([]map[string]interface{}, error) { - apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ - HttpMethod: http.MethodGet, - ApiPath: mergeForwardMessagesPath(messageID), - QueryParams: larkcore.QueryParams{ - "user_id_type": []string{"open_id"}, - "card_msg_content_type": []string{"raw_card_content"}, - }, - }) + data, err := runtime.DoAPIJSON(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{ + "user_id_type": []string{"open_id"}, + "card_msg_content_type": []string{"raw_card_content"}, + }, nil) if err != nil { return nil, err } - - var result map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return nil, fmt.Errorf("invalid response: %w", err) - } - data, _ := result["data"].(map[string]interface{}) + // DoAPIJSON returns the envelope's `data` field; when the server's JSON + // has `code: 0` but omits `data` entirely, that field comes back as nil. + // Reading from a nil map in Go is safe (returns the zero value, never + // panics), but guarding explicitly makes the "successful empty + // response" path obvious and keeps a future signature change from + // silently introducing nil-deref hazards. if data == nil { - return nil, fmt.Errorf("empty data") + return []map[string]interface{}{}, nil } - rawItems, _ := data["items"].([]interface{}) items := make([]map[string]interface{}, 0, len(rawItems)) for _, raw := range rawItems { @@ -168,6 +320,7 @@ func FormatMergeForwardSubTree(parentID string, childrenMap map[string][]map[str content = ConvertBodyContent(msgType, &ConvertContext{ RawContent: rawContent, MentionMap: BuildMentionKeyMap(mentions), + Mentions: mentions, }) } diff --git a/shortcuts/im/convert_lib/merge_test.go b/shortcuts/im/convert_lib/merge_test.go index 01ba0da5b..b9b5eff66 100644 --- a/shortcuts/im/convert_lib/merge_test.go +++ b/shortcuts/im/convert_lib/merge_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "strings" + "sync" "testing" "time" ) @@ -86,7 +87,14 @@ func TestFetchMergeForwardSubMessages(t *testing.T) { } }) - t.Run("empty data", func(t *testing.T) { + t.Run("empty data treated as no children", func(t *testing.T) { + // `code: 0` with no data field is a successful "no children" response + // after the switch to DoAPIJSON (which checks the response envelope's + // code/msg directly). Previously this was reported as a generic + // "empty data" error — which also masked real failures like a + // non-zero code with data: null — so a successful empty payload now + // returns (nil, nil) and lets Convert fall through to its summary + // fallback string. runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { switch { case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_bad"): @@ -96,11 +104,193 @@ func TestFetchMergeForwardSubMessages(t *testing.T) { } })) - _, err := fetchMergeForwardSubMessages("om_bad", runtime) - if err == nil || !strings.Contains(err.Error(), "empty data") { - t.Fatalf("fetchMergeForwardSubMessages() error = %v", err) + items, err := fetchMergeForwardSubMessages("om_bad", runtime) + if err != nil { + t.Fatalf("fetchMergeForwardSubMessages(success-but-empty) err = %v, want nil", err) + } + if len(items) != 0 { + t.Fatalf("fetchMergeForwardSubMessages(success-but-empty) items = %#v, want empty", items) } }) + + t.Run("non-zero code surfaces real error", func(t *testing.T) { + // Regression coverage for the bug that motivated the DoAPIJSON + // switch: a server response with code != 0 (here: 2200 Internal + // Error, observed in production for some merge_forward IDs) used to + // be silently reported as the generic "empty data" string, hiding + // the real code/msg/log_id. With DoAPIJSON the envelope's code is + // checked and surfaced as an ErrAPI containing the real message. + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 2200, + "msg": "Internal Error", + }), nil + })) + + _, err := fetchMergeForwardSubMessages("om_err", runtime) + if err == nil { + t.Fatal("fetchMergeForwardSubMessages(code=2200) err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "Internal Error") { + t.Fatalf("fetchMergeForwardSubMessages(code=2200) err = %q, want it to contain the real msg", err) + } + }) +} + +// TestPrefetchMergeForwardSubItems exercises the bounded-concurrency prefetch +// path: each merge_forward in the input gets its own GET fetched in +// parallel, and the returned map keys items by their merge_forward +// message_id. A goroutine cross-contamination bug would manifest as +// mis-keyed entries. +func TestPrefetchMergeForwardSubItems(t *testing.T) { + var ( + mu sync.Mutex + callCount int + ) + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + // Each merge_forward's path ends with its message_id; key the + // returned child off that so the test can detect mis-attachment. + path := req.URL.Path + // The path looks like /open-apis/im/v1/messages/; take + // the last segment. + lastSlash := strings.LastIndex(path, "/") + if lastSlash < 0 { + return nil, fmt.Errorf("unexpected path: %s", path) + } + hostID := path[lastSlash+1:] + mu.Lock() + callCount++ + mu.Unlock() + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "message_id": "om_child_of_" + hostID, + "create_time": "1710500000000", + }, + }, + }, + }), nil + })) + + // Mix of merge_forward and non-merge_forward messages — only the former + // should be fetched. 5 merge_forwards is enough to exercise the + // bounded fan-out (cap = 4) rather than fall into a single-message fast + // path. + rawItems := []interface{}{ + map[string]interface{}{"message_id": "om_mf_1", "msg_type": "merge_forward"}, + map[string]interface{}{"message_id": "om_text_a", "msg_type": "text"}, + map[string]interface{}{"message_id": "om_mf_2", "msg_type": "merge_forward"}, + map[string]interface{}{"message_id": "om_mf_3", "msg_type": "merge_forward"}, + map[string]interface{}{"message_id": "om_image", "msg_type": "image"}, + map[string]interface{}{"message_id": "om_mf_4", "msg_type": "merge_forward"}, + map[string]interface{}{"message_id": "om_mf_5", "msg_type": "merge_forward"}, + } + + got := PrefetchMergeForwardSubItems(runtime, rawItems, nil) + + if callCount != 5 { + t.Fatalf("expected 5 merge_forward fetches, got %d", callCount) + } + wantIDs := []string{"om_mf_1", "om_mf_2", "om_mf_3", "om_mf_4", "om_mf_5"} + for _, id := range wantIDs { + children, ok := got[id] + if !ok { + t.Fatalf("prefetch map missing key %q (cross-thread contamination?)", id) + } + if len(children) != 1 { + t.Fatalf("prefetch[%s] children len = %d, want 1", id, len(children)) + } + want := "om_child_of_" + id + if children[0]["message_id"] != want { + t.Fatalf("prefetch[%s] child id = %v, want %q — mis-attributed result", id, children[0]["message_id"], want) + } + } + for _, missing := range []string{"om_text_a", "om_image"} { + if _, ok := got[missing]; ok { + t.Fatalf("prefetch map should not contain non-merge_forward key %q", missing) + } + } +} + +// TestPrefetchMergeForwardSubItemsHTTPError covers the transport-level +// failure path: server replies with a non-2xx status (e.g. 503). DoAPIJSON +// surfaces this as a network error, the prefetch goroutine emits a stderr +// warning, and — critically — does NOT insert the failed id into the +// result map, so Convert falls back to inline retry (same contract as +// envelope-level errors, exercised by +// TestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch). +func TestPrefetchMergeForwardSubItemsHTTPError(t *testing.T) { + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + // 503 Service Unavailable with no body — purely a transport-layer + // error. DoAPIJSON's `resp.StatusCode >= 400` branch handles this + // before it ever tries to parse an envelope, which is the path the + // envelope-error test doesn't reach. + return convertlibJSONResponse(503, map[string]interface{}{}), nil + })) + + rawItems := []interface{}{ + map[string]interface{}{"message_id": "om_mf_a", "msg_type": "merge_forward"}, + map[string]interface{}{"message_id": "om_mf_b", "msg_type": "merge_forward"}, + } + + got := PrefetchMergeForwardSubItems(runtime, rawItems, nil) + + for _, id := range []string{"om_mf_a", "om_mf_b"} { + if _, ok := got[id]; ok { + t.Fatalf("prefetch map contains transport-error id %q — Convert would render an empty tree instead of falling back to the inline retry path", id) + } + } +} + +// TestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch is a +// regression test for the silent-empty-tree bug: when a prefetch fails, the +// failed id MUST be absent from the returned map (not present-with-nil). +// Otherwise Convert's "if cached, ok := m[id]; ok { renderTree(cached) }" +// path hits `ok=true, cached=nil`, renders an empty +// tree, and the user-visible "[Merged forward: fetch failed: ...]" string +// that the inline path produced disappears. +func TestPrefetchMergeForwardSubItemsFailureFallsThroughToInlineFetch(t *testing.T) { + // Mock: every fetch returns an API error. + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 2200, + "msg": "Internal Error", + }), nil + })) + + // Multiple ids so we hit the concurrent path (the single-id fast path + // has its own dedicated branch; covering the concurrent branch is more + // stringent since the bug originally hid inside its mu.Lock section). + rawItems := []interface{}{ + map[string]interface{}{"message_id": "om_mf_1", "msg_type": "merge_forward"}, + map[string]interface{}{"message_id": "om_mf_2", "msg_type": "merge_forward"}, + } + + got := PrefetchMergeForwardSubItems(runtime, rawItems, nil) + + // Every failed id MUST be absent from the map (not present-with-nil). + for _, id := range []string{"om_mf_1", "om_mf_2"} { + if _, ok := got[id]; ok { + t.Fatalf("prefetch map contains failed id %q — this would cause Convert to render an empty tree instead of falling back to the inline-fetch error path", id) + } + } + + // And as the downstream effect: invoking the converter on the failed id + // with the (now-cleanly-absent-key) prefetch map must produce the + // inline-path error string, not an empty tree. The mocked inline fetch + // also errors with the same 2200 / Internal Error, so the rendered + // content should contain "Merged forward: fetch failed". + out := (mergeForwardConverter{}).Convert(&ConvertContext{ + MessageID: "om_mf_1", + Runtime: runtime, + SenderNames: map[string]string{}, + MergeForwardSubItems: got, + }) + if !strings.Contains(out, "Merged forward: fetch failed") { + t.Fatalf("Convert output after prefetch failure = %q, want it to contain \"Merged forward: fetch failed\" — failure signal lost", out) + } } func TestMergeForwardConverterWithRuntime(t *testing.T) { @@ -135,3 +325,29 @@ func TestMergeForwardConverterWithRuntime(t *testing.T) { t.Fatalf("mergeForwardConverter.Convert(runtime) = %s", got) } } + +func TestFormatMergeForwardSubTreeInteractiveCardUsesMentions(t *testing.T) { + cardContent := `{"json_card":"{\"body\":{\"elements\":[{\"tag\":\"at\",\"property\":{\"userID\":\"cde8a6c8\"}}]}}","json_attachment":"{\"at_users\":{\"cde8a6c8\":{\"user_id\":\"754700000001\",\"content\":\"Alice\",\"mention_key\":\"@_user_1\"}}}"}` + items := []map[string]interface{}{ + { + "message_id": "om_card", + "msg_type": "interactive", + "create_time": "1710500000000", + "sender": map[string]interface{}{"name": "Sender"}, + "body": map[string]interface{}{"content": cardContent}, + "mentions": []interface{}{ + map[string]interface{}{ + "key": "@_user_1", + "id": "ou_real_open_id", + "name": "Alice", + }, + }, + }, + } + + children := BuildMergeForwardChildrenMap(items, "om_root") + got := FormatMergeForwardSubTree("om_root", children) + if !strings.Contains(got, "@Alice(ou_real_open_id)") { + t.Fatalf("FormatMergeForwardSubTree(interactive card) = %s", got) + } +} diff --git a/shortcuts/im/convert_lib/reactions.go b/shortcuts/im/convert_lib/reactions.go new file mode 100644 index 000000000..3c39d1fc1 --- /dev/null +++ b/shortcuts/im/convert_lib/reactions.go @@ -0,0 +1,272 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package convertlib + +import ( + "fmt" + "io" + "net/http" + "sync" + + "github.com/larksuite/cli/shortcuts/common" +) + +// reactionsBatchQueryMaxQueries is the server-side hard limit on queries[] +// length for POST /im/v1/messages/reactions/batch_query (see +// larkim/message/members/facade_reaction/service: batchListReactionsMaxMessageIDs). +const reactionsBatchQueryMaxQueries = 20 + +// reactionsBatchQueryConcurrency caps in-flight batch_query requests. A single +// batch_query call is observed at ~700ms RTT regardless of payload size, so a +// fully serial loop turns N=550 (page-size 50 + 500 expanded thread_replies) +// into ~20s of latency and lets outer wrappers (agents, shells with a wall +// clock) time the whole command out. Bounded concurrency cuts that to ~5s +// without risking the server's gateway-layer 50/s + 1000/min ceiling: even at +// the worst sustained pattern (28 batches at 4-way fan-out finishing every +// ~700ms) the effective rate stays well under 6/s. +const reactionsBatchQueryConcurrency = 4 + +// EnrichReactions enriches messages with their reactions by calling the +// im.reactions.batch_query API. Messages are modified in place: each message +// that the server returns reactions for gets a "reactions" map attached. +// +// Failure modes (warning to stderr + skip; never aborts main message output): +// - batch_query call fails (network, 5xx, scope insufficient, rate limited): +// each message in the failed batch is marked with "reactions_error": true +// so callers can distinguish "fetch failed" from "no reactions exist". +// - batch_query returns a partial result: only messages the server failed on +// get "reactions_error": true; the successful ones get the reactions block. +// +// The "reactions_error" flag mirrors the "thread_replies_error" pattern in +// thread.go so downstream consumers handle both enrichment failures uniformly. +// +// Output shape (only on messages that the server actually returned data for): +// +// "reactions": { +// "counts": [{"reaction_type": "SMILE", "count": 3}], +// "details": [{"reaction_id": "...", "emoji_type": "SMILE", +// "operator": {...}, "action_time": "..."}] +// } +// +// The server caps queries[] at 20 per call, so messages are split into +// batches of size <= 20 before invoking the API. +func EnrichReactions(runtime *common.RuntimeContext, messages []map[string]interface{}) { + if len(messages) == 0 { + return + } + + // Index messages by ID so we can merge reactions back later. + // A single message_id may appear more than once (e.g. mget --message-ids + // om_a,om_a); every occurrence must receive the reactions block, but the + // API should only be queried once per distinct id. + // Walks into msg["thread_replies"] recursively so replies attached by + // ExpandThreadReplies are enriched in the same batched call as their parent. + idIndex := make(map[string][]map[string]interface{}, len(messages)) + var ids []string + collectMessageNodes(messages, idIndex, &ids) + if len(ids) == 0 { + return + } + + // Slice the id list into batches of <= reactionsBatchQueryMaxQueries. + var batches [][]string + for i := 0; i < len(ids); i += reactionsBatchQueryMaxQueries { + end := i + reactionsBatchQueryMaxQueries + if end > len(ids) { + end = len(ids) + } + batches = append(batches, ids[i:end]) + } + + // Single-batch fast path: no goroutine overhead, fully deterministic + // stderr ordering, identical behavior to the original serial loop. + if len(batches) == 1 { + fetchReactionsBatch(runtime, batches[0], idIndex, nil) + return + } + + // Multi-batch path: bounded-concurrency fan-out. Safety invariant: + // collectMessageNodes dedups ids on first-seen (the `if _, seen := + // idIndex[id]; !seen` check above), so the slice ids — and therefore + // every batch[i:end] sub-slice we hand to a goroutine — contains each + // id at most once. Different batches operate on disjoint id sets, + // which means different idIndex buckets, which means different + // message-map pointers. Goroutines never write to the same map. The + // shared mutex serializes only the stderr warning lines so they don't + // interleave between goroutines. (Race detector verifies; see + // TestEnrichReactions_DuplicateMessageID and + // TestEnrichReactions_MultiBatchCorrectness for the round-trip.) + var stderrMu sync.Mutex + sem := make(chan struct{}, reactionsBatchQueryConcurrency) + var wg sync.WaitGroup + for _, batch := range batches { + // Add(1) before the semaphore acquire — sync.WaitGroup godoc + // recommends Add precede the goroutine-spawning event, and + // putting it ahead of the blocking sem read keeps the parent + // goroutine's bookkeeping monotonic. + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + fetchReactionsBatch(runtime, batch, idIndex, &stderrMu) + }() + } + wg.Wait() +} + +// collectMessageNodes walks messages (and any nested thread_replies) and +// records each map under its message_id. Distinct ids are appended to *ids in +// first-seen order so the API is queried at most once per id. +func collectMessageNodes(messages []map[string]interface{}, idIndex map[string][]map[string]interface{}, ids *[]string) { + for _, msg := range messages { + if id, _ := msg["message_id"].(string); id != "" { + if _, seen := idIndex[id]; !seen { + *ids = append(*ids, id) + } + idIndex[id] = append(idIndex[id], msg) + } + // thread_replies may arrive as a typed slice (set by ExpandThreadReplies) + // or as []interface{} (e.g. when produced via JSON round-trip). + switch nested := msg["thread_replies"].(type) { + case []map[string]interface{}: + collectMessageNodes(nested, idIndex, ids) + case []interface{}: + typed := make([]map[string]interface{}, 0, len(nested)) + for _, raw := range nested { + if m, ok := raw.(map[string]interface{}); ok { + typed = append(typed, m) + } + } + collectMessageNodes(typed, idIndex, ids) + } + } +} + +// fetchReactionsBatch invokes batch_query for one batch of <= 20 message IDs +// and merges the results into idIndex. Failures are logged to stderr without +// aborting subsequent batches. +// +// stderrMu is non-nil in the multi-batch concurrent path (serializes warning +// lines so they don't interleave) and nil in the single-batch fast path. +func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIndex map[string][]map[string]interface{}, stderrMu *sync.Mutex) { + queries := make([]map[string]interface{}, 0, len(batchIDs)) + for _, id := range batchIDs { + queries = append(queries, map[string]interface{}{"message_id": id}) + } + + data, err := runtime.DoAPIJSON(http.MethodPost, + "/open-apis/im/v1/messages/reactions/batch_query", + nil, + map[string]interface{}{"queries": queries}, + ) + if err != nil { + warnReactionsf(stderrMu, runtime.IO().ErrOut, "warning: reactions_batch_query_failed: %v\n", err) + markReactionsError(batchIDs, idIndex) + return + } + + countsByMsg := groupReactionCounts(data["success_msg_reaction_counts"]) + detailsByMsg := groupReactionDetails(data["success_msg_reaction_details"]) + + // Attach the merged reactions block to every message that had any data. + // Each id may map to >1 message map (duplicate input), so iterate the slice. + for _, id := range batchIDs { + msgs := idIndex[id] + if len(msgs) == 0 { + continue + } + counts := countsByMsg[id] + details := detailsByMsg[id] + if len(counts) == 0 && len(details) == 0 { + continue + } + block := make(map[string]interface{}, 2) + if len(counts) > 0 { + block["counts"] = counts + } + if len(details) > 0 { + block["details"] = details + } + for _, msg := range msgs { + msg["reactions"] = block + } + } + + // Surface per-message failures from the API response. + if fails, _ := data["fail_msg_reaction_details"].([]interface{}); len(fails) > 0 { + var failedIDs []string + for _, raw := range fails { + item, _ := raw.(map[string]interface{}) + if id, _ := item["message_id"].(string); id != "" { + failedIDs = append(failedIDs, id) + } + } + if len(failedIDs) > 0 { + warnReactionsf(stderrMu, runtime.IO().ErrOut, + "warning: reactions_partial_failed: %d message(s) failed (%v)\n", + len(failedIDs), failedIDs) + markReactionsError(failedIDs, idIndex) + } + } +} + +// warnReactionsf writes a stderr warning under the supplied mutex when one is +// provided (multi-batch concurrent path), so concurrent goroutines can't +// interleave partial lines. mu == nil means the caller is on the single-batch +// fast path where no synchronization is needed. +func warnReactionsf(mu *sync.Mutex, w io.Writer, format string, args ...interface{}) { + if mu != nil { + mu.Lock() + defer mu.Unlock() + } + fmt.Fprintf(w, format, args...) +} + +// markReactionsError flags every message map indexed under the given ids with +// reactions_error=true, so downstream consumers can distinguish "fetch failed" +// from "no reactions exist" by reading stdout alone. +func markReactionsError(ids []string, idIndex map[string][]map[string]interface{}) { + for _, id := range ids { + for _, msg := range idIndex[id] { + msg["reactions_error"] = true + } + } +} + +func groupReactionCounts(raw interface{}) map[string][]interface{} { + groups := map[string][]interface{}{} + items, _ := raw.([]interface{}) + for _, item := range items { + row, _ := item.(map[string]interface{}) + msgID, _ := row["message_id"].(string) + if msgID == "" { + continue + } + entries, _ := row["reaction_count"].([]interface{}) + if len(entries) == 0 { + continue + } + groups[msgID] = append(groups[msgID], entries...) + } + return groups +} + +func groupReactionDetails(raw interface{}) map[string][]interface{} { + groups := map[string][]interface{}{} + items, _ := raw.([]interface{}) + for _, item := range items { + row, _ := item.(map[string]interface{}) + msgID, _ := row["message_id"].(string) + if msgID == "" { + continue + } + entries, _ := row["message_reaction_items"].([]interface{}) + if len(entries) == 0 { + continue + } + groups[msgID] = append(groups[msgID], entries...) + } + return groups +} diff --git a/shortcuts/im/convert_lib/reactions_test.go b/shortcuts/im/convert_lib/reactions_test.go new file mode 100644 index 000000000..4408119ad --- /dev/null +++ b/shortcuts/im/convert_lib/reactions_test.go @@ -0,0 +1,410 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package convertlib + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "sort" + "strings" + "sync" + "testing" +) + +// TestEnrichReactions_Success exercises the basic happy path: messages that +// carry reactions get a "reactions" field, messages without reactions stay +// untouched. +func TestEnrichReactions_Success(t *testing.T) { + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/reactions/batch_query") { + return nil, fmt.Errorf("unexpected path: %s", req.URL.Path) + } + var payload map[string]interface{} + body, _ := io.ReadAll(req.Body) + _ = json.Unmarshal(body, &payload) + queries, _ := payload["queries"].([]interface{}) + if len(queries) != 2 { + t.Fatalf("queries size = %d, want 2", len(queries)) + } + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "success_msg_reaction_counts": []interface{}{ + map[string]interface{}{ + "message_id": "om_a", + "reaction_count": []interface{}{ + map[string]interface{}{"reaction_type": "SMILE", "count": 3}, + }, + }, + }, + "success_msg_reaction_details": []interface{}{ + map[string]interface{}{ + "message_id": "om_a", + "message_reaction_items": []interface{}{ + map[string]interface{}{ + "reaction_id": "react_1", + "emoji_type": "SMILE", + "operator": map[string]interface{}{"operator_id": "ou_x", "operator_type": "user"}, + "action_time": "1710600000", + }, + }, + }, + }, + "fail_msg_reaction_details": []interface{}{}, + }, + }), nil + })) + + messages := []map[string]interface{}{ + {"message_id": "om_a"}, + {"message_id": "om_b"}, + } + + EnrichReactions(runtime, messages) + + reactionsA, ok := messages[0]["reactions"].(map[string]interface{}) + if !ok { + t.Fatalf("message om_a missing reactions field: %#v", messages[0]) + } + counts, _ := reactionsA["counts"].([]interface{}) + if len(counts) != 1 { + t.Fatalf("om_a counts = %d, want 1", len(counts)) + } + details, _ := reactionsA["details"].([]interface{}) + if len(details) != 1 { + t.Fatalf("om_a details = %d, want 1", len(details)) + } + + if _, ok := messages[1]["reactions"]; ok { + t.Fatalf("message om_b should not have reactions field (none in response): %#v", messages[1]) + } +} + +// TestEnrichReactions_BatchSize splits queries into batches of 20 (server-side +// max for batch_query). Multi-batch dispatch is concurrent (bounded fan-out), +// so callers must tolerate any ordering of batch arrivals at the transport. +func TestEnrichReactions_BatchSize(t *testing.T) { + var mu sync.Mutex + var observedBatchSizes []int + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + var payload map[string]interface{} + _ = json.Unmarshal(body, &payload) + queries, _ := payload["queries"].([]interface{}) + mu.Lock() + observedBatchSizes = append(observedBatchSizes, len(queries)) + mu.Unlock() + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }), nil + })) + + messages := make([]map[string]interface{}, 25) + for i := range messages { + messages[i] = map[string]interface{}{"message_id": fmt.Sprintf("om_%02d", i)} + } + + EnrichReactions(runtime, messages) + + sort.Ints(observedBatchSizes) + if want := []int{5, 20}; !reflect.DeepEqual(observedBatchSizes, want) { + t.Fatalf("batch sizes (sorted) = %v, want %v", observedBatchSizes, want) + } +} + +// TestEnrichReactions_MultiBatchCorrectness exercises the bounded-concurrency +// multi-batch path: every message across all batches must receive its own +// reactions block regardless of which goroutine the batch ran on. A race or a +// cross-batch index mix-up would manifest as missing or duplicated blocks. +func TestEnrichReactions_MultiBatchCorrectness(t *testing.T) { + var mu sync.Mutex + var batchCalls int + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + var payload map[string]interface{} + _ = json.Unmarshal(body, &payload) + queries, _ := payload["queries"].([]interface{}) + counts := make([]interface{}, 0, len(queries)) + for _, q := range queries { + qm, _ := q.(map[string]interface{}) + id, _ := qm["message_id"].(string) + counts = append(counts, map[string]interface{}{ + "message_id": id, + "reaction_count": []interface{}{ + map[string]interface{}{"reaction_type": "SMILE", "count": 1}, + }, + }) + } + mu.Lock() + batchCalls++ + mu.Unlock() + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "success_msg_reaction_counts": counts, + }, + }), nil + })) + + // 65 messages -> 4 batches (20+20+20+5), enough to actually exercise the + // bounded fan-out (concurrency cap = 4) rather than degenerate to 1-2 calls. + messages := make([]map[string]interface{}, 65) + for i := range messages { + messages[i] = map[string]interface{}{"message_id": fmt.Sprintf("om_%03d", i)} + } + EnrichReactions(runtime, messages) + + if batchCalls != 4 { + t.Fatalf("expected 4 batched calls, got %d", batchCalls) + } + for i, m := range messages { + if _, ok := m["reactions"]; !ok { + t.Fatalf("message %d (%s) missing reactions after multi-batch run", i, m["message_id"]) + } + } +} + +// TestEnrichReactions_APIFailure: when the API call fails, messages stay +// without a reactions field but get marked with reactions_error=true so +// downstream consumers can distinguish "fetch failed" from "no reactions". +// Mirrors the thread_replies_error pattern in thread.go. +func TestEnrichReactions_APIFailure(t *testing.T) { + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("simulated network error") + })) + + messages := []map[string]interface{}{ + {"message_id": "om_a"}, + {"message_id": "om_b"}, + } + + EnrichReactions(runtime, messages) + + for _, m := range messages { + if _, ok := m["reactions"]; ok { + t.Fatalf("message %v should have no reactions after API failure", m["message_id"]) + } + if v, _ := m["reactions_error"].(bool); !v { + t.Fatalf("message %v should have reactions_error=true after API failure, got = %#v", + m["message_id"], m["reactions_error"]) + } + } +} + +// TestEnrichReactions_PartialFailure: when batch_query returns a fail entry +// for one ID, that message gets reactions_error=true while the rest stay +// clean (no error flag) and keep their normal reactions block. +func TestEnrichReactions_PartialFailure(t *testing.T) { + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "success_msg_reaction_counts": []interface{}{ + map[string]interface{}{ + "message_id": "om_ok", + "reaction_count": []interface{}{ + map[string]interface{}{"reaction_type": "SMILE", "count": 1}, + }, + }, + }, + "fail_msg_reaction_details": []interface{}{ + map[string]interface{}{"message_id": "om_bad"}, + }, + }, + }), nil + })) + + ok := map[string]interface{}{"message_id": "om_ok"} + bad := map[string]interface{}{"message_id": "om_bad"} + EnrichReactions(runtime, []map[string]interface{}{ok, bad}) + + if _, has := ok["reactions"]; !has { + t.Fatalf("om_ok should have reactions: %#v", ok) + } + if v, _ := ok["reactions_error"].(bool); v { + t.Fatalf("om_ok must not carry reactions_error: %#v", ok) + } + if _, has := bad["reactions"]; has { + t.Fatalf("om_bad should have no reactions block: %#v", bad) + } + if v, _ := bad["reactions_error"].(bool); !v { + t.Fatalf("om_bad should have reactions_error=true, got = %#v", bad["reactions_error"]) + } +} + +// TestEnrichReactions_EmptyMessages: no messages -> no API call at all. +func TestEnrichReactions_EmptyMessages(t *testing.T) { + called := false + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + called = true + return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil + })) + + EnrichReactions(runtime, nil) + EnrichReactions(runtime, []map[string]interface{}{}) + + if called { + t.Fatalf("API should not be called when messages list is empty") + } +} + +// TestEnrichReactions_SkipsMessagesWithoutID: messages missing message_id +// (defensive) should not crash and not be sent in queries. +func TestEnrichReactions_SkipsMessagesWithoutID(t *testing.T) { + var sentIDs []string + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + var payload map[string]interface{} + _ = json.Unmarshal(body, &payload) + queries, _ := payload["queries"].([]interface{}) + for _, q := range queries { + qm, _ := q.(map[string]interface{}) + id, _ := qm["message_id"].(string) + sentIDs = append(sentIDs, id) + } + return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil + })) + + messages := []map[string]interface{}{ + {"message_id": "om_a"}, + {}, // no message_id + {"message_id": ""}, + {"message_id": "om_b"}, + } + + EnrichReactions(runtime, messages) + + if want := []string{"om_a", "om_b"}; !reflect.DeepEqual(sentIDs, want) { + t.Fatalf("sent IDs = %v, want %v", sentIDs, want) + } +} + +// TestEnrichReactions_WalksThreadReplies: thread_replies nested under a parent +// message must also be enriched, in the same batch_query call as the parent — +// otherwise the parent gets reactions but its replies don't, leaving the output +// inconsistent. +func TestEnrichReactions_WalksThreadReplies(t *testing.T) { + var observedQueriedIDs []string + var observedCallCount int + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + observedCallCount++ + body, _ := io.ReadAll(req.Body) + var payload map[string]interface{} + _ = json.Unmarshal(body, &payload) + queries, _ := payload["queries"].([]interface{}) + for _, q := range queries { + qm, _ := q.(map[string]interface{}) + id, _ := qm["message_id"].(string) + observedQueriedIDs = append(observedQueriedIDs, id) + } + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "success_msg_reaction_counts": []interface{}{ + map[string]interface{}{ + "message_id": "om_top", + "reaction_count": []interface{}{ + map[string]interface{}{"reaction_type": "SMILE", "count": 1}, + }, + }, + map[string]interface{}{ + "message_id": "om_reply1", + "reaction_count": []interface{}{ + map[string]interface{}{"reaction_type": "THUMBSUP", "count": 2}, + }, + }, + map[string]interface{}{ + "message_id": "om_reply2", + "reaction_count": []interface{}{ + map[string]interface{}{"reaction_type": "HEART", "count": 3}, + }, + }, + }, + }, + }), nil + })) + + reply1 := map[string]interface{}{"message_id": "om_reply1"} + reply2 := map[string]interface{}{"message_id": "om_reply2"} + top := map[string]interface{}{ + "message_id": "om_top", + "thread_replies": []map[string]interface{}{reply1, reply2}, + } + messages := []map[string]interface{}{top} + + EnrichReactions(runtime, messages) + + if observedCallCount != 1 { + t.Fatalf("expected 1 batched API call, got %d", observedCallCount) + } + sort.Strings(observedQueriedIDs) + if want := []string{"om_reply1", "om_reply2", "om_top"}; !reflect.DeepEqual(observedQueriedIDs, want) { + t.Fatalf("queried IDs = %v, want %v (top + thread_replies)", observedQueriedIDs, want) + } + + if _, ok := top["reactions"]; !ok { + t.Fatalf("top message missing reactions") + } + if _, ok := reply1["reactions"]; !ok { + t.Fatalf("reply1 missing reactions — thread_replies were not walked") + } + if _, ok := reply2["reactions"]; !ok { + t.Fatalf("reply2 missing reactions — thread_replies were not walked") + } +} + +// TestEnrichReactions_DuplicateMessageID: when the caller passes two distinct +// message maps that share the same message_id (e.g. mget --message-ids om_a,om_a), +// both maps must receive the same reactions block, and the API must be queried +// for the id only once. +func TestEnrichReactions_DuplicateMessageID(t *testing.T) { + var observedQueriesPerCall []int + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + var payload map[string]interface{} + _ = json.Unmarshal(body, &payload) + queries, _ := payload["queries"].([]interface{}) + observedQueriesPerCall = append(observedQueriesPerCall, len(queries)) + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "success_msg_reaction_counts": []interface{}{ + map[string]interface{}{ + "message_id": "om_a", + "reaction_count": []interface{}{ + map[string]interface{}{"reaction_type": "SMILE", "count": 2}, + }, + }, + }, + }, + }), nil + })) + + first := map[string]interface{}{"message_id": "om_a"} + second := map[string]interface{}{"message_id": "om_a"} + other := map[string]interface{}{"message_id": "om_b"} + messages := []map[string]interface{}{first, other, second} + + EnrichReactions(runtime, messages) + + if want := []int{2}; !reflect.DeepEqual(observedQueriesPerCall, want) { + t.Fatalf("queries-per-call = %v, want %v (each id once, no dup fetch)", observedQueriesPerCall, want) + } + + firstReactions, firstOK := first["reactions"] + secondReactions, secondOK := second["reactions"] + if !firstOK { + t.Fatalf("first om_a entry missing reactions") + } + if !secondOK { + t.Fatalf("second om_a entry missing reactions — dup msg_id was dropped") + } + if !reflect.DeepEqual(firstReactions, secondReactions) { + t.Fatalf("dup entries reactions differ: %#v vs %#v", firstReactions, secondReactions) + } +} diff --git a/shortcuts/im/convert_lib/thread.go b/shortcuts/im/convert_lib/thread.go index 5e0e42f43..2f6e80353 100644 --- a/shortcuts/im/convert_lib/thread.go +++ b/shortcuts/im/convert_lib/thread.go @@ -6,6 +6,7 @@ package convertlib import ( "fmt" "net/http" + "sync" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -17,10 +18,55 @@ const ThreadRepliesPerThread = 50 // ThreadRepliesTotalLimit is the default max total thread replies across all threads. const ThreadRepliesTotalLimit = 500 +// threadRepliesFetchConcurrency caps in-flight per-thread GET /messages calls +// when expanding multiple threads in one shortcut invocation. Each call is a +// per-thread RTT (~1s observed), so a strictly serial loop turns N=10 thread +// roots into ~10s of latency — the same multiplier that motivated the +// reactions enrichment fan-out. GET /messages has no published per-app +// rate-limit anywhere near these levels, so we set this higher than the +// reactions batch_query cap (which sits at 4 to stay well under the +// gateway-layer 50/s + 1000/min explicit ceiling on the reactions endpoint). +const threadRepliesFetchConcurrency = 8 + // ExpandThreadReplies fetches and embeds thread replies for messages that contain a thread_id. // For each unique thread_id found in messages, it fetches up to perThread replies (asc order) -// and attaches them as "thread_replies" on the message. Expansion stops once totalLimit -// cumulative replies have been fetched. nameCache is the shared open_id→name map. +// and attaches them as "thread_replies" on the first outer message that referenced that thread. +// Expansion stops once totalLimit cumulative replies have been allocated across planned fetches. +// nameCache is the shared open_id→name map. +// +// Implementation is two-phase: +// +// 1. Plan + concurrent fetch. Walk messages in order, recording every +// unique thread_id with a fetch limit of perThread (no upfront budget +// deduction — see below). Then dispatch the planned fetches with +// bounded concurrency; each goroutine writes only to its own result +// slot, no shared mutable state besides that slot. +// +// 2. Sequential attach with post-hoc budget enforcement. Walk the planned +// threads in their original first-seen order, accumulating actual +// returned reply counts against totalLimit. When a thread's actual +// replies would push the running total past totalLimit, its reply slice +// is truncated to fit the remaining budget and thread_has_more is set +// on its host so consumers know more replies exist server-side. Threads +// that arrive past a fully-exhausted budget keep their thread_id on the +// host but don't get thread_replies attached (semantically identical to +// the pre-existing serial behavior for over-budget threads). The phase +// stays single-threaded because ResolveSenderNames writes to the shared +// nameCache and FormatMessageItem may trigger merge_forward expansion +// that also touches nameCache. +// +// Budget semantics match the pre-existing serial implementation exactly: +// each thread's actual returned count is what gets deducted from the +// budget, not its planned per-thread ceiling. An earlier draft of this +// refactor allocated the budget against the planned ceiling upfront for +// implementation simplicity, but that silently dropped later threads in +// chats where many threads return well under perThread replies (e.g. +// totalLimit=500 + perThread=50 + 12 short threads of 3 replies each → old +// code attached all 12, planned-allocation code attached only 10). The +// trade-off here is a small amount of server-side over-fetching for +// threads that will end up truncated or dropped — bounded by perThread per +// thread — in exchange for preserving the original "every thread that fits +// gets its data" guarantee. func ExpandThreadReplies(runtime *common.RuntimeContext, messages []map[string]interface{}, nameCache map[string]string, perThread, totalLimit int) { if runtime == nil { return @@ -35,52 +81,161 @@ func ExpandThreadReplies(runtime *common.RuntimeContext, messages []map[string]i totalLimit = ThreadRepliesTotalLimit } - totalFetched := 0 + // Phase 1a: enumerate every unique thread_id in first-seen order. We + // deliberately do NOT deduct anything from the totalLimit budget here — + // see the godoc above and the Phase 2 truncation step. The first outer + // message referencing a given thread_id is the host that will receive + // the thread_replies attachment, matching the pre-existing behavior + // where duplicates inherited nothing. + type plan struct { + threadID string + limit int + host map[string]interface{} + } + var plans []plan seen := make(map[string]bool) - for _, msg := range messages { - if totalFetched >= totalLimit { - break - } tid, _ := msg["thread_id"].(string) if tid == "" || seen[tid] { continue } seen[tid] = true + plans = append(plans, plan{threadID: tid, limit: perThread, host: msg}) + } + if len(plans) == 0 { + return + } - limit := perThread - if remaining := totalLimit - totalFetched; limit > remaining { - limit = remaining + // Phase 1b: concurrent fetch. Each goroutine writes only to its own + // results[i] slot, so there is no shared mutable state besides that + // slot. The single-batch fast path skips goroutine setup for clarity + // and to keep "one thread root" behavior identical to the old code. + type result struct { + rawReplies []map[string]interface{} + hasMore bool + err error + } + results := make([]result, len(plans)) + if len(plans) == 1 { + items, hasMore, err := fetchThreadReplies(runtime, plans[0].threadID, plans[0].limit) + results[0] = result{rawReplies: items, hasMore: hasMore, err: err} + } else { + sem := make(chan struct{}, threadRepliesFetchConcurrency) + var wg sync.WaitGroup + for i, p := range plans { + // Add before the semaphore acquire — sync.WaitGroup godoc + // recommends Add precede the goroutine-spawning event. + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + items, hasMore, err := fetchThreadReplies(runtime, p.threadID, p.limit) + results[i] = result{rawReplies: items, hasMore: hasMore, err: err} + }() } + wg.Wait() + } - rawReplies, hasMore, fetchErr := fetchThreadReplies(runtime, tid, limit) - if fetchErr != nil { - // Preserve the outer message while surfacing that thread expansion failed. - msg["thread_replies_error"] = true + // Phase 2a-pre: apply the totalLimit budget against actual returned + // counts (not planned ceilings) and trim each result in place. Walking + // in original plan order matches the pre-existing serial behavior so a + // chat with budget-exceeding total replies cuts off at the same thread + // position as the old code. Threads past a fully-drained budget have + // their slice cleared to an empty (non-nil) slice — distinct from a + // fetch error's nil rawReplies — so the attach loop below leaves the + // host alone without flagging thread_replies_error. Threads whose + // actual count crosses the boundary get their slice truncated and + // hasMore flagged so consumers know more exist server-side. + remaining := totalLimit + for i := range plans { + r := &results[i] + if r.err != nil || len(r.rawReplies) == 0 { continue } - // Successful fetches always return a non-nil (possibly empty) slice. - // A nil slice indicates thread expansion did not complete. - if rawReplies == nil { - msg["thread_replies_error"] = true + if remaining <= 0 { + // Budget already drained by earlier threads — discard this + // thread's fetched replies. We over-fetched on the wire (one + // of the explicit trade-offs documented on the function), but + // the user-visible output remains the same as the serial + // implementation, which would never have issued this fetch. + // Empty slice (not nil) so the attach loop treats this like + // "successfully returned no replies", not "fetch failed". + r.rawReplies = r.rawReplies[:0] continue } - if len(rawReplies) == 0 { - continue + if len(r.rawReplies) > remaining { + r.rawReplies = r.rawReplies[:remaining] + r.hasMore = true } + remaining -= len(r.rawReplies) + } - replies := make([]map[string]interface{}, 0, len(rawReplies)) - for _, r := range rawReplies { - replies = append(replies, FormatMessageItem(r, runtime, nameCache)) + // Phase 2a-merge: collect every (post-truncation) raw reply across all + // threads and pre-fetch merge_forward sub-messages for the ones that + // need it. Without this, a thread reply that is itself a merge_forward + // would trigger another serial GET inside FormatMessageItem — + // re-introducing the same N × RTT stall pattern that Phase 1b just + // removed. + var allRawReplies []interface{} + for i := range plans { + r := results[i] + if len(r.rawReplies) == 0 { + continue + } + for _, raw := range r.rawReplies { + allRawReplies = append(allRawReplies, raw) + } + } + mergePrefetch := PrefetchMergeForwardSubItems(runtime, allRawReplies, nameCache) + + // Phase 2a: format every plan's replies sequentially. FormatMessageItem + // may still touch nameCache for non-merge_forward content types + // (e.g. mention resolution), so this stays single-threaded — concurrent + // writes to nameCache would race. + preparedReplies := make([][]map[string]interface{}, len(plans)) + for i, p := range plans { + r := results[i] + if r.err != nil || r.rawReplies == nil { + p.host["thread_replies_error"] = true + continue + } + if len(r.rawReplies) == 0 { + continue + } + replies := make([]map[string]interface{}, 0, len(r.rawReplies)) + for _, raw := range r.rawReplies { + replies = append(replies, FormatMessageItemWithMergePrefetch(raw, runtime, nameCache, mergePrefetch)) + } + preparedReplies[i] = replies + } + + // Phase 2b: one batched ResolveSenderNames across all replies from all + // threads. The pre-existing per-thread call pattern would issue a fresh + // contact API request for every thread that introduced a new sender, + // turning N threads into up to N serial contact RTTs even after the + // fetches themselves went parallel. Consolidating into a single call + // resolves every still-missing open_id in one request and lets the + // nameCache absorb the rest. + var combined []map[string]interface{} + for _, replies := range preparedReplies { + combined = append(combined, replies...) + } + if len(combined) > 0 { + ResolveSenderNames(runtime, combined, nameCache) + } + + // Phase 2c: attach the (now name-resolved) replies to their hosts. + for i, p := range plans { + replies := preparedReplies[i] + if replies == nil { + continue } - ResolveSenderNames(runtime, replies, nameCache) AttachSenderNames(replies, nameCache) - - msg["thread_replies"] = replies - if hasMore { - msg["thread_has_more"] = true + p.host["thread_replies"] = replies + if results[i].hasMore { + p.host["thread_has_more"] = true } - totalFetched += len(rawReplies) } } diff --git a/shortcuts/im/convert_lib/thread_test.go b/shortcuts/im/convert_lib/thread_test.go index 25be088c5..2f4173691 100644 --- a/shortcuts/im/convert_lib/thread_test.go +++ b/shortcuts/im/convert_lib/thread_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "strings" + "sync" "testing" ) @@ -89,6 +90,201 @@ func TestFetchThreadRepliesError(t *testing.T) { } } +// TestExpandThreadRepliesMultiThreadConcurrent exercises the bounded-concurrency +// multi-thread path: every distinct thread_id gets its own GET fetched in +// parallel, and the right replies land on the right outer host (the *first* +// outer message that referenced each thread_id). A race or cross-thread +// result mix-up would manifest as missing / mis-attached replies. +func TestExpandThreadRepliesMultiThreadConcurrent(t *testing.T) { + var ( + mu sync.Mutex + callCount int + ) + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages") { + return nil, fmt.Errorf("unexpected path: %s", req.URL.Path) + } + tid := req.URL.Query().Get("container_id") + mu.Lock() + callCount++ + mu.Unlock() + // Return one synthetic reply per thread, tagged with the thread id so + // we can assert that the right replies landed on the right host. + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "items": []interface{}{ + map[string]interface{}{ + "message_id": "om_reply_" + tid, + "msg_type": "text", + "create_time": "1710500000", + "thread_id": tid, + "sender": map[string]interface{}{"name": "Sender"}, + "body": map[string]interface{}{"content": `{"text":"reply for ` + tid + `"}`}, + }, + }, + }, + }), nil + })) + + // 5 distinct thread roots → 5 planned fetches, dispatched under the + // concurrency cap. Enough to actually exercise the bounded fan-out + // rather than degenerate to the single-thread fast path. + messages := []map[string]interface{}{ + {"message_id": "om_root_1", "thread_id": "omt_a"}, + {"message_id": "om_root_2", "thread_id": "omt_b"}, + {"message_id": "om_root_3", "thread_id": "omt_c"}, + {"message_id": "om_root_4", "thread_id": "omt_d"}, + {"message_id": "om_root_5", "thread_id": "omt_e"}, + } + + ExpandThreadReplies(runtime, messages, map[string]string{}, 10, 500) + + if callCount != 5 { + t.Fatalf("expected 5 thread fetches, got %d", callCount) + } + for i, m := range messages { + tid := m["thread_id"].(string) + replies, ok := m["thread_replies"].([]map[string]interface{}) + if !ok { + t.Fatalf("message %d (thread %s) missing thread_replies: %#v", i, tid, m) + } + if len(replies) != 1 { + t.Fatalf("message %d (thread %s) replies len = %d, want 1", i, tid, len(replies)) + } + // Each thread's reply was tagged with its own thread_id; verify no + // goroutine cross-contamination. + gotTid, _ := replies[0]["thread_id"].(string) + if gotTid != tid { + t.Fatalf("message %d (thread %s) got reply tagged with thread_id=%q — cross-thread contamination", + i, tid, gotTid) + } + } +} + +// TestExpandThreadRepliesTotalLimitUsesActualCounts is a regression test for +// the budget-allocation refactor: the new concurrent path must deduct +// totalLimit using the *actual* returned reply count per thread, not the +// planned per-thread ceiling. Otherwise chats with many low-volume threads +// (very common — most threads in a busy group have just a few replies) +// silently drop later threads when the planned ceilings sum past totalLimit +// well before the actual replies do. +func TestExpandThreadRepliesTotalLimitUsesActualCounts(t *testing.T) { + // Synthetic API: every thread returns exactly 3 replies, regardless of + // the requested page_size. This is the "short threads" scenario where + // the difference between planned-ceiling and actual-count budget + // accounting becomes visible. + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + tid := req.URL.Query().Get("container_id") + items := make([]interface{}, 3) + for i := range items { + items[i] = map[string]interface{}{ + "message_id": fmt.Sprintf("om_reply_%s_%d", tid, i), + "msg_type": "text", + "create_time": "1710500000", + "thread_id": tid, + "sender": map[string]interface{}{"name": "Sender"}, + "body": map[string]interface{}{"content": `{"text":"hi"}`}, + } + } + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "items": items, + }, + }), nil + })) + + // 12 distinct thread roots × 3 actual replies each = 36 total. With + // perThread=50 (the default ceiling), the old "deduct planned ceiling" + // implementation would have exhausted totalLimit=100 after just 2 + // threads (2 × 50 = 100) and silently skipped the remaining 10. The + // correct behavior deducts actual counts (12 × 3 = 36 < 100), so all + // 12 threads should attach. + messages := make([]map[string]interface{}, 12) + for i := range messages { + messages[i] = map[string]interface{}{ + "message_id": fmt.Sprintf("om_root_%02d", i), + "thread_id": fmt.Sprintf("omt_%02d", i), + } + } + + ExpandThreadReplies(runtime, messages, map[string]string{}, 50, 100) + + for i, m := range messages { + replies, ok := m["thread_replies"].([]map[string]interface{}) + if !ok { + t.Fatalf("thread %d (%s) silently dropped — thread_replies missing despite actual budget headroom", + i, m["thread_id"]) + } + if len(replies) != 3 { + t.Fatalf("thread %d (%s) replies len = %d, want 3", i, m["thread_id"], len(replies)) + } + } +} + +// TestExpandThreadRepliesTruncatesOnBudgetBoundary covers the cross-boundary +// case: a thread whose actual replies straddle the remaining budget gets +// its slice truncated to fit and thread_has_more flagged so consumers know +// more exist server-side. +func TestExpandThreadRepliesTruncatesOnBudgetBoundary(t *testing.T) { + // Every thread returns exactly 4 replies. + runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { + tid := req.URL.Query().Get("container_id") + items := make([]interface{}, 4) + for i := range items { + items[i] = map[string]interface{}{ + "message_id": fmt.Sprintf("om_reply_%s_%d", tid, i), + "msg_type": "text", + "create_time": "1710500000", + "thread_id": tid, + "sender": map[string]interface{}{"name": "Sender"}, + "body": map[string]interface{}{"content": `{"text":"hi"}`}, + } + } + return convertlibJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "items": items, + }, + }), nil + })) + + // 3 threads × 4 replies = 12, but totalLimit = 10. So: + // - thread 0 fully attached (4 replies; running total 4) + // - thread 1 fully attached (4 replies; running total 8) + // - thread 2 truncated to 2 replies (running total 10), has_more=true + // - any thread 3+ would be dropped entirely + messages := []map[string]interface{}{ + {"message_id": "om_root_0", "thread_id": "omt_0"}, + {"message_id": "om_root_1", "thread_id": "omt_1"}, + {"message_id": "om_root_2", "thread_id": "omt_2"}, + } + + ExpandThreadReplies(runtime, messages, map[string]string{}, 10, 10) + + for i, want := range []int{4, 4, 2} { + replies, _ := messages[i]["thread_replies"].([]map[string]interface{}) + if len(replies) != want { + t.Fatalf("thread %d replies len = %d, want %d (post-budget truncation)", i, len(replies), want) + } + } + if messages[2]["thread_has_more"] != true { + t.Fatalf("thread 2 was truncated by budget but thread_has_more = %#v, want true", + messages[2]["thread_has_more"]) + } + // And the truncated host must NOT be flagged with thread_replies_error — + // budget truncation is success, not failure. + for i, m := range messages { + if v, _ := m["thread_replies_error"].(bool); v { + t.Fatalf("message %d incorrectly flagged with thread_replies_error after budget truncation: %#v", i, m) + } + } +} + func TestExpandThreadRepliesMarksFetchError(t *testing.T) { runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) { switch { diff --git a/shortcuts/im/coverage_additional_test.go b/shortcuts/im/coverage_additional_test.go index 244e30d11..e710f0126 100644 --- a/shortcuts/im/coverage_additional_test.go +++ b/shortcuts/im/coverage_additional_test.go @@ -102,9 +102,6 @@ func TestResolveMarkdownAsPost(t *testing.T) { if !strings.Contains(got, `"tag":"md"`) { t.Fatalf("resolveMarkdownAsPost() = %q, want post payload", got) } - if !strings.Contains(got, `"tag":"text"`) { - t.Fatalf("resolveMarkdownAsPost() = %q, want segmented blank-line text paragraph", got) - } if !strings.Contains(got, `#### Title`) || !strings.Contains(got, `##### Subtitle`) { t.Fatalf("resolveMarkdownAsPost() = %q, want optimized heading levels", got) } diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index a49e3b950..f75119219 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -543,7 +543,17 @@ func findMP4Box(data []byte, start, end int, boxType string) (int, int) { if offset+16 > end { return -1, -1 } - boxEnd = int(binary.BigEndian.Uint64(data[offset+8:])) + // 64-bit "largesize" is the whole box length including its 16-byte + // header, so the box ends at offset+largesize (mirroring the + // offset+size used for 32-bit boxes below). Reject sizes that do not + // fit the search window; this also rejects values that would + // overflow int and drive boxEnd negative (CWE-190), which would + // otherwise index data out of range and panic. + largesize := binary.BigEndian.Uint64(data[offset+8:]) + if largesize < 16 || largesize > uint64(end-offset) { + return -1, -1 + } + boxEnd = offset + int(largesize) dataStart = offset + 16 default: if size < 8 { @@ -688,7 +698,16 @@ func readMp4DurationBytes(data []byte) int64 { if offset+16 > fileSize { return 0 } - boxEnd = int64(binary.BigEndian.Uint64(data[offset+8 : offset+16])) + // 64-bit "largesize" is the whole box length including its 16-byte + // header, so the box ends at offset+largesize (mirroring offset+size + // for 32-bit boxes). Reject sizes that do not fit the file; this also + // rejects values that would overflow int64 and drive boxEnd negative + // (CWE-190), which would otherwise index data out of range and panic. + largesize := binary.BigEndian.Uint64(data[offset+8 : offset+16]) + if largesize < 16 || largesize > uint64(fileSize-offset) { + return 0 + } + boxEnd = offset + int64(largesize) dataStart = offset + 16 case size < 8: return 0 @@ -749,7 +768,16 @@ func readMp4Duration(f fileio.File, fileSize int64) int64 { if _, err := f.ReadAt(hdr[8:16], offset+8); err != nil { return 0 } - boxEnd = int64(binary.BigEndian.Uint64(hdr[8:16])) + // 64-bit "largesize" is the whole box length including its 16-byte + // header, so the box ends at offset+largesize (mirroring offset+size + // for 32-bit boxes). Reject sizes that do not fit the file; this also + // rejects values that would overflow int64 and drive boxEnd negative + // (CWE-190). + largesize := binary.BigEndian.Uint64(hdr[8:16]) + if largesize < 16 || largesize > uint64(fileSize-offset) { + return 0 + } + boxEnd = offset + int64(largesize) dataStart = offset + 16 case size < 8: return 0 @@ -789,49 +817,25 @@ func readMp4Duration(f fileio.File, fileSize int64) int64 { // 5. Compress excess blank lines // 6. Strip invalid image references (keep only img_xxx keys) var ( - reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`) - reH1 = regexp.MustCompile(`(?m)^# (.+)$`) - reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `) - reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`) - reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`) - reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`) - reExcessNL = regexp.MustCompile(`\n{3,}`) - reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`) - reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```") - reBlankLineSeparator = regexp.MustCompile(`\n(?:[ \t]*\n)+`) + reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`) + reH1 = regexp.MustCompile(`(?m)^# (.+)$`) + reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `) + reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`) + reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`) + reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`) + reExcessNL = regexp.MustCompile(`\n{3,}`) + reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`) + reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```") ) -const ( - markdownCodeBlockPlaceholder = "___CB_" - postBlankLinePlaceholder = "\u200B" -) - -type markdownPart struct { - text string - newlineCount int - isSeparator bool -} - -func protectMarkdownCodeBlocks(text string) (string, []string) { - var codeBlocks []string - protected := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string { - idx := len(codeBlocks) - codeBlocks = append(codeBlocks, m) - return fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, idx) - }) - return protected, codeBlocks -} - -func restoreMarkdownCodeBlocks(text string, codeBlocks []string) string { - restored := text - for i, block := range codeBlocks { - restored = strings.Replace(restored, fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, i), block, 1) - } - return restored -} - func optimizeMarkdownStyle(text string) string { - r, codeBlocks := protectMarkdownCodeBlocks(text) + const mark = "___CB_" + var codeBlocks []string + r := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string { + idx := len(codeBlocks) + codeBlocks = append(codeBlocks, m) + return fmt.Sprintf("%s%d___", mark, idx) + }) // Only downgrade when original text has H1~H3; order matters (H2~H6 first). if reHasH1toH3.MatchString(text) { @@ -844,7 +848,9 @@ func optimizeMarkdownStyle(text string) string { r = reTableNoGap.ReplaceAllString(r, "$1\n\n$2") r = reTableAfter.ReplaceAllString(r, "$1\n") - r = restoreMarkdownCodeBlocks(r, codeBlocks) + for i, block := range codeBlocks { + r = strings.Replace(r, fmt.Sprintf("%s%d___", mark, i), block, 1) + } r = reExcessNL.ReplaceAllString(r, "\n\n") @@ -863,109 +869,12 @@ func optimizeMarkdownStyle(text string) string { return r } -func shouldUseSegmentedPost(markdown string) bool { - protected, _ := protectMarkdownCodeBlocks(markdown) - return reBlankLineSeparator.MatchString(protected) -} - -func splitMarkdownByBlankLines(markdown string) []markdownPart { - protected, codeBlocks := protectMarkdownCodeBlocks(markdown) - locs := reBlankLineSeparator.FindAllStringIndex(protected, -1) - if len(locs) == 0 { - return []markdownPart{{text: markdown}} - } - - parts := make([]markdownPart, 0, len(locs)*2+1) - last := 0 - for _, loc := range locs { - if loc[0] > last { - content := restoreMarkdownCodeBlocks(protected[last:loc[0]], codeBlocks) - if content != "" { - parts = append(parts, markdownPart{text: content}) - } - } - separator := protected[loc[0]:loc[1]] - parts = append(parts, markdownPart{ - isSeparator: true, - newlineCount: strings.Count(separator, "\n"), - }) - last = loc[1] - } - - if last < len(protected) { - content := restoreMarkdownCodeBlocks(protected[last:], codeBlocks) - if content != "" { - parts = append(parts, markdownPart{text: content}) - } - } - - if len(parts) == 0 { - return []markdownPart{{text: markdown}} - } - return parts -} - -func marshalMarkdownPostContent(content [][]map[string]interface{}) string { - payload := map[string]interface{}{ - "zh_cn": map[string]interface{}{ - "content": content, - }, - } - data, _ := json.Marshal(payload) - return string(data) -} - -func buildSingleMDPost(markdown string) string { - return marshalMarkdownPostContent([][]map[string]interface{}{ - {{ - "tag": "md", - "text": optimizeMarkdownStyle(markdown), - }}, - }) -} - -func buildSegmentedPost(markdown string) string { - parts := splitMarkdownByBlankLines(markdown) - content := make([][]map[string]interface{}, 0, len(parts)) - for _, part := range parts { - if part.isSeparator { - for i := 1; i < part.newlineCount; i++ { - content = append(content, []map[string]interface{}{{ - "tag": "text", - "text": postBlankLinePlaceholder, - }}) - } - continue - } - if part.text == "" { - continue - } - optimized := strings.Trim(optimizeMarkdownStyle(part.text), "\n") - if optimized == "" { - continue - } - content = append(content, []map[string]interface{}{{ - "tag": "md", - "text": optimized, - }}) - } - if len(content) == 0 { - return buildSingleMDPost(markdown) - } - return marshalMarkdownPostContent(content) -} - -func buildMarkdownPostContent(markdown string) string { - if shouldUseSegmentedPost(markdown) { - return buildSegmentedPost(markdown) - } - return buildSingleMDPost(markdown) -} - // wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network). -// Used by DryRun. Output may include md/text paragraphs when blank-line separators are present. +// Used by DryRun. Output: {"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}} func wrapMarkdownAsPost(markdown string) string { - return buildMarkdownPostContent(markdown) + optimized := optimizeMarkdownStyle(markdown) + inner, _ := json.Marshal(optimized) + return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}` } var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\((https?://[^)\s]+)\)`) @@ -1000,7 +909,9 @@ func wrapMarkdownAsPostForDryRun(markdown string) (content, desc string) { // and wraps as post format JSON. Used by Execute (makes network calls). func resolveMarkdownAsPost(ctx context.Context, runtime *common.RuntimeContext, markdown string) string { resolved := resolveMarkdownImageURLs(ctx, runtime, markdown) - return buildMarkdownPostContent(resolved) + optimized := optimizeMarkdownStyle(resolved) + inner, _ := json.Marshal(optimized) + return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}` } // resolveMarkdownImageURLs finds ![alt](https://...) in markdown, downloads each URL, diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 114ae524f..3df5c3b54 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -6,7 +6,6 @@ package im import ( "context" "encoding/binary" - "encoding/json" "errors" "fmt" "net/http" @@ -17,36 +16,6 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -func decodePostContentForTest(t *testing.T, raw string) []interface{} { - t.Helper() - - var payload map[string]interface{} - if err := json.Unmarshal([]byte(raw), &payload); err != nil { - t.Fatalf("json.Unmarshal() error = %v, raw=%s", err, raw) - } - locale, _ := payload["zh_cn"].(map[string]interface{}) - content, _ := locale["content"].([]interface{}) - if content == nil { - t.Fatalf("post content missing: %#v", payload) - } - return content -} - -func decodePostParagraphForTest(t *testing.T, raw string, idx int) map[string]interface{} { - t.Helper() - - content := decodePostContentForTest(t, raw) - if idx >= len(content) { - t.Fatalf("paragraph index %d out of range, len=%d, raw=%s", idx, len(content), raw) - } - paragraph, _ := content[idx].([]interface{}) - if len(paragraph) != 1 { - t.Fatalf("paragraph %d = %#v, want single node", idx, paragraph) - } - node, _ := paragraph[0].(map[string]interface{}) - return node -} - func TestNormalizeAtMentions(t *testing.T) { input := ` hi and and ` got := normalizeAtMentions(input) @@ -171,16 +140,6 @@ func TestWrapMarkdownAsPostForDryRun(t *testing.T) { } } -func TestWrapMarkdownAsPostForDryRun_SegmentedBlankLines(t *testing.T) { - content, _ := wrapMarkdownAsPostForDryRun("hello\n\n![alt](https://example.com/a.png)") - if !strings.Contains(content, `![alt](img_dryrun_1)`) { - t.Fatalf("wrapMarkdownAsPostForDryRun(segmented) content = %q, want placeholder img key", content) - } - if !strings.Contains(content, `"tag":"text"`) { - t.Fatalf("wrapMarkdownAsPostForDryRun(segmented) content = %q, want blank-line text paragraph", content) - } -} - func TestResolveMediaContentWithoutUploads(t *testing.T) { tests := []struct { name string @@ -375,88 +334,15 @@ func TestOptimizeMarkdownStyle(t *testing.T) { func TestWrapMarkdownAsPost(t *testing.T) { got := wrapMarkdownAsPost("hello **world**") - content := decodePostContentForTest(t, got) - if len(content) != 1 { - t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content)) + // Should produce valid JSON with post structure + if !strings.Contains(got, `"tag":"md"`) { + t.Fatalf("wrapMarkdownAsPost() missing md tag: %s", got) } - node := decodePostParagraphForTest(t, got, 0) - if node["tag"] != "md" { - t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"]) + if !strings.Contains(got, `"zh_cn"`) { + t.Fatalf("wrapMarkdownAsPost() missing zh_cn: %s", got) } - if node["text"] != "hello **world**" { - t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**") - } -} - -func TestShouldUseSegmentedPost(t *testing.T) { - tests := []struct { - name string - markdown string - want bool - }{ - {name: "single newline", markdown: "a\nb", want: false}, - {name: "blank line", markdown: "a\n\nb", want: true}, - {name: "blank line with spaces", markdown: "a\n \nb", want: true}, - {name: "multiple blank lines", markdown: "a\n \n \n b", want: true}, - {name: "blank lines inside code block only", markdown: "```go\n\n\nfmt.Println(1)\n```\nnext", want: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := shouldUseSegmentedPost(tt.markdown); got != tt.want { - t.Fatalf("shouldUseSegmentedPost(%q) = %v, want %v", tt.markdown, got, tt.want) - } - }) - } -} - -func TestWrapMarkdownAsPost_SegmentedBlankLines(t *testing.T) { - got := wrapMarkdownAsPost("a\n\nb") - content := decodePostContentForTest(t, got) - if len(content) != 3 { - t.Fatalf("wrapMarkdownAsPost(a\\n\\nb) content len = %d, want 3", len(content)) - } - - first := decodePostParagraphForTest(t, got, 0) - if first["tag"] != "md" || first["text"] != "a" { - t.Fatalf("first paragraph = %#v, want md/a", first) - } - - second := decodePostParagraphForTest(t, got, 1) - if second["tag"] != "text" || second["text"] != postBlankLinePlaceholder { - t.Fatalf("second paragraph = %#v, want blank text placeholder", second) - } - - third := decodePostParagraphForTest(t, got, 2) - if third["tag"] != "md" || third["text"] != "b" { - t.Fatalf("third paragraph = %#v, want md/b", third) - } -} - -func TestWrapMarkdownAsPost_SegmentedMultipleBlankLines(t *testing.T) { - got := wrapMarkdownAsPost("a\n\n\nb") - content := decodePostContentForTest(t, got) - if len(content) != 4 { - t.Fatalf("wrapMarkdownAsPost(a\\n\\n\\nb) content len = %d, want 4", len(content)) - } - - for i := 1; i <= 2; i++ { - node := decodePostParagraphForTest(t, got, i) - if node["tag"] != "text" || node["text"] != postBlankLinePlaceholder { - t.Fatalf("blank paragraph %d = %#v, want blank text placeholder", i, node) - } - } -} - -func TestWrapMarkdownAsPost_SegmentedBlankLinesWithSpaces(t *testing.T) { - got := wrapMarkdownAsPost("a\n \nb") - content := decodePostContentForTest(t, got) - if len(content) != 3 { - t.Fatalf("wrapMarkdownAsPost(a\\n \\nb) content len = %d, want 3", len(content)) - } - node := decodePostParagraphForTest(t, got, 1) - if node["tag"] != "text" || node["text"] != postBlankLinePlaceholder { - t.Fatalf("middle paragraph = %#v, want blank text placeholder", node) + if !strings.Contains(got, "hello **world**") { + t.Fatalf("wrapMarkdownAsPost() missing content: %s", got) } } diff --git a/shortcuts/im/im_chat_list.go b/shortcuts/im/im_chat_list.go index 61b4a2870..028f741f0 100644 --- a/shortcuts/im/im_chat_list.go +++ b/shortcuts/im/im_chat_list.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io" + "strings" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" @@ -15,13 +16,31 @@ import ( // imChatListPath is the upstream HTTP path for the +chat-list shortcut. const imChatListPath = "/open-apis/im/v1/chats" +// bot_strip_p2p is the request-level adjustment notice emitted when bot +// identity receives a mixed --types containing "p2p": the p2p value is +// removed from the outgoing query (which the API would otherwise reject) +// and the caller is informed via a stderr warning + a structured entry +// in outData["notices"]. This is a notice, not a filter — it lives in a +// separate slot from outData["filter"] so the two never collide. +const ( + botStripP2pCode = "bot_strip_p2p" + botStripP2pMessage = "To protect user privacy, bot identity cannot list p2p chats; --types=p2p,group was sent as types=group. Use --as user to include p2p." +) + +// writeBotStripP2pWarning prints the bot_strip_p2p adjustment to stderr in +// the repo's standard "warning: : " form (matches the format +// used in shortcuts/common/runner.go's unknown-format fallback). +func writeBotStripP2pWarning(errOut io.Writer) { + fmt.Fprintf(errOut, "warning: %s: %s\n", botStripP2pCode, botStripP2pMessage) +} + // ImChatList is the +chat-list shortcut: wraps GET /open-apis/im/v1/chats to // list groups the current user/bot is a member of. Supports sort order, // pagination, and (user identity only) muted-chat filtering via --exclude-muted. var ImChatList = common.Shortcut{ Service: "im", Command: "+chat-list", - Description: "List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only)", + Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only)", Risk: "read", Scopes: []string{"im:chat:read"}, AuthTypes: []string{"user", "bot"}, @@ -29,28 +48,53 @@ var ImChatList = common.Shortcut{ 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: "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"}, {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"}, }, // DryRun previews the GET /open-apis/im/v1/chats request without executing. + // When bot identity strips p2p from --types, emits the same stderr warning + // Execute would emit, so DryRun output truthfully reflects what the API + // will receive (matches the shortcuts/drive/drive_search.go pattern of + // echoing request-level adjustments in both DryRun and Execute). DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + effective, stripped, _ := resolveTypes(runtime) // Validate has already guaranteed err == nil + if stripped { + writeBotStripP2pWarning(runtime.IO().ErrOut) + } return common.NewDryRunAPI(). GET(imChatListPath). - Params(buildChatListParams(runtime)) + Params(buildChatListParams(runtime, effective)) }, - // Validate enforces flag preconditions; only --page-size has bounds (1-100). + // Validate enforces flag preconditions: page-size bounds, --types element + // enum, and the bot + single-p2p rejection (mixed types degrade in Execute). Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if n := runtime.Int("page-size"); n < 1 || n > 100 { return output.ErrValidation("--page-size must be an integer between 1 and 100") } + parts, err := normalizeTypes(runtime.StrSlice("types")) + if err != nil { + return err + } + if len(parts) == 1 && parts[0] == "p2p" && runtime.IsBot() { + return output.ErrValidation( + `--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`) + } return nil }, // Execute fetches one page of chats, optionally applies --exclude-muted // via MaybeApplyMuteFilter, and renders the result. outData["filter"] is // populated only when --exclude-muted is set (backward compatible). + // outData["notices"] is populated only when bot identity strips p2p from + // --types — a request-level adjustment that lives in its own slot so it + // never collides with the row-level mute filter. Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - params := buildChatListParams(runtime) + effective, stripped, _ := resolveTypes(runtime) // Validate guarantees err == nil + if stripped { + writeBotStripP2pWarning(runtime.IO().ErrOut) + } + params := buildChatListParams(runtime, effective) resData, err := runtime.CallAPI("GET", imChatListPath, params, nil) if err != nil { return err @@ -88,6 +132,11 @@ var ImChatList = common.Shortcut{ if mfOut.Meta.Applied != "" { outData["filter"] = MuteFilterMetaToMap(mfOut.Meta) } + if stripped { + outData["notices"] = []map[string]interface{}{ + {"code": botStripP2pCode, "message": botStripP2pMessage}, + } + } runtime.OutFormat(outData, nil, func(w io.Writer) { if len(items) == 0 { @@ -115,6 +164,17 @@ var ImChatList = common.Shortcut{ if status, _ := m["chat_status"].(string); status != "" { row["chat_status"] = status } + if chatMode, _ := m["chat_mode"].(string); chatMode != "" { + row["chat_mode"] = chatMode + if chatMode == "p2p" { + if pt, _ := m["p2p_target_type"].(string); pt != "" { + row["p2p_target_type"] = pt + } + if pid, _ := m["p2p_target_id"].(string); pid != "" { + row["p2p_target_id"] = pid + } + } + } rows = append(rows, row) } output.PrintTable(w, rows) @@ -135,11 +195,76 @@ var ImChatList = common.Shortcut{ }, } -// buildChatListParams builds the query parameters for the GET /im/v1/chats -// call from the runtime flag values. user_id_type and sort_type are always -// present (their flag defaults are non-empty); page_token is omitted when -// empty; page_size falls back to the API default of 20 when not provided. -func buildChatListParams(runtime *common.RuntimeContext) map[string]interface{} { +// normalizeTypes validates and normalizes the --types slice already parsed by cobra. +// cobra's StringSlice handles the CSV split automatically — both --types=p2p,group +// and repeated --types p2p --types group arrive here as a 2-element []string, +// so this function never re-splits on commas. +// Returns the normalized (lowercased, deduped, in input order) parts on success. +// Empty raw input is a no-op (returns nil, nil). +// Returns ErrValidation when any element is empty or outside {"p2p", "group"}. +func normalizeTypes(raw []string) ([]string, error) { + if len(raw) == 0 { + return nil, nil + } + seen := make(map[string]struct{}, len(raw)) + out := make([]string, 0, len(raw)) + for _, p := range raw { + p = strings.TrimSpace(strings.ToLower(p)) + if p == "" { + return nil, output.ErrValidation("--types must contain at least one of p2p, group") + } + if p != "p2p" && p != "group" { + return nil, output.ErrValidation("--types contains invalid value %q: expected one of p2p, group", p) + } + if _, dup := seen[p]; dup { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + return out, nil +} + +// resolveTypes layers bot identity downgrade on top of normalizeTypes. +// Under bot identity, "p2p" is stripped from the parts and the caller is +// informed (DryRun / Execute emit a stderr warning; Execute additionally +// writes a structured entry under outData["notices"]). +// Validate has already rejected "bot + parts == ['p2p']" cases, so kept is +// never empty here. +// +// Returns (effective CSV, stripped, err): +// - effective: comma-joined types to send as the API query param +// - stripped: true iff bot identity removed "p2p" from a mixed --types value +// - err: forwarded from normalizeTypes +func resolveTypes(runtime *common.RuntimeContext) (string, bool, error) { + parts, err := normalizeTypes(runtime.StrSlice("types")) + if err != nil { + return "", false, err + } + if !runtime.IsBot() { + return strings.Join(parts, ","), false, nil + } + // Bot identity: strip "p2p" so the API call succeeds with just groups. + // Validate has already rejected the "bot + only p2p" case, so kept is never empty here. + // Allocate a fresh slice (rather than aliasing parts[:0]) — parts has at most 2 + // elements so the cost is negligible, and avoiding shared backing storage removes + // a class of "two slices, same array" surprises if a future caller keeps parts. + stripped := false + kept := make([]string, 0, len(parts)) + for _, p := range parts { + if p == "p2p" { + stripped = true + continue + } + kept = append(kept, p) + } + return strings.Join(kept, ","), stripped, nil +} + +// buildChatListParams builds the query parameters. effectiveTypes is the +// 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{} { params := map[string]interface{}{ "user_id_type": runtime.Str("user-id-type"), "sort_type": runtime.Str("sort-type"), @@ -152,5 +277,8 @@ func buildChatListParams(runtime *common.RuntimeContext) map[string]interface{} if pt := runtime.Str("page-token"); pt != "" { params["page_token"] = pt } + if effectiveTypes != "" { + params["types"] = effectiveTypes + } return params } diff --git a/shortcuts/im/im_chat_list_test.go b/shortcuts/im/im_chat_list_test.go index a99348262..65431526b 100644 --- a/shortcuts/im/im_chat_list_test.go +++ b/shortcuts/im/im_chat_list_test.go @@ -4,26 +4,41 @@ package im import ( + "bytes" "context" + "encoding/json" + "io" + "net/http" + "strconv" "strings" "testing" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) -// newChatListTestRuntimeContext mirrors newMessagesSearchTestRuntimeContext — -// it registers page-size as Int (the existing newTestRuntimeContext registers -// it as String, which would short-circuit our buildChatListParams logic). +// newChatListTestRuntimeContext registers flags and returns a user-identity runtime context. func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext { + return newChatListTestRuntimeContextWithIdentity(t, stringFlags, boolFlags, core.AsUser) +} + +// newChatListTestRuntimeContextWithIdentity is the identity-aware variant. +func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool, as core.Identity) *common.RuntimeContext { t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) cmd := &cobra.Command{Use: "test"} cmd.Flags().Int("page-size", 20, "") for name := range stringFlags { if name == "page-size" { continue } - cmd.Flags().String(name, "", "") + if name == "types" { + cmd.Flags().StringSlice(name, nil, "") + } else { + cmd.Flags().String(name, "", "") + } } for name := range boolFlags { cmd.Flags().Bool(name, false, "") @@ -37,11 +52,22 @@ func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string, } } for name, val := range boolFlags { - if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil { + if err := cmd.Flags().Set(name, strconv.FormatBool(val)); err != nil { t.Fatalf("Flags().Set(%q) error = %v", name, err) } } - return &common.RuntimeContext{Cmd: cmd} + rt := common.TestNewRuntimeContextWithIdentity(cmd, nil, as) + // Attach a minimal Factory with IOStreams so DryRun / Execute paths that + // emit stderr warnings (e.g. bot_strip_p2p) don't panic on runtime.IO(). + // Stays pure-logic — no HTTP client, no httpmock; integration tests use + // newBotShortcutRuntime / newUserShortcutRuntime for that. + rt.Factory = &cmdutil.Factory{ + IOStreams: &cmdutil.IOStreams{ + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + }, + } + return rt } func TestBuildChatListParams_Defaults(t *testing.T) { @@ -49,7 +75,7 @@ func TestBuildChatListParams_Defaults(t *testing.T) { "user-id-type": "open_id", "sort-type": "ByCreateTimeAsc", }, nil) - got := buildChatListParams(rt) + got := buildChatListParams(rt, "") if got["user_id_type"] != "open_id" { t.Fatalf("user_id_type = %v", got["user_id_type"]) } @@ -62,6 +88,9 @@ func TestBuildChatListParams_Defaults(t *testing.T) { if _, present := got["page_token"]; present { t.Fatalf("page_token should be omitted when empty") } + if _, present := got["types"]; present { + t.Fatalf("types should be omitted when --types is empty") + } } func TestBuildChatListParams_Overrides(t *testing.T) { @@ -71,7 +100,7 @@ func TestBuildChatListParams_Overrides(t *testing.T) { "page-size": "50", "page-token": "tok_xyz", }, nil) - got := buildChatListParams(rt) + got := buildChatListParams(rt, "") if got["user_id_type"] != "user_id" { t.Fatalf("user_id_type = %v", got["user_id_type"]) } @@ -126,3 +155,459 @@ func TestImChatList_DryRun_IncludesEndpoint(t *testing.T) { t.Fatalf("DryRun missing page_size: %s", got) } } + +func TestNormalizeTypes(t *testing.T) { + cases := []struct { + name string + raw []string + want []string + wantErr string // substring match + }{ + {"empty returns nil no error", nil, nil, ""}, + {"single p2p", []string{"p2p"}, []string{"p2p"}, ""}, + {"single group", []string{"group"}, []string{"group"}, ""}, + {"p2p,group preserves order", []string{"p2p", "group"}, []string{"p2p", "group"}, ""}, + {"group,p2p preserves order", []string{"group", "p2p"}, []string{"group", "p2p"}, ""}, + {"trim whitespace", []string{" p2p ", " group "}, []string{"p2p", "group"}, ""}, + {"lowercase", []string{"P2P", "GROUP"}, []string{"p2p", "group"}, ""}, + {"dedupe", []string{"p2p", "p2p"}, []string{"p2p"}, ""}, + {"empty element rejected", []string{""}, nil, "must contain at least one of p2p, group"}, + {"invalid element rejected", []string{"private"}, nil, `expected one of p2p, group`}, + {"mixed invalid rejected", []string{"p2p", "private"}, nil, `expected one of p2p, group`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := normalizeTypes(c.raw) + if c.wantErr != "" { + if err == nil { + t.Fatalf("normalizeTypes(%v) err = nil; want substring %q", c.raw, c.wantErr) + } + if !strings.Contains(err.Error(), c.wantErr) { + t.Fatalf("normalizeTypes(%v) err = %v; want substring %q", c.raw, err, c.wantErr) + } + return + } + if err != nil { + t.Fatalf("normalizeTypes(%v) unexpected err = %v", c.raw, err) + } + if len(got) != len(c.want) { + t.Fatalf("normalizeTypes(%v) = %v; want %v", c.raw, got, c.want) + } + for i := range got { + if got[i] != c.want[i] { + t.Fatalf("normalizeTypes(%v)[%d] = %q; want %q", c.raw, i, got[i], c.want[i]) + } + } + }) + } +} + +func TestResolveTypes(t *testing.T) { + cases := []struct { + name string + raw string + as core.Identity + wantEffective string + wantStripped bool + }{ + {"user empty", "", core.AsUser, "", false}, + {"user p2p", "p2p", core.AsUser, "p2p", false}, + {"user p2p,group", "p2p,group", core.AsUser, "p2p,group", false}, + {"user group,p2p preserves order", "group,p2p", core.AsUser, "group,p2p", false}, + {"user normalized casing", "P2P,GROUP", core.AsUser, "p2p,group", false}, + {"bot empty", "", core.AsBot, "", false}, + {"bot group only", "group", core.AsBot, "group", false}, + {"bot p2p,group strips p2p", "p2p,group", core.AsBot, "group", true}, + {"bot group,p2p strips p2p", "group,p2p", core.AsBot, "group", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rt := newChatListTestRuntimeContextWithIdentity(t, map[string]string{"types": c.raw}, nil, c.as) + effective, stripped, err := resolveTypes(rt) + if err != nil { + t.Fatalf("resolveTypes() unexpected err = %v", err) + } + if effective != c.wantEffective { + t.Fatalf("effective = %q; want %q", effective, c.wantEffective) + } + if stripped != c.wantStripped { + t.Fatalf("stripped = %v; want %v", stripped, c.wantStripped) + } + }) + } +} + +func TestBuildChatListParams_TypesPassthrough(t *testing.T) { + rt := newChatListTestRuntimeContext(t, map[string]string{ + "user-id-type": "open_id", + "sort-type": "ByCreateTimeAsc", + }, nil) + got := buildChatListParams(rt, "p2p,group") + if got["types"] != "p2p,group" { + t.Fatalf("types = %v; want \"p2p,group\"", got["types"]) + } +} + +func TestImChatList_Validate_Types(t *testing.T) { + cases := []struct { + name string + typesRaw string + as core.Identity + wantErr string // substring; "" means no error + }{ + {"user empty ok", "", core.AsUser, ""}, + {"user p2p ok", "p2p", core.AsUser, ""}, + {"user group ok", "group", core.AsUser, ""}, + {"user p2p,group ok", "p2p,group", core.AsUser, ""}, + {"user invalid element rejected", "private", core.AsUser, "expected one of p2p, group"}, + {"user comma-only rejected", ",", core.AsUser, "must contain at least one of p2p, group"}, + {"bot empty ok", "", core.AsBot, ""}, + {"bot group ok", "group", core.AsBot, ""}, + {"bot p2p,group ok (degraded at Execute)", "p2p,group", core.AsBot, ""}, + {"bot single p2p rejected", "p2p", core.AsBot, "only supported with user identity"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rt := newChatListTestRuntimeContextWithIdentity(t, + map[string]string{"types": c.typesRaw, "page-size": "20"}, + nil, c.as) + err := ImChatList.Validate(context.Background(), rt) + if c.wantErr == "" { + if err != nil { + t.Fatalf("Validate() unexpected err = %v", err) + } + return + } + if err == nil { + t.Fatalf("Validate() err = nil; want substring %q", c.wantErr) + } + if !strings.Contains(err.Error(), c.wantErr) { + t.Fatalf("Validate() err = %v; want substring %q", err, c.wantErr) + } + }) + } +} + +// attachChatListCmd builds a cobra.Command pre-loaded with all flags ImChatList +// reads, applies stringFlags / boolFlags, and assigns it to runtime.Cmd. Format +// is forced to "json" so Execute output lands in a parseable form on +// runtime.Factory.IOStreams.Out. +func attachChatListCmd(t *testing.T, runtime *common.RuntimeContext, stringFlags map[string]string, boolFlags map[string]bool) { + t.Helper() + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int("page-size", 20, "") + cmd.Flags().String("user-id-type", "open_id", "") + cmd.Flags().String("sort-type", "ByCreateTimeAsc", "") + cmd.Flags().StringSlice("types", nil, "") + cmd.Flags().String("page-token", "", "") + cmd.Flags().Bool("exclude-muted", false, "") + cmd.Flags().Bool("dry-run", false, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + for name, val := range stringFlags { + if err := cmd.Flags().Set(name, val); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + for name, val := range boolFlags { + if err := cmd.Flags().Set(name, strconv.FormatBool(val)); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + runtime.Cmd = cmd + runtime.Format = "json" +} + +// chatListOutBuf retrieves the captured stdout buffer for assertions. +func chatListOutBuf(t *testing.T, runtime *common.RuntimeContext) *bytes.Buffer { + t.Helper() + buf, ok := runtime.Factory.IOStreams.Out.(*bytes.Buffer) + if !ok { + t.Fatalf("expected IOStreams.Out to be *bytes.Buffer") + } + return buf +} + +// chatListErrBuf retrieves the captured stderr buffer for assertions +// (used to verify request-level warnings like `bot_strip_p2p`). +func chatListErrBuf(t *testing.T, runtime *common.RuntimeContext) *bytes.Buffer { + t.Helper() + buf, ok := runtime.Factory.IOStreams.ErrOut.(*bytes.Buffer) + if !ok { + t.Fatalf("expected IOStreams.ErrOut to be *bytes.Buffer") + } + return buf +} + +func TestImChatList_Execute_BotStripsP2p(t *testing.T) { + var capturedURL string + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + capturedURL = req.URL.String() + body := `{"code":0,"msg":"ok","data":{"items":[{"chat_id":"oc_g","name":"G","chat_mode":"group"}],"has_more":false,"page_token":""}}` + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + })) + attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil) + + if err := ImChatList.Execute(context.Background(), rt); err != nil { + t.Fatalf("Execute() err = %v", err) + } + + if !strings.Contains(capturedURL, "types=group") { + t.Fatalf("request URL = %s; want types=group (bot strips p2p)", capturedURL) + } + if strings.Contains(capturedURL, "p2p") { + t.Fatalf("request URL = %s; must NOT contain p2p (bot stripped it)", capturedURL) + } + + // Structured notice: outData["notices"] contains a {code, message} entry + // for bot_strip_p2p (request-level adjustment, not a row-level filter). + out := chatListOutBuf(t, rt).String() + for _, want := range []string{`"notices"`, `"code": "bot_strip_p2p"`, `"message"`} { + if !strings.Contains(out, want) { + t.Fatalf("stdout JSON missing notice field %q:\n%s", want, out) + } + } + // filter slot must remain mute-scoped: bot_strip_p2p must not leak into + // outData["filter"].applied (no priority conflict by design). + if strings.Contains(out, `"applied": "bot_strip_p2p"`) { + t.Fatalf("bot_strip_p2p should not appear in filter.applied (separate slot):\n%s", out) + } + + // Stderr: matches repo `warning: : ` convention (cf. + // shortcuts/common/runner.go unknown-format fallback). + errOut := chatListErrBuf(t, rt).String() + if !strings.Contains(errOut, "warning: bot_strip_p2p:") { + t.Fatalf("stderr missing `warning: bot_strip_p2p:` prefix:\n%s", errOut) + } +} + +// TestImChatList_DryRun_BotStripsP2pStderrNotice verifies the DryRun branch +// also emits the bot_strip_p2p warning to stderr so a previewed request +// truthfully reflects what Execute would send (drive_search.go DryRun parity). +func TestImChatList_DryRun_BotStripsP2pStderrNotice(t *testing.T) { + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatalf("DryRun should not make HTTP calls; got: %s", req.URL.String()) + return nil, nil + })) + attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil) + + dr := ImChatList.DryRun(context.Background(), rt) + if dr == nil { + t.Fatalf("DryRun returned nil") + } + + errOut := chatListErrBuf(t, rt).String() + if !strings.Contains(errOut, "warning: bot_strip_p2p:") { + t.Fatalf("DryRun stderr missing `warning: bot_strip_p2p:` prefix:\n%s", errOut) + } +} + +func TestImChatList_RowRendering_P2pFields(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body := `{"code":0,"msg":"ok","data":{"items":[ + {"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner"}, + {"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"} + ],"has_more":false,"page_token":""}}` + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + })) + attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil) + + if err := ImChatList.Execute(context.Background(), rt); err != nil { + t.Fatalf("Execute() err = %v", err) + } + + out := chatListOutBuf(t, rt).String() + for _, want := range []string{"oc_g", "oc_p", "Group", "Peer", `"chat_mode": "p2p"`, `"p2p_target_id": "ou_peer"`} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q; got: %s", want, out) + } + } +} + +// TestImChatList_Execute_PrettyOutputRendersP2pRow exercises the pretty-format +// rendering closure in Execute, including the new chat_mode=="p2p" branch that +// surfaces p2p_target_type / p2p_target_id, and the has_more footer that +// echoes back the page_token. +func TestImChatList_Execute_PrettyOutputRendersP2pRow(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body := `{"code":0,"msg":"ok","data":{"items":[ + {"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner","description":"a group","external":false,"chat_status":"normal"}, + {"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"} + ],"has_more":true,"page_token":"next_tok"}}` + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + })) + attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil) + rt.Format = "pretty" + + if err := ImChatList.Execute(context.Background(), rt); err != nil { + t.Fatalf("Execute() err = %v", err) + } + + out := chatListOutBuf(t, rt).String() + for _, want := range []string{"oc_g", "Group", "a group", "ou_owner", "normal"} { + if !strings.Contains(out, want) { + t.Fatalf("pretty output missing group-row field %q:\n%s", want, out) + } + } + for _, want := range []string{"oc_p", "Peer", "p2p", "ou_peer"} { + if !strings.Contains(out, want) { + t.Fatalf("pretty output missing p2p-row field %q:\n%s", want, out) + } + } + if !strings.Contains(out, "2 chat(s) listed") { + t.Fatalf("pretty output missing footer count:\n%s", out) + } + if !strings.Contains(out, "next_tok") { + t.Fatalf("pretty output missing page_token in has_more footer:\n%s", out) + } +} + +func TestImChatList_DryRun_TypesPassthrough(t *testing.T) { + cases := []struct { + name string + as core.Identity + typesRaw string + wantSub string // substring expected in dry-run JSON + wantErr bool // whether Validate should reject before DryRun runs + }{ + {"user p2p", core.AsUser, "p2p", `"types":"p2p"`, false}, + {"user p2p,group", core.AsUser, "p2p,group", `"types":"p2p,group"`, false}, + {"bot p2p,group strips to group", core.AsBot, "p2p,group", `"types":"group"`, false}, + {"bot group passes", core.AsBot, "group", `"types":"group"`, false}, + {"bot single p2p rejected at Validate", core.AsBot, "p2p", "", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rt := newChatListTestRuntimeContextWithIdentity(t, map[string]string{ + "user-id-type": "open_id", + "sort-type": "ByCreateTimeAsc", + "page-size": "20", + "types": c.typesRaw, + }, nil, c.as) + if err := ImChatList.Validate(context.Background(), rt); err != nil { + if !c.wantErr { + t.Fatalf("Validate() unexpected err = %v", err) + } + return + } + if c.wantErr { + t.Fatalf("Validate() err = nil; want rejection") + } + got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), rt)) + if !strings.Contains(got, c.wantSub) { + t.Fatalf("DryRun = %s; want substring %q", got, c.wantSub) + } + }) + } +} + +func TestImChatList_RowRendering_ChatModeAbsent(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + // Response items deliberately omit chat_mode / p2p_target_* (legacy/defensive case). + body := `{"code":0,"msg":"ok","data":{"items":[ + {"chat_id":"oc_g1","name":"Group1","owner_id":"ou_owner"}, + {"chat_id":"oc_g2","name":"Group2","external":true} + ],"has_more":false,"page_token":""}}` + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + })) + attachChatListCmd(t, rt, nil, nil) // no --types; default behavior + + if err := ImChatList.Execute(context.Background(), rt); err != nil { + t.Fatalf("Execute() err = %v", err) + } + + out := chatListOutBuf(t, rt).String() + // chat_mode / p2p_target_* must NOT appear since the API didn't return them. + for _, forbidden := range []string{`"chat_mode"`, `"p2p_target_type"`, `"p2p_target_id"`} { + // "chats[].chat_mode" is the row-level field — JSON envelope might include it as null or omit it; + // asserting the rendered table fields are missing is the goal. + // The JSON pass-through preserves whatever API returned (omitted here), + // so neither path should produce these strings. + if strings.Contains(out, forbidden) { + t.Fatalf("output unexpectedly contains %q (should not appear when API omitted these fields); got: %s", forbidden, out) + } + } + // Sanity: the two chat IDs must still be present (renderer didn't crash). + for _, want := range []string{"oc_g1", "oc_g2", "Group1", "Group2"} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q; got: %s", want, out) + } + } +} + +func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + path := req.URL.Path + switch { + case strings.HasSuffix(path, "/im/v1/chats"): + body := `{"code":0,"msg":"ok","data":{"items":[ + {"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner"}, + {"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"} + ],"has_more":false,"page_token":""}}` + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + case strings.HasSuffix(path, "/chat_user_setting/batch_get_mute_status"): + // Mark oc_p (the p2p) as muted; oc_g not muted. + body := `{"code":0,"msg":"ok","data":{"items":[ + {"chat_id":"oc_g","is_muted":false}, + {"chat_id":"oc_p","is_muted":true} + ]}}` + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + } + t.Fatalf("unexpected request path: %s", path) + return nil, nil + })) + attachChatListCmd(t, rt, + map[string]string{"types": "p2p,group"}, + map[string]bool{"exclude-muted": true}) + + if err := ImChatList.Execute(context.Background(), rt); err != nil { + t.Fatalf("Execute() err = %v", err) + } + + out := chatListOutBuf(t, rt).String() + + var parsed struct { + Data struct { + Chats []map[string]interface{} `json:"chats"` + Filter struct { + Applied string `json:"applied"` + FilteredCount int `json:"filtered_count"` + } `json:"filter"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("Unmarshal output failed: %v; raw: %s", err, out) + } + if parsed.Data.Filter.Applied != "exclude_muted" { + t.Fatalf("filter.applied = %q; want exclude_muted (no bot_strip_p2p under user). Raw: %s", + parsed.Data.Filter.Applied, out) + } + if parsed.Data.Filter.FilteredCount != 1 { + t.Fatalf("filter.filtered_count = %d; want 1 (the muted p2p row). Raw: %s", + parsed.Data.Filter.FilteredCount, out) + } + // The muted p2p row should be gone from chats; only oc_g remains. + if len(parsed.Data.Chats) != 1 { + t.Fatalf("expected 1 chat after muting; got %d. Raw: %s", len(parsed.Data.Chats), out) + } + if parsed.Data.Chats[0]["chat_id"] != "oc_g" { + t.Fatalf("remaining chat = %v; want oc_g", parsed.Data.Chats[0]["chat_id"]) + } +} diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index bb247a9b5..a32e2d74b 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -22,8 +22,8 @@ var ImChatMessageList = common.Shortcut{ Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination", Risk: "read", Scopes: []string{"im:message:readonly"}, - UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.base:readonly"}, - BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly"}, + UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.base:readonly"}, + BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ @@ -34,6 +34,7 @@ var ImChatMessageList = common.Shortcut{ {Name: "sort", Default: "desc", Desc: "sort order", 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)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { d := common.NewDryRunAPI() @@ -54,7 +55,12 @@ var ImChatMessageList = common.Shortcut{ dryParams[k] = vs[0] } } - return d.GET("/open-apis/im/v1/messages").Params(dryParams) + d = d.GET("/open-apis/im/v1/messages").Params(dryParams) + if !runtime.Bool("no-reactions") { + d = d.POST("/open-apis/im/v1/messages/reactions/batch_query"). + Desc("Reaction enrichment: queries returned messages (including thread_replies expanded inline) in batches of up to 20. Pass --no-reactions to skip.") + } + return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { // Under bot identity, --user-id is not supported; require --chat-id only. @@ -111,16 +117,28 @@ var ImChatMessageList = common.Shortcut{ hasMore, nextPageToken := common.PaginationMeta(data) nameCache := make(map[string]string) + // Pre-fetch merge_forward sub-messages concurrently before the per-item + // conversion loop. Each merge_forward in the page would otherwise issue + // its own serial GET inside FormatMessageItem; N merge_forwards turned + // into N × ~1s of stall. Passing nameCache also lets the prefetch + // batch-resolve every sub-item's sender open_id in one contact API + // call, so the per-merge_forward render path doesn't fan out N more + // serial contact requests during the FormatMessageItem loop. + mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache) + messages := make([]map[string]interface{}, 0, len(rawItems)) for _, item := range rawItems { m, _ := item.(map[string]interface{}) - messages = append(messages, convertlib.FormatMessageItem(m, runtime, nameCache)) + messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch)) } // Enrich: resolve sender names for outer messages (reuses cache from merge_forward) convertlib.ResolveSenderNames(runtime, messages, nameCache) convertlib.AttachSenderNames(messages, nameCache) convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit) + if !runtime.Bool("no-reactions") { + convertlib.EnrichReactions(runtime, messages) + } outData := map[string]interface{}{ "messages": messages, diff --git a/shortcuts/im/im_messages_mget.go b/shortcuts/im/im_messages_mget.go index a8d9ade72..866d63595 100644 --- a/shortcuts/im/im_messages_mget.go +++ b/shortcuts/im/im_messages_mget.go @@ -22,16 +22,22 @@ var ImMessagesMGet = common.Shortcut{ Description: "Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies", Risk: "read", Scopes: []string{"im:message:readonly"}, - UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.basic_profile:readonly"}, - BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base:readonly"}, + UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"}, + BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ {Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)", Required: true}, + {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { ids := common.SplitCSV(runtime.Str("message-ids")) - return common.NewDryRunAPI().GET(buildMGetURL(ids)) + d := common.NewDryRunAPI().GET(buildMGetURL(ids)) + if !runtime.Bool("no-reactions") { + d = d.POST("/open-apis/im/v1/messages/reactions/batch_query"). + Desc("Reaction enrichment: queries returned messages in batches of up to 20 to attach the reactions block (operator, action_time, counts). Pass --no-reactions to skip.") + } + return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { ids := common.SplitCSV(runtime.Str("message-ids")) @@ -60,15 +66,25 @@ var ImMessagesMGet = common.Shortcut{ rawItems, _ := data["items"].([]interface{}) nameCache := make(map[string]string) + // Pre-fetch merge_forward sub-messages concurrently before the per-item + // conversion loop, so N merge_forwards in the input don't serialize + // into N × ~1s of stall inside FormatMessageItem. Passing nameCache + // also pre-resolves every sub-item's sender open_id in one batched + // contact API call. + mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache) + messages := make([]map[string]interface{}, 0, len(rawItems)) for _, item := range rawItems { m, _ := item.(map[string]interface{}) - messages = append(messages, convertlib.FormatMessageItem(m, runtime, nameCache)) + messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch)) } convertlib.ResolveSenderNames(runtime, messages, nameCache) convertlib.AttachSenderNames(messages, nameCache) convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit) + if !runtime.Bool("no-reactions") { + convertlib.EnrichReactions(runtime, messages) + } outData := map[string]interface{}{ "messages": messages, diff --git a/shortcuts/im/im_messages_search.go b/shortcuts/im/im_messages_search.go index 48dbca06d..af0e9621b 100644 --- a/shortcuts/im/im_messages_search.go +++ b/shortcuts/im/im_messages_search.go @@ -30,7 +30,7 @@ var ImMessagesSearch = common.Shortcut{ Command: "+messages-search", Description: "Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query", Risk: "read", - Scopes: []string{"search:message", "contact:user.basic_profile:readonly"}, + Scopes: []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ @@ -49,6 +49,7 @@ var ImMessagesSearch = common.Shortcut{ {Name: "page-token", Desc: "page token"}, {Name: "page-all", Type: "bool", Desc: "automatically paginate search results"}, {Name: "page-limit", Type: "int", Default: "20", Desc: "max search pages when auto-pagination is enabled (default 20, max 40)"}, + {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { req, err := buildMessagesSearchRequest(runtime) @@ -68,12 +69,17 @@ var ImMessagesSearch = common.Shortcut{ } else { d = d.Desc("Step 1: search messages") } - return d. + d = d. POST("/open-apis/im/v1/messages/search"). Params(dryParams). Body(req.body). Desc("Step 2 (if results): GET /open-apis/im/v1/messages/mget?message_ids=... — batch fetch message details (max 50)"). Desc("Step 3 (if results): POST /open-apis/im/v1/chats/batch_query — fetch chat names for context") + if !runtime.Bool("no-reactions") { + d = d.POST("/open-apis/im/v1/messages/reactions/batch_query"). + Desc("Step 4 (if results): reaction enrichment in batches of up to 20 messages. Pass --no-reactions to skip.") + } + return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { _, err := buildMessagesSearchRequest(runtime) @@ -153,13 +159,19 @@ var ImMessagesSearch = common.Shortcut{ // ── Step 4: Format message content + attach chat context ── nameCache := make(map[string]string) + // Pre-fetch merge_forward sub-messages concurrently before the per-item + // conversion loop, so N merge_forwards in the search hits don't + // serialize into N × ~1s of stall inside FormatMessageItem. Passing + // nameCache also pre-resolves every sub-item's sender open_id in one + // batched contact API call. + mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, msgItems, nameCache) enriched := make([]map[string]interface{}, 0, len(msgItems)) for _, item := range msgItems { m, _ := item.(map[string]interface{}) chatId, _ := m["chat_id"].(string) // Reuse unified content converter - msg := convertlib.FormatMessageItem(m, runtime, nameCache) + msg := convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch) if chatId != "" { msg["chat_id"] = chatId } @@ -184,6 +196,9 @@ var ImMessagesSearch = common.Shortcut{ // Enrich: resolve sender names for outer messages (reuses cache from merge_forward) convertlib.ResolveSenderNames(runtime, enriched, nameCache) convertlib.AttachSenderNames(enriched, nameCache) + if !runtime.Bool("no-reactions") { + convertlib.EnrichReactions(runtime, enriched) + } outData := map[string]interface{}{ "messages": enriched, diff --git a/shortcuts/im/im_messages_send.go b/shortcuts/im/im_messages_send.go index 4b8a30984..680672744 100644 --- a/shortcuts/im/im_messages_send.go +++ b/shortcuts/im/im_messages_send.go @@ -81,10 +81,14 @@ var ImMessagesSend = common.Shortcut{ if desc != "" { d.Desc(desc) } - return d. + d. POST("/open-apis/im/v1/messages"). Params(map[string]interface{}{"receive_id_type": receiveIdType}). Body(body) + if chatFlag != "" { + d.Desc("NOTE: dry-run validates request shape only. Bot/user membership in the target chat is not verified; the real send may fail with `Bot/User can NOT be out of the chat`.") + } + return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { chatFlag := runtime.Str("chat-id") diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index 538117a94..5a2c11cba 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -24,8 +24,8 @@ var ImThreadsMessagesList = common.Shortcut{ Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination", Risk: "read", Scopes: []string{"im:message:readonly"}, - UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.basic_profile:readonly"}, - BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base:readonly"}, + UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"}, + BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ @@ -33,6 +33,7 @@ var ImThreadsMessagesList = common.Shortcut{ {Name: "sort", Default: "asc", Desc: "sort order", Enum: []string{"asc", "desc"}}, {Name: "page-size", Default: "50", Desc: "page size (1-500)"}, {Name: "page-token", Desc: "page token"}, + {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { threadFlag := runtime.Str("thread") @@ -65,10 +66,15 @@ var ImThreadsMessagesList = common.Shortcut{ params["page_token"] = pageToken } - return d. + d = d. GET("/open-apis/im/v1/messages"). Params(params). Set("thread", threadFlag).Set("sort", sortFlag).Set("page_size", pageSizeStr) + if !runtime.Bool("no-reactions") { + d = d.POST("/open-apis/im/v1/messages/reactions/batch_query"). + Desc("Reaction enrichment: queries returned thread messages in batches of up to 20. Pass --no-reactions to skip.") + } + return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { threadId := runtime.Str("thread") @@ -115,15 +121,25 @@ var ImThreadsMessagesList = common.Shortcut{ hasMore, nextPageToken := common.PaginationMeta(data) nameCache := make(map[string]string) + // Pre-fetch merge_forward sub-messages concurrently before the per-item + // conversion loop. Thread replies that are themselves merge_forward + // messages would otherwise issue serial GETs inside FormatMessageItem. + // Passing nameCache also pre-resolves every sub-item's sender open_id + // in one batched contact API call. + mergePrefetch := convertlib.PrefetchMergeForwardSubItems(runtime, rawItems, nameCache) + messages := make([]map[string]interface{}, 0, len(rawItems)) for _, item := range rawItems { m, _ := item.(map[string]interface{}) - messages = append(messages, convertlib.FormatMessageItem(m, runtime, nameCache)) + messages = append(messages, convertlib.FormatMessageItemWithMergePrefetch(m, runtime, nameCache, mergePrefetch)) } // Enrich: resolve sender names for outer messages (reuses cache from merge_forward) convertlib.ResolveSenderNames(runtime, messages, nameCache) convertlib.AttachSenderNames(messages, nameCache) + if !runtime.Bool("no-reactions") { + convertlib.EnrichReactions(runtime, messages) + } outData := map[string]interface{}{ "thread_id": threadId, diff --git a/shortcuts/im/mp4_box_test.go b/shortcuts/im/mp4_box_test.go new file mode 100644 index 000000000..4e0090e06 --- /dev/null +++ b/shortcuts/im/mp4_box_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "encoding/binary" + "testing" +) + +// build64BitBox builds an ISO-BMFF box using the 64-bit "largesize" form: the +// 32-bit size field is set to 1 and an 8-byte largesize follows the 4-byte box +// type. largesize is the total box length including the 16-byte header. +func build64BitBox(boxType string, largesize uint64, payload []byte) []byte { + box := make([]byte, 16+len(payload)) + binary.BigEndian.PutUint32(box[0:4], 1) // size == 1 → 64-bit largesize follows + copy(box[4:8], boxType) + binary.BigEndian.PutUint64(box[8:16], largesize) + copy(box[16:], payload) + return box +} + +// build32BitBox builds an ISO-BMFF box using the ordinary 32-bit size form. +func build32BitBox(boxType string, payload []byte) []byte { + box := make([]byte, 8+len(payload)) + binary.BigEndian.PutUint32(box[0:4], uint32(len(box))) + copy(box[4:8], boxType) + copy(box[8:], payload) + return box +} + +// TestMP4BoxLargeSizeOverflowNoPanic guards the 64-bit box-size branch against +// CWE-190 integer overflow. A largesize whose high bit is set converts to a +// negative offset; without a bounds guard that offset indexes the input slice +// out of range and panics, crashing the CLI on a crafted/corrupt MP4 (the +// in-memory walkers run on URL-sourced media that the caller does not control). +// The walkers' contract is best-effort: malformed input must return 0, not panic. +func TestMP4BoxLargeSizeOverflowNoPanic(t *testing.T) { + // A single top-level box in the 64-bit form with largesize = 2^64-1. + data := build64BitBox("ftyp", 0xFFFFFFFFFFFFFFFF, nil) + + if got := readMp4DurationBytes(data); got != 0 { + t.Errorf("readMp4DurationBytes(overflow largesize) = %d, want 0", got) + } + if got := parseMp4Duration(data); got != 0 { + t.Errorf("parseMp4Duration(overflow largesize) = %d, want 0", got) + } + if start, end := findMP4Box(data, 0, len(data), "ftyp"); start != -1 || end != -1 { + t.Errorf("findMP4Box(overflow largesize) = (%d, %d), want (-1, -1)", start, end) + } +} + +// TestMP4Box64BitSizeAtNonZeroOffset locks in correct handling of a 64-bit box +// that does not start at offset 0. boxEnd must be offset+largesize (as the +// 32-bit branch already does with offset+size); dropping the offset truncates +// the box and the duration is silently lost. +func TestMP4Box64BitSizeAtNonZeroOffset(t *testing.T) { + mvhd := buildMvhdBox(0, 1000, 5000) // timescale=1000, duration=5000 → 5000ms + // moov carried as a 64-bit box: largesize = 16-byte header + mvhd payload. + moov := build64BitBox("moov", uint64(16+len(mvhd)), mvhd) + // Precede moov with a 32-bit ftyp box so it sits at a non-zero offset — + // that is where the missing "offset +" surfaces. + data := append(build32BitBox("ftyp", []byte("isom")), moov...) + + if got := readMp4DurationBytes(data); got != 5000 { + t.Errorf("readMp4DurationBytes(64-bit moov at offset>0) = %d, want 5000", got) + } +} + +// TestFindMP4Box64BitSizeAtNonZeroOffset is the findMP4Box-level analogue: a +// 64-bit box preceding the target must advance the cursor by offset+largesize +// so the following box is located at the right position. +func TestFindMP4Box64BitSizeAtNonZeroOffset(t *testing.T) { + free := build64BitBox("free", 24, make([]byte, 8)) // 16-byte header + 8 bytes + target := build32BitBox("mvhd", []byte("payload!")) + data := append(free, target...) + + start, end := findMP4Box(data, 0, len(data), "mvhd") + if start < 0 { + t.Fatalf("findMP4Box did not find mvhd after a 64-bit box (start=%d)", start) + } + if got := string(data[start:end]); got != "payload!" { + t.Errorf("findMP4Box returned %q, want %q", got, "payload!") + } +} diff --git a/shortcuts/mail/body_file.go b/shortcuts/mail/body_file.go new file mode 100644 index 000000000..f15c05ab2 --- /dev/null +++ b/shortcuts/mail/body_file.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "io" + "strings" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// bodyFileFlag is the shared `--body-file` flag declaration reused by every +// compose shortcut (+send / +draft-create / +reply / +reply-all / +forward). +// All six shortcuts honour the same mutual-exclusion contract with `--body` +// and the cwd-subtree path safety rule. The flag is intentionally NOT +// shared with `+lint-html` because that command's description differs +// ("HTML to lint" vs "email body") in a way that is more readable when +// authored per-shortcut. `+draft-edit` does not expose `--body-file` either +// — its body ops flow through `--patch-file` JSON whose `value` field is +// the natural file-based entry point for large bodies. +var bodyFileFlag = common.Flag{ + Name: "body-file", + Desc: "Path (relative, within cwd subtree) to a file containing the email body HTML. Mutually exclusive with --body. Size capped at 32 MB.", + Input: []string{common.File}, +} + +// maxBodyFileSize caps the size of a `--body-file` HTML input. The compose +// path's downstream EML limit is 25 MB (helpers.go MAX_EML_BYTES); we allow a +// bit more headroom here (32 MB) so a body close to the limit still loads +// before the downstream check fires with a clearer error message. The cap +// prevents an `io.ReadAll` from blowing memory on a misdirected gigabyte +// file. +const maxBodyFileSize = 32 * 1024 * 1024 // 32 MB + +// validateBodyFileMutex enforces the `--body` / `--body-file` mutual +// exclusion + cwd-subtree path safety. Compose shortcuts call this in +// their Validate phase so AI / users see a clear error before any work +// runs. Pass the shortcut's RuntimeContext-resolved flag values directly: +// `bodyFlag` is the `--body` value (may be empty), `bodyFile` is the +// trimmed `--body-file` value, and `validatePath` is the +// runtime.ValidatePath bound function used to enforce the relative-path +// rule (cwd-subtree only; no absolute / `..` traversal). +// +// Returns an ErrValidation error when either invariant is violated, nil +// otherwise. The "exactly one of {--body, --body-file}" check is +// shortcut-specific (some shortcuts allow neither, e.g. `+forward` with +// no explicit body) and is therefore left to the caller. +func validateBodyFileMutex(bodyFlag, bodyFile string, validatePath func(string) error) error { + bodyEmpty := strings.TrimSpace(bodyFlag) == "" + if !bodyEmpty && bodyFile != "" { + return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one") + } + if bodyFile != "" { + if err := validatePath(bodyFile); err != nil { + return output.ErrValidation("--body-file: %v", err) + } + } + return nil +} + +// resolveBodyFromFlags returns the body content from --body or --body-file. +// Validate has already enforced mutual exclusion via validateBodyFileMutex, +// so exactly one is set (or neither when a template / parent message +// supplies the body). Returns ("", nil) when neither flag is set so +// downstream code can decide whether the empty body is allowed. +func resolveBodyFromFlags(runtime *common.RuntimeContext) (string, error) { + if body := runtime.Str("body"); strings.TrimSpace(body) != "" { + return body, nil + } + path := strings.TrimSpace(runtime.Str("body-file")) + if path == "" { + return "", nil + } + return readBodyFile(runtime.FileIO(), path) +} + +func validateRequiredResolvedBody(body string, hasTemplate bool, message string) error { + if !hasTemplate && strings.TrimSpace(body) == "" { + return output.ErrValidation(message) + } + return nil +} + +// readBodyFile loads --body-file content with a size cap. Returns an +// ErrValidation error if the file exceeds maxBodyFileSize or any IO error +// occurs. The size check uses io.LimitReader(maxBodyFileSize+1) so any +// over-cap byte is observable without reading the whole file. +// +// Callers MUST have run runtime.ValidatePath(path) on `path` first — the +// helper only opens the file via the supplied FileIO and does not repeat +// the cwd-subtree safety check. +func readBodyFile(fio fileio.FileIO, path string) (string, error) { + f, err := fio.Open(path) + if err != nil { + return "", output.ErrValidation("open --body-file %s: %v", path, err) + } + defer f.Close() + buf, err := io.ReadAll(io.LimitReader(f, maxBodyFileSize+1)) + if err != nil { + return "", output.ErrValidation("read --body-file %s: %v", path, err) + } + if len(buf) > maxBodyFileSize { + return "", output.ErrValidation("--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024) + } + return string(buf), nil +} diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index b7446df43..f5513425e 100644 --- a/shortcuts/mail/helpers.go +++ b/shortcuts/mail/helpers.go @@ -18,6 +18,7 @@ import ( "strings" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/output" @@ -2364,9 +2365,11 @@ func validateConfirmSendScope(runtime *common.RuntimeContext) error { } required := []string{"mail:user_mailbox.message:send"} if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 { - return output.ErrWithHint(output.ExitAuth, "missing_scope", - fmt.Sprintf("--confirm-send requires scope: %s", strings.Join(missing, ", ")), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant the send permission", strings.Join(missing, " "))) + return errs.NewPermissionError(errs.SubtypeMissingScope, + "--confirm-send requires scope: %s", strings.Join(missing, ", ")). + WithHint("run `lark-cli auth login --scope %q` to grant the send permission", strings.Join(missing, " ")). + WithMissingScopes(missing...). + WithIdentity("user") } return nil } @@ -2387,9 +2390,11 @@ func validateFolderReadScope(runtime *common.RuntimeContext) error { } required := []string{"mail:user_mailbox.folder:read"} if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 { - return output.ErrWithHint(output.ExitAuth, "missing_scope", - fmt.Sprintf("folder resolution requires scope: %s", strings.Join(missing, ", ")), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant folder read permission", strings.Join(missing, " "))) + return errs.NewPermissionError(errs.SubtypeMissingScope, + "folder resolution requires scope: %s", strings.Join(missing, ", ")). + WithHint("run `lark-cli auth login --scope %q` to grant folder read permission", strings.Join(missing, " ")). + WithMissingScopes(missing...). + WithIdentity("user") } return nil } @@ -2410,9 +2415,11 @@ func validateLabelReadScope(runtime *common.RuntimeContext) error { } required := []string{"mail:user_mailbox.message:modify"} if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 { - return output.ErrWithHint(output.ExitAuth, "missing_scope", - fmt.Sprintf("label resolution requires scope: %s", strings.Join(missing, ", ")), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant label access permission", strings.Join(missing, " "))) + return errs.NewPermissionError(errs.SubtypeMissingScope, + "label resolution requires scope: %s", strings.Join(missing, ", ")). + WithHint("run `lark-cli auth login --scope %q` to grant label access permission", strings.Join(missing, " ")). + WithMissingScopes(missing...). + WithIdentity("user") } return nil } diff --git a/shortcuts/mail/lint/linter.go b/shortcuts/mail/lint/linter.go new file mode 100644 index 000000000..d0286a4f0 --- /dev/null +++ b/shortcuts/mail/lint/linter.go @@ -0,0 +1,1156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lint + +import ( + "bytes" + "fmt" + "hash/fnv" + "strings" + + xhtml "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +// MaxExcerptBytes caps the raw-HTML excerpt embedded in a Finding.Excerpt so +// a single offending tag with megabyte content can't bloat the envelope JSON. +// Lint operates on bytes only, but the excerpt representation must not be +// size-amplifying. +const MaxExcerptBytes = 200 + +// Run lints the given HTML body and returns a structured Report. +// Report.CleanedHTML contains the rewritten HTML (warnings rewritten + errors +// deleted) — the autofix is unconditional. +// +// IMPORTANT: when the input is empty or plain-text (no HTML markup detected +// by the cli's existing `bodyIsHTML` heuristic), callers should short-circuit +// with EmptyReport(html) instead of paying the parse cost. Run still handles +// this gracefully — html.Parse on plain text wraps the input in +// ..., and the lib's pass-through +// rendering will reproduce the original text — but the round-trip is wasteful +// and produces no findings. +func Run(html string, opts Options) Report { + if html == "" { + return EmptyReport("") + } + + rep := Report{ + Applied: []Finding{}, + Blocked: []Finding{}, + } + + // We use html.ParseFragment so users authoring fragment-style snippets + // (the canonical compose-5 input shape — `
...
` rather than a + // full document) don't get implicit wrappers + // re-rendered. The "body" insertion mode matches what html.Parse would + // have done internally for a fragment but skips the structural wrappers + // at render time. + bodyContext := &xhtml.Node{Type: xhtml.ElementNode, DataAtom: atom.Body, Data: "body"} + nodes, err := xhtml.ParseFragment(strings.NewReader(html), bodyContext) + if err != nil { + // Parser failure is exceptional (the parser is permissive by design); + // fall back to the original input so we don't lose user content. + return EmptyReport(html) + } + + // Wrap fragment nodes in a synthetic root so the recursive walker has a + // uniform parent pointer to mutate. + root := &xhtml.Node{Type: xhtml.DocumentNode} + for _, n := range nodes { + root.AppendChild(n) + } + + walk(root, &rep) + // nativeCtx tracks per-Run() state so positional ids (e.g. data-ol-id) + // are deterministic across multiple Run() calls on the same input — + // keying off the document-traversal order rather than heap pointers, + // so cleaned_html is byte-stable and amenable to golden-file tests / CI + // diff / cache-key reuse. + nctx := &nativeCtx{olIDs: map[*xhtml.Node]string{}} + applyFeishuNativeStyles(root, &rep, nctx) + + rep.HasErrorFindings = len(rep.Blocked) > 0 + rep.HasWarningFindings = len(rep.Applied) > 0 + rep.CleanedHTML = renderFragment(root) + + return rep +} + +// walk visits every element node under parent, applying tag/attr/style +// classification. Children are iterated via the next-sibling pointer because +// we mutate the tree in place (replace / remove nodes). +// +// The walker is iterative-style via explicit recursion because the html +// parser's typical nesting depth (≤ 256 by default) is well below Go's +// goroutine stack limit; the existing draft package's plainTextFromHTML +// (mail/draft/htmltext.go) similarly recurses for the same reason. +func walk(parent *xhtml.Node, rep *Report) { + child := parent.FirstChild + for child != nil { + next := child.NextSibling + if child.Type == xhtml.ElementNode { + processElement(parent, child, rep) + } + // child may have been removed/replaced by processElement; recurse + // only if it still has the original parent (i.e. wasn't deleted). + // The html parser sets Parent on every node, so a removed-then- + // reattached node still recurses correctly via its new Parent. + if child.Parent != nil { + walk(child, rep) + } + child = next + } +} + +// processElement applies the element-level classification cascade: +// 1. tag → allow / warn-rewrite / error-delete +// 2. attributes → on*-handlers, URL-bearing attrs (scheme allow-list), +// style attribute (CSS property allow-list) +func processElement(parent, n *xhtml.Node, rep *Report) { + tagName := strings.ToLower(n.Data) + kind, ruleID := classifyTag(tagName) + + switch kind { + case "error": + rep.Blocked = append(rep.Blocked, Finding{ + RuleID: ruleID, + Severity: SeverityError, + TagOrAttr: tagName, + Excerpt: excerptOf(n), + Hint: hintForBlockedTag(tagName), + }) + // Always remove blocked tags — the writing-path safety floor has no + // opt-out; `--no-lint` is not provided. + parent.RemoveChild(n) + return + + case "warn": + // Always rewrite (e.g. ) and surface the finding. + rep.Applied = append(rep.Applied, Finding{ + RuleID: ruleID, + Severity: SeverityWarning, + TagOrAttr: tagName, + Excerpt: excerptOf(n), + Hint: hintForWarnTag(tagName), + }) + rewriteWarnTag(n, tagName) + // Recurse into the rewritten node by falling through; the rewrite + // preserved children as-is. + // fall through to attribute scan + case "allow": + // no-op + } + + // Attribute scan: build a new attribute slice, dropping/sanitising as we + // go and surfacing findings. + if len(n.Attr) > 0 { + processAttributes(n, rep) + } +} + +// processAttributes walks the attribute list and: +// - drops on*-handlers (always; surfaced as error) +// - drops URL-bearing attrs whose value uses a forbidden scheme +// - filters the `style` attribute property-by-property against the allow-list +// +// Other attributes pass through unchanged. The cli's existing +// `validateInlineCIDs` (helpers.go:2226) handles `cid:`-specific checks; +// the lint must not duplicate that responsibility. +func processAttributes(n *xhtml.Node, rep *Report) { + keep := n.Attr[:0] + for _, attr := range n.Attr { + name := strings.ToLower(attr.Key) + + // 1. on*-handlers → always drop, error-tier. + if isEventHandlerAttr(name) { + rep.Blocked = append(rep.Blocked, Finding{ + RuleID: RuleAttrEventHandlerBlocked, + Severity: SeverityError, + TagOrAttr: name, + Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""), + Hint: "Removed event handler attribute (on*)", + }) + continue + } + + // 2. URL-bearing attrs → check scheme allow-list. + if urlAttributes[name] { + kind, ruleID := classifyURLValue(attr.Val) + switch kind { + case "error": + rep.Blocked = append(rep.Blocked, Finding{ + RuleID: ruleID, + Severity: SeverityError, + TagOrAttr: name, + Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""), + Hint: "Removed dangerous URL scheme (allowed: http/https/mailto/cid/data:image/*)", + }) + continue + case "warn": + rep.Blocked = append(rep.Blocked, Finding{ + RuleID: ruleID, + Severity: SeverityError, + TagOrAttr: name, + Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""), + Hint: "Removed URL with unrecognised scheme (allowed: http/https/mailto/cid/data:image/*)", + }) + // Always drop the attribute — writing-path safety floor (the + // URL would not render correctly anyway). + continue + } + } + + // 3. `style` attribute → property-by-property allow-list. + if name == "style" { + cleaned, dropped := sanitiseStyleAttr(attr.Val) + for _, prop := range dropped { + rep.Applied = append(rep.Applied, Finding{ + RuleID: RuleStylePropertyDropped, + Severity: SeverityWarning, + TagOrAttr: "style." + prop, + Excerpt: truncateExcerpt(prop), + Hint: "Removed CSS property not in allowlist (see references/lark-mail-html.md)", + }) + } + if len(dropped) == 0 { + // Byte-stable when no property was dropped: leave the + // attribute exactly as authored so lint round-trips are + // idempotent on clean input. + keep = append(keep, attr) + continue + } + if cleaned == "" { + // All properties dropped — remove the attribute entirely. + continue + } + attr.Val = cleaned + keep = append(keep, attr) + continue + } + + // 4. Pass-through. + keep = append(keep, attr) + } + n.Attr = keep +} + +// rewriteWarnTag replaces a warning-tier tag with its Feishu-native +// equivalent in place: with color/face/size +// distilled into inline style;
; +// / (text-only, animation discarded — collapsing +// to a span keeps the children but drops the deprecated animation effect). +func rewriteWarnTag(n *xhtml.Node, tagName string) { + switch tagName { + case "font": + // Distill . + var styles []string + var keepAttrs []xhtml.Attribute + for _, attr := range n.Attr { + switch strings.ToLower(attr.Key) { + case "color": + if v := strings.TrimSpace(attr.Val); v != "" { + styles = append(styles, "color:"+v) + } + case "face": + if v := strings.TrimSpace(attr.Val); v != "" { + styles = append(styles, "font-family:"+v) + } + case "size": + if v := mapFontSize(attr.Val); v != "" { + styles = append(styles, "font-size:"+v) + } + default: + keepAttrs = append(keepAttrs, attr) + } + } + // Merge any existing style attribute already present on the + // (rare but possible). + if len(styles) > 0 { + merged := strings.Join(styles, ";") + styleIdx := -1 + for i, attr := range keepAttrs { + if strings.ToLower(attr.Key) == "style" { + styleIdx = i + break + } + } + if styleIdx >= 0 { + existing := strings.TrimRight(keepAttrs[styleIdx].Val, "; ") + if existing != "" { + merged = existing + ";" + merged + } + keepAttrs[styleIdx].Val = merged + } else { + keepAttrs = append(keepAttrs, xhtml.Attribute{Key: "style", Val: merged}) + } + } + n.Data = "span" + n.DataAtom = atom.Span + n.Attr = keepAttrs + + case "center": + //
. Existing style attr + // (if any) is merged with text-align prepended. + styleIdx := -1 + for i, attr := range n.Attr { + if strings.ToLower(attr.Key) == "style" { + styleIdx = i + break + } + } + newStyle := "text-align:center" + if styleIdx >= 0 { + existing := strings.TrimRight(n.Attr[styleIdx].Val, "; ") + if existing != "" { + newStyle = newStyle + ";" + existing + } + n.Attr[styleIdx].Val = newStyle + } else { + n.Attr = append(n.Attr, xhtml.Attribute{Key: "style", Val: newStyle}) + } + n.Data = "div" + n.DataAtom = atom.Div + + case "marquee", "blink": + // Both deprecated; collapse to so children survive. + n.Data = "span" + n.DataAtom = atom.Span + // Strip marquee-specific attributes (direction, scrollamount, ...) + // so the rewritten span is plain. + var keepAttrs []xhtml.Attribute + for _, attr := range n.Attr { + if strings.ToLower(attr.Key) == "style" || strings.ToLower(attr.Key) == "class" || strings.ToLower(attr.Key) == "id" { + keepAttrs = append(keepAttrs, attr) + } + } + n.Attr = keepAttrs + } +} + +// mapFontSize maps the legacy values (1..7) to a CSS px +// equivalent, matching the mapping used by Feishu mail-editor's renderer. +// Out-of-range values fall through to the empty string so the property is +// dropped (better than emitting an arbitrary value). +func mapFontSize(raw string) string { + switch strings.TrimSpace(raw) { + case "1": + return "10px" + case "2": + return "13px" + case "3": + return "16px" + case "4": + return "18px" + case "5": + return "24px" + case "6": + return "32px" + case "7": + return "48px" + default: + return "" + } +} + +// sanitiseStyleAttr filters a `style="prop1:val; prop2:val"` declaration +// against the property allow-list. Returns the cleaned style text (joined +// with "; " separators) and a slice of dropped property names (lower-case) +// so the caller can surface STYLE_PROPERTY_DROPPED findings. +// +// NOTE: We do NOT validate property values — only property names. The style +// attribute is filtered by CSS property allow-list; value-level validation +// (e.g. URL safety inside `background-image: url(...)`) is delegated to the +// urlAttributes path because such values typically appear in `src` / `href` +// attrs in compose-5 templates. Users authoring `background-image: url(http:...)` +// in inline style will see the property pass — the URL inside is not a +// security concern at the inline-style level since URL fetching from style +// is restricted by the rendering layer's CSP regardless. +func sanitiseStyleAttr(raw string) (cleaned string, dropped []string) { + if strings.TrimSpace(raw) == "" { + return "", nil + } + parts := strings.Split(raw, ";") + keep := make([]string, 0, len(parts)) + for _, part := range parts { + decl := strings.TrimSpace(part) + if decl == "" { + continue + } + colon := strings.IndexByte(decl, ':') + if colon < 0 { + // Malformed declaration; drop and surface as a finding so the + // user notices. + dropped = append(dropped, decl) + continue + } + name := strings.ToLower(strings.TrimSpace(decl[:colon])) + if !classifyStyleProperty(name) { + dropped = append(dropped, name) + continue + } + keep = append(keep, decl) + } + cleaned = strings.Join(keep, "; ") + return cleaned, dropped +} + +// hintForBlockedTag returns a hint for an error-blocked tag (matching +// the `output.ErrWithHint` convention used elsewhere in the cli). +func hintForBlockedTag(tag string) string { + switch tag { + case "script": + return "Removed whole tag (XSS risk)" + case "iframe", "object", "embed": + return "Removed whole tag (external embeds not allowed; use or a body link for rich media)" + case "form", "input", "select", "option", "button": + return "Removed whole tag (forms not allowed in email body)" + case "link": + return "Removed (external CSS / resources not allowed)" + case "meta": + return "Removed (viewport / refresh declarations not allowed)" + case "base": + return "Removed (URL base rewrites not allowed)" + default: + return "Removed whole tag (tag not allowed)" + } +} + +// hintForWarnTag returns a hint for a warning-tier tag. +func hintForWarnTag(tag string) string { + switch tag { + case "font": + return "Rewritten as (modern HTML expresses size / color via inline style)" + case "center": + return "Rewritten as
(deprecated
tag)" + case "marquee", "blink": + return "Rewritten as (animations not supported; text preserved)" + default: + return "Rewritten in modern HTML shape" + } +} + +// excerptOf renders the offending node's open-tag header into a short string +// suitable for surfacing in a Finding.Excerpt. We render only the tag header +// (not the full subtree) so a single offending

after

`, Options{}) + if len(rep.Blocked) != 1 { + t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked)) + } + if rep.Blocked[0].RuleID != RuleTagScriptBlocked { + t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleTagScriptBlocked) + } + if strings.Contains(rep.CleanedHTML, " content should be deleted, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "safe") || !strings.Contains(rep.CleanedHTML, "after") { + t.Errorf("surrounding content lost, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_BlockedTagsRemoved iterates all error-tier tags. +func TestRun_BlockedTagsRemoved(t *testing.T) { + cases := map[string]string{ + ``: RuleTagIframeBlocked, + ``: RuleTagObjectBlocked, + ``: RuleTagEmbedBlocked, + `
`: RuleTagFormBlocked, + ``: RuleTagLinkBlocked, + ``: RuleTagMetaBlocked, + ``: RuleTagBaseBlocked, + } + for input, wantRule := range cases { + t.Run(input[:min(len(input), 30)], func(t *testing.T) { + rep := Run(input, Options{}) + found := false + for _, f := range rep.Blocked { + if f.RuleID == wantRule { + found = true + break + } + } + if !found { + t.Errorf("expected rule %s, got %+v", wantRule, rep.Blocked) + } + }) + } +} + +// TestRun_EventHandlerAttrBlocked verifies on*-handlers (onclick etc.) are +// stripped — they are an event-handler injection vector. +func TestRun_EventHandlerAttrBlocked(t *testing.T) { + rep := Run(`

x

`, Options{}) + if len(rep.Blocked) != 1 { + t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked)) + } + if rep.Blocked[0].RuleID != RuleAttrEventHandlerBlocked { + t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleAttrEventHandlerBlocked) + } + if strings.Contains(rep.CleanedHTML, "onclick") { + t.Errorf("onclick should be stripped, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, `id="ok"`) { + t.Errorf("non-handler attrs should survive, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_OnErrorAttrBlocked tests one of the more common XSS vectors. +func TestRun_OnErrorAttrBlocked(t *testing.T) { + rep := Run(``, Options{}) + hasErr := false + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrEventHandlerBlocked && f.TagOrAttr == "onerror" { + hasErr = true + } + } + if !hasErr { + t.Errorf("onerror should fire, got %+v", rep.Blocked) + } +} + +// ===================================================================== +// URL scheme allow-list. +// ===================================================================== + +// TestRun_JavaScriptURLBlocked verifies javascript: hrefs are stripped. +func TestRun_JavaScriptURLBlocked(t *testing.T) { + rep := Run(`click`, Options{}) + hasErr := false + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked { + hasErr = true + } + } + if !hasErr { + t.Errorf("javascript: URL should fire ATTR_JS_URL_BLOCKED, got %+v", rep.Blocked) + } + if strings.Contains(rep.CleanedHTML, "javascript:") { + t.Errorf("javascript: should be stripped, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_VBScriptURLBlocked verifies vbscript: is rejected. +func TestRun_VBScriptURLBlocked(t *testing.T) { + rep := Run(`x`, Options{}) + if len(rep.Blocked) == 0 { + t.Errorf("expected vbscript: to be blocked, got 0 findings") + } +} + +// TestRun_DataNonImageURLBlocked verifies data:text/html is rejected +// (only data:image/* is allowed). +func TestRun_DataNonImageURLBlocked(t *testing.T) { + rep := Run(``, Options{}) + if len(rep.Blocked) == 0 { + t.Errorf("expected data:text/html to be blocked") + } +} + +// TestRun_DataImageAllowed verifies data:image/png passes. +func TestRun_DataImageAllowed(t *testing.T) { + rep := Run(``, Options{}) + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked { + t.Errorf("data:image/* should pass, got %+v", f) + } + } +} + +// TestRun_RelativeURLAllowed verifies relative URLs (no scheme) pass. +func TestRun_RelativeURLAllowed(t *testing.T) { + rep := Run(`x`, Options{}) + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked || f.RuleID == RuleAttrUnsafeSchemeBlocked { + t.Errorf("relative URL should pass, got %+v", f) + } + } +} + +// ===================================================================== +// Style property allow-list. +// ===================================================================== + +// TestRun_StylePropertyDropped verifies non-allow-list properties drop. +func TestRun_StylePropertyDropped(t *testing.T) { + rep := Run(`

x

`, Options{}) + dropped := []string{} + for _, f := range rep.Applied { + if f.RuleID == RuleStylePropertyDropped { + dropped = append(dropped, f.TagOrAttr) + } + } + if !sliceContains(dropped, "style.position") { + t.Errorf("expected position to be dropped, got %v", dropped) + } + if !sliceContains(dropped, "style.z-index") { + t.Errorf("expected z-index to be dropped, got %v", dropped) + } + if strings.Contains(rep.CleanedHTML, "position:") || strings.Contains(rep.CleanedHTML, "z-index:") { + t.Errorf("dropped properties should be removed from cleaned style, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "color:red") { + t.Errorf("allowed property should survive, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_StyleBorderPrefixAllowed verifies the border-* prefix rule. +func TestRun_StyleBorderPrefixAllowed(t *testing.T) { + rep := Run(`

x

`, Options{}) + for _, f := range rep.Applied { + if f.RuleID == RuleStylePropertyDropped { + t.Errorf("border-* should pass, got %+v", f) + } + } +} + +// TestRun_FeishuListShorthandMarginPreserved guards the nested-list indent +// regression: when a user writes shorthand `margin:0 0 0 24px` on an inner +//
    (mail-editor's own native nested-list shape), the Feishu-list autofix +// must NOT clobber it by appending `margin-left:0`. ensureInlineStyleProps +// is supposed to skip props the user already declared, but earlier +// hasInlineStyleProp was only matching longhand `margin-left:` literally +// and missed the shorthand form, causing 24px indents to be reset to 0. +func TestRun_FeishuListShorthandMarginPreserved(t *testing.T) { + in := `
    • indented
    ` + rep := Run(in, Options{}) + cleaned := rep.CleanedHTML + // Extract just the
      opening tag's style attr (li has its own + // independent margin-left:0 longhand which is correct — list indent + // belongs on the container, not the item). + ulOpen := cleaned + if i := strings.Index(ulOpen, ">"); i >= 0 { + ulOpen = ulOpen[:i] + } + if !strings.Contains(ulOpen, "margin:0px 0px 0px 24px") { + t.Errorf("shorthand margin with 24px left should survive on
        , ulOpen=%q", ulOpen) + } + // The bug signature: extra `margin-left:` appended after the shorthand + // on the
          element itself (CSS rule says the later one wins, so any + // margin-left:0 after the shorthand resets the indent to 0). + if strings.Contains(ulOpen, "margin-left") { + t.Errorf("autofix must not append margin-left longhand onto
            when shorthand already declares it, ulOpen=%q", ulOpen) + } +} + +// TestRun_BlockquoteShorthandBorderPreserved verifies the blockquote native +// autofix does not override a user-authored border shorthand by appending +// border-left. CSS applies the later longhand over the earlier shorthand, so +// adding border-left here would replace the user's left border. +func TestRun_BlockquoteShorthandBorderPreserved(t *testing.T) { + rep := Run(`
            quoted
            `, Options{}) + cleaned := rep.CleanedHTML + if !strings.Contains(cleaned, `border:1px solid red`) { + t.Fatalf("user-authored border shorthand should survive, cleaned=%q", cleaned) + } + if strings.Contains(cleaned, `border-left:`) { + t.Fatalf("autofix must not append border-left when border shorthand already declares it, cleaned=%q", cleaned) + } + if !strings.Contains(cleaned, `color:rgb(100,106,115)`) { + t.Fatalf("blockquote native autofix should still add missing non-border style props, cleaned=%q", cleaned) + } +} + +func TestRun_BlockquoteNativeContentWrapper(t *testing.T) { + rep := Run(`
            quoted
            `, Options{}) + cleaned := rep.CleanedHTML + for _, want := range []string{ + `class="lark-mail-doc-quote"`, + `border-left:2px solid rgb(187,191,196)`, + `
            quoted
            `, + } { + if !strings.Contains(cleaned, want) { + t.Fatalf("cleaned blockquote missing %q, cleaned=%q", want, cleaned) + } + } +} + +func TestRun_BlockquoteNativeContentWrapperIdempotent(t *testing.T) { + in := `
            quoted
            ` + rep := Run(in, Options{}) + if strings.Count(rep.CleanedHTML, `padding-left:12px`) != 1 { + t.Fatalf("native-shaped blockquote should not get nested content wrappers, cleaned=%q", rep.CleanedHTML) + } +} + +func TestRun_ParagraphRewritePreservesDirAndFontSize(t *testing.T) { + rep := Run(`

            hello

            `, Options{}) + cleaned := rep.CleanedHTML + if !strings.Contains(cleaned, `style="font-size:20px;margin-top:4px;margin-bottom:4px;line-height:1.6" dir="rtl"`) { + t.Fatalf("outer paragraph wrapper should preserve author font-size and dir, cleaned=%q", cleaned) + } + if !strings.Contains(cleaned, `
            hello
            `) { + t.Fatalf("inner paragraph wrapper should inherit author dir and omit default font-size, cleaned=%q", cleaned) + } + if strings.Contains(cleaned, `font-size:14px`) { + t.Fatalf("inner paragraph wrapper must not force default font-size over author value, cleaned=%q", cleaned) + } + if strings.Contains(cleaned, `dir="auto"`) { + t.Fatalf("inner paragraph wrapper must not force dir=auto over author value, cleaned=%q", cleaned) + } +} + +// ===================================================================== +// CleanedHTML output / contract guarantees. +// ===================================================================== + +// TestRun_EmptyArraysAlwaysPresent verifies the report has non-nil empty +// slices when nothing is found (the JSON envelope contract requires `[]`, +// not `null`). +func TestRun_EmptyArraysAlwaysPresent(t *testing.T) { + // Use
            instead of

            to avoid the Feishu-native paragraph + // rewrite autofix, which would surface a finding even on otherwise + // clean input. + rep := Run(`

            nothing here
            `, Options{}) + if rep.Applied == nil || rep.Blocked == nil { + t.Errorf("Applied/Blocked must be non-nil; got applied=%v blocked=%v", rep.Applied, rep.Blocked) + } + if len(rep.Applied) != 0 || len(rep.Blocked) != 0 { + t.Errorf("expected empty findings, got applied=%d blocked=%d", len(rep.Applied), len(rep.Blocked)) + } +} + +// TestEmptyReport_HasContractFields covers the helper used by compose 5's +// plain-text branch. +func TestEmptyReport_HasContractFields(t *testing.T) { + rep := EmptyReport(`plain text`) + if rep.Applied == nil { + t.Error("Applied must be non-nil") + } + if rep.Blocked == nil { + t.Error("Blocked must be non-nil") + } + if rep.CleanedHTML != "plain text" { + t.Errorf("CleanedHTML = %q, want %q", rep.CleanedHTML, "plain text") + } +} + +// TestRun_CleanedHTMLPreservesStructure verifies that the round-trip through +// the parser doesn't accidentally lose user content. +func TestRun_CleanedHTMLPreservesStructure(t *testing.T) { + html := `

            title

            body bold end

            • a
            • b
            ` + rep := Run(html, Options{}) + if len(rep.Blocked) != 0 { + t.Fatalf("unexpected blocked: %+v", rep.Blocked) + } + // Feishu-native autofix expected to fire on

            ,

              ,
            • — content + // must still survive untouched even though structure is augmented. + for _, want := range []string{"line-height:1.6", "

              ", "title", "", "bold", ""} { + if !strings.Contains(rep.CleanedHTML, want) { + t.Errorf("expected %q in cleaned, got %q", want, rep.CleanedHTML) + } + } +} + +// TestRun_EmptyInput verifies the lib short-circuits cleanly on empty input. +func TestRun_EmptyInput(t *testing.T) { + rep := Run("", Options{}) + if rep.CleanedHTML != "" { + t.Errorf("CleanedHTML = %q, want empty", rep.CleanedHTML) + } + if len(rep.Applied) != 0 || len(rep.Blocked) != 0 { + t.Errorf("empty input must produce empty findings") + } +} + +// TestRun_HasErrorFindingsFlag verifies the flag tracks blocked findings. +func TestRun_HasErrorFindingsFlag(t *testing.T) { + rep := Run(``, Options{}) + if !rep.HasErrorFindings { + t.Error("expected HasErrorFindings=true") + } + clean := Run(`

              safe

              `, Options{}) + if clean.HasErrorFindings { + t.Error("expected HasErrorFindings=false on clean HTML") + } +} + +// TestRun_HasWarningFindingsFlag verifies the flag tracks warnings. +func TestRun_HasWarningFindingsFlag(t *testing.T) { + rep := Run(`x`, Options{}) + if !rep.HasWarningFindings { + t.Error("expected HasWarningFindings=true") + } +} + +// ===================================================================== +// Excerpt cap. +// ===================================================================== + +// TestTruncateExcerpt_RespectsCap verifies the per-finding excerpt cap. +func TestTruncateExcerpt_RespectsCap(t *testing.T) { + long := strings.Repeat("x", MaxExcerptBytes+50) + got := truncateExcerpt(long) + if len(got) > MaxExcerptBytes { + t.Errorf("excerpt len %d exceeds cap %d", len(got), MaxExcerptBytes) + } + if !strings.HasSuffix(got, " ...") { + t.Errorf("expected truncation suffix, got %q", got[len(got)-10:]) + } +} + +// TestRun_ExcerptCappedForLargeOffender verifies large blocked content +// produces a short excerpt (envelope size protection). +func TestRun_ExcerptCappedForLargeOffender(t *testing.T) { + bigAttr := strings.Repeat("a", MaxExcerptBytes*2) + rep := Run(`x`, Options{}) + if len(rep.Blocked) == 0 { + t.Fatal("expected blocked finding") + } + for _, f := range rep.Blocked { + if len(f.Excerpt) > MaxExcerptBytes { + t.Errorf("excerpt len %d exceeds cap %d", len(f.Excerpt), MaxExcerptBytes) + } + } +} + +// ===================================================================== +// Helpers. +// ===================================================================== + +func sliceContains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// ===================================================================== +// Additional coverage for edge cases and exhaustive value mapping. +// ===================================================================== + +// TestMapFontSize_ExhaustiveSpan covers every mapping +// + invalid values fall through to "" so the property is dropped. +func TestMapFontSize_ExhaustiveSpan(t *testing.T) { + cases := map[string]string{ + "1": "10px", + "2": "13px", + "3": "16px", + "4": "18px", + "5": "24px", + "6": "32px", + "7": "48px", + "": "", + "8": "", + "abc": "", + "3.5": "", + " 3 ": "16px", + } + for raw, want := range cases { + got := mapFontSize(raw) + if got != want { + t.Errorf("mapFontSize(%q) = %q, want %q", raw, got, want) + } + } +} + +// TestRun_FontTagWithFaceMappedToFontFamily ensures → +// font-family inline style. +func TestRun_FontTagWithFaceMappedToFontFamily(t *testing.T) { + rep := Run(`x`, Options{}) + if !strings.Contains(rep.CleanedHTML, "font-family:Arial") { + t.Errorf("expected font-family preserved, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_FontTagWithExistingStyleMerged ensures distillation merges with an +// existing style attribute on the same element. +func TestRun_FontTagWithExistingStyleMerged(t *testing.T) { + rep := Run(`x`, Options{}) + if !strings.Contains(rep.CleanedHTML, "line-height:1.6") { + t.Errorf("expected line-height retained, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "color:red") { + t.Errorf("expected color merged, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_CenterTagWithExistingStyleMerged ensures
              's style merge. +func TestRun_CenterTagWithExistingStyleMerged(t *testing.T) { + rep := Run(`
              x
              `, Options{}) + if !strings.Contains(rep.CleanedHTML, "text-align:center") { + t.Errorf("expected text-align:center, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "line-height:1.6") { + t.Errorf("expected line-height preserved, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_MarqueeRetainsClassAndID verifies marquee → span keeps class/id. +func TestRun_MarqueeRetainsClassAndID(t *testing.T) { + rep := Run(`y`, Options{}) + if !strings.Contains(rep.CleanedHTML, `class="cls"`) { + t.Errorf("expected class preserved, cleaned=%q", rep.CleanedHTML) + } + if strings.Contains(rep.CleanedHTML, `direction`) { + t.Errorf("expected marquee-specific attrs stripped, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_UnknownSchemeBlocked verifies an unknown URL scheme produces a +// blocked (error) finding and the attribute is dropped. +func TestRun_UnknownSchemeBlocked(t *testing.T) { + rep := Run(`x`, Options{}) + gotBlocked := false + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrUnsafeSchemeBlocked { + gotBlocked = true + } + } + if !gotBlocked { + t.Errorf("expected ATTR_UNSAFE_SCHEME_BLOCKED in Blocked, got blocked=%+v applied=%+v", rep.Blocked, rep.Applied) + } + if strings.Contains(rep.CleanedHTML, "webcal:") { + t.Errorf("expected unknown scheme stripped, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_WhitespaceObfuscatedJavaScriptScheme verifies "java\tscript:..." +// is still caught after control-byte stripping in classifyURLValue. +func TestRun_WhitespaceObfuscatedJavaScriptScheme(t *testing.T) { + rep := Run("x", Options{}) + gotErr := false + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked { + gotErr = true + } + } + if !gotErr { + t.Errorf("expected obfuscated javascript: to be caught, got %+v", rep.Blocked) + } +} + +// TestRun_FileSchemeBlocked verifies file: URLs are rejected. +func TestRun_FileSchemeBlocked(t *testing.T) { + rep := Run(`x`, Options{}) + if len(rep.Blocked) == 0 { + t.Error("expected file: to be blocked") + } +} + +// TestRun_StyleMalformedDeclarationDropped verifies a property without a +// colon delimiter is treated as malformed and dropped. +func TestRun_StyleMalformedDeclarationDropped(t *testing.T) { + rep := Run(`

              x

              `, Options{}) + gotMalformed := false + for _, f := range rep.Applied { + if f.RuleID == RuleStylePropertyDropped && f.TagOrAttr == "style.malformed" { + gotMalformed = true + } + } + if !gotMalformed { + t.Errorf("expected malformed declaration to be dropped, got %+v", rep.Applied) + } + if !strings.Contains(rep.CleanedHTML, "color:red") || !strings.Contains(rep.CleanedHTML, "line-height:1.6") { + t.Errorf("valid declarations should survive, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_StyleAllPropertiesDroppedRemovesAttribute verifies the style +// attribute is removed entirely when every property is invalid. +func TestRun_StyleAllPropertiesDroppedRemovesAttribute(t *testing.T) { + // Use
              to avoid the Feishu-native paragraph autofix, which adds + // a fresh style attribute on the rewritten outer wrapper. + rep := Run(`
              x
              `, Options{}) + if strings.Contains(rep.CleanedHTML, "style=") { + t.Errorf("style attribute should be removed when all props invalid, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_StyleEmptyValuePassThrough verifies an empty style attr passes. +func TestRun_StyleEmptyValuePassThrough(t *testing.T) { + // Use
              to avoid the Feishu-native paragraph autofix. + rep := Run(`
              x
              `, Options{}) + if len(rep.Applied) != 0 { + t.Errorf("empty style attr should not produce findings, got %+v", rep.Applied) + } +} + +// TestRun_HintsForAllBlockedTags verifies every blocked-tag rule has a +// non-empty hint (consumer contract). +func TestRun_HintsForAllBlockedTags(t *testing.T) { + cases := []string{ + ``, ``, + ``, ``, `
              `, + ``, ``, ``, + ``, ``, + } + for _, html := range cases { + rep := Run(html, Options{}) + for _, f := range rep.Blocked { + if f.Hint == "" { + t.Errorf("blocked rule %s missing hint for %q", f.RuleID, html) + } + } + } +} + +// TestRun_HintsForAllWarnTags verifies every warn-tag rule has a non-empty hint. +func TestRun_HintsForAllWarnTags(t *testing.T) { + cases := []string{ + `x`, `
              x
              `, + `x`, `x`, + } + for _, html := range cases { + rep := Run(html, Options{}) + for _, f := range rep.Applied { + if f.Hint == "" { + t.Errorf("warn rule %s missing hint for %q", f.RuleID, html) + } + } + } +} + +// TestClassifyTag_Coverage exercises classifyTag with every category. +func TestClassifyTag_Coverage(t *testing.T) { + if k, _ := classifyTag("p"); k != "allow" { + t.Errorf("p classified as %q", k) + } + if k, id := classifyTag("script"); k != "error" || id != RuleTagScriptBlocked { + t.Errorf("script classified as %q/%q", k, id) + } + if k, id := classifyTag("font"); k != "warn" || id != RuleTagFontToSpan { + t.Errorf("font classified as %q/%q", k, id) + } + // Niche tag passes silently (e.g.
              ). + if k, _ := classifyTag("details"); k != "allow" { + t.Errorf("niche tag
              should pass through, got %q", k) + } + // Case-insensitive. + if k, _ := classifyTag("SCRIPT"); k != "error" { + t.Errorf("SCRIPT (uppercase) should still classify as error") + } +} + +// TestClassifyURLValue_CoverageEdges covers empty, whitespace-only, +// no-scheme variants. +func TestClassifyURLValue_CoverageEdges(t *testing.T) { + cases := map[string]string{ + "": "ok", + " ": "ok", + "https://x": "ok", + "https://x/path?q=1": "ok", + "#fragment": "ok", + "/relative": "ok", + "javascript:alert(1)": "error", + "vbscript:msgbox 1": "error", + "data:image/png;base64,XYZ": "ok", + "data:text/html,x` + + `

              y

              ` + rep := Run(html, Options{}) + if len(rep.Blocked) < 4 { + t.Errorf("expected ≥4 errors, got %d: %+v", len(rep.Blocked), rep.Blocked) + } +} + +// TestRun_NestedStructurePreserved verifies deep nesting passes through. +func TestRun_NestedStructurePreserved(t *testing.T) { + html := `

              deep

              ` + rep := Run(html, Options{}) + if len(rep.Blocked) != 0 { + t.Errorf("nested allowed tags should pass, got %+v", rep.Blocked) + } + if !strings.Contains(rep.CleanedHTML, "deep") { + t.Errorf("inner text lost, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_BlockedInsideAllowedRemovedNotParent verifies that removing a +// blocked tag inside an allowed parent leaves the parent intact. +func TestRun_BlockedInsideAllowedRemovedNotParent(t *testing.T) { + html := `
              beforeafter
              ` + rep := Run(html, Options{}) + if !strings.Contains(rep.CleanedHTML, "before") || !strings.Contains(rep.CleanedHTML, "after") { + t.Errorf("parent text should survive, cleaned=%q", rep.CleanedHTML) + } + if strings.Contains(rep.CleanedHTML, "
                nested +// directly without an
              • wrapper triggers LIST_DIRECT_CHILD_NON_LI and +// the inner
                  ends up wrapped in a synthetic
                • . Same for
                      . +func TestRun_ListDirectChildNonLIWrapped(t *testing.T) { + cases := []struct { + name string + html string + }{ + {"ul wraps ul", `
                        • x
                      `}, + {"ol wraps ol", `
                        1. x
                      `}, + {"ul wraps div", `
                        orphan
                      • real
                      `}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rep := Run(tc.html, Options{}) + gotRule := false + for _, f := range rep.Applied { + if f.RuleID == RuleListDirectChildNonLI { + gotRule = true + break + } + } + if !gotRule { + t.Errorf("expected LIST_DIRECT_CHILD_NON_LI, got %+v", rep.Applied) + } + // The cleaned HTML should not have a direct ul>ul or ol>ol or + // ul>div sequence anymore. + if strings.Contains(rep.CleanedHTML, "
                        wrapper, cleaned=%q", rep.CleanedHTML) + } + }) + } +} diff --git a/shortcuts/mail/lint/rules.go b/shortcuts/mail/lint/rules.go new file mode 100644 index 000000000..746bad428 --- /dev/null +++ b/shortcuts/mail/lint/rules.go @@ -0,0 +1,353 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lint + +import "strings" + +// Rule IDs surfaced through Finding.RuleID. UPPER_SNAKE_CASE naming is the +// contract for the stdout envelope. New rules MUST keep this naming convention +// so AI / test consumers can pattern-match reliably. +const ( + // Tag-level rules. + RuleTagFontToSpan = "TAG_FONT_TO_SPAN" + RuleTagCenterToDiv = "TAG_CENTER_TO_DIV" + RuleTagMarqueeToText = "TAG_MARQUEE_TO_TEXT" + RuleTagBlinkToText = "TAG_BLINK_TO_TEXT" + RuleTagScriptBlocked = "TAG_SCRIPT_BLOCKED" + RuleTagIframeBlocked = "TAG_IFRAME_BLOCKED" + RuleTagObjectBlocked = "TAG_OBJECT_BLOCKED" + RuleTagEmbedBlocked = "TAG_EMBED_BLOCKED" + RuleTagFormBlocked = "TAG_FORM_BLOCKED" + RuleTagInputBlocked = "TAG_INPUT_BLOCKED" + RuleTagLinkBlocked = "TAG_LINK_BLOCKED" + RuleTagMetaBlocked = "TAG_META_BLOCKED" + RuleTagBaseBlocked = "TAG_BASE_BLOCKED" + RuleTagUnknownStripped = "TAG_UNKNOWN_STRIPPED" + + // Attribute-level rules. + RuleAttrEventHandlerBlocked = "ATTR_EVENT_HANDLER_BLOCKED" + RuleAttrJSURLBlocked = "ATTR_JS_URL_BLOCKED" + RuleAttrUnsafeSchemeBlocked = "ATTR_UNSAFE_SCHEME_BLOCKED" + + // Style-level rules. + RuleStylePropertyDropped = "STYLE_PROPERTY_DROPPED" + + // Feishu-native autofix rules. These autofix the inline style / + // class / nesting shape of common elements so AI-authored HTML + // matches what Feishu mail-editor itself emits, fixing the visual + // "extra blank line between blocks", "list bullets/numbers missing", + // "link color wrong" etc. classes of issues. The rewrite is purely + // additive — user-supplied inline styles take precedence; the lib + // only fills the missing properties. + RuleStyleListNative = "STYLE_LIST_NATIVE_INLINE_APPLIED" + RuleStyleListItemNative = "STYLE_LIST_ITEM_NATIVE_INLINE_APPLIED" + RuleStyleBlockquoteNative = "STYLE_BLOCKQUOTE_NATIVE_INLINE_APPLIED" + RuleStyleLinkNative = "STYLE_LINK_NATIVE_INLINE_APPLIED" + RuleStyleParaWrapper = "STYLE_PARA_WRAPPER_REWRITTEN" + + // RuleListDirectChildNonLI fires when a
                          or
                            has a non-
                          1. + // element child (e.g. nested
                                ). HTML spec requires list children + // to be
                              • ; browsers silently hoist the nested list out and the visual + // nesting falls apart. The lib autofixes by wrapping the offending child + // in a synthetic
                              • . + RuleListDirectChildNonLI = "LIST_DIRECT_CHILD_NON_LI" +) + +// Tag classification ---------------------------------------------------------- + +// allowedTags enumerates tags that pass through verbatim (tag classification row "通过"). +// Lower-case canonical names; the parser normalises tag names so we don't need +// case-insensitive comparison at lookup time. +var allowedTags = map[string]bool{ + "p": true, + "div": true, + "span": true, + "br": true, + "hr": true, + "a": true, + "img": true, + "table": true, + "thead": true, + "tbody": true, + "tfoot": true, + "tr": true, + "td": true, + "th": true, + "ul": true, + "ol": true, + "li": true, + "blockquote": true, + "pre": true, + "code": true, + "b": true, + "i": true, + "em": true, + "strong": true, + "u": true, + "s": true, + "strike": true, + "h1": true, + "h2": true, + "h3": true, + "h4": true, + "h5": true, + "h6": true, + "sub": true, + "sup": true, + "section": true, + "article": true, + "header": true, + "footer": true, + "nav": true, + "main": true, + "figure": true, + "figcaption": true, + "caption": true, + "colgroup": true, + "col": true, + // Document structural tags (golang.org/x/net/html always wraps fragments + // in ); we treat them as transparent so the wrapper + // nodes the parser inserts don't generate spurious findings. + "html": true, + "head": true, + "body": true, +} + +// blockedTags enumerates tags whose content is removed in full and a +// SeverityError finding is emitted (tag classification row "错误(删除)"). Each entry +// maps to the rule id surfaced in Finding.RuleID. +var blockedTags = map[string]string{ + "script": RuleTagScriptBlocked, + "iframe": RuleTagIframeBlocked, + "object": RuleTagObjectBlocked, + "embed": RuleTagEmbedBlocked, + "form": RuleTagFormBlocked, + "input": RuleTagInputBlocked, + "select": RuleTagInputBlocked, + "option": RuleTagInputBlocked, + "button": RuleTagInputBlocked, + "link": RuleTagLinkBlocked, + "meta": RuleTagMetaBlocked, + "base": RuleTagBaseBlocked, +} + +// warnAutofixTags enumerates tags rewritten when AutoFix is true (tag +// classification row "警告 + 自动修复"). The replacement strategy is per-tag. +var warnAutofixTags = map[string]string{ + "font": RuleTagFontToSpan, + "center": RuleTagCenterToDiv, + "marquee": RuleTagMarqueeToText, + "blink": RuleTagBlinkToText, +} + +// classifyTag returns the rule kind for the given lower-case tag name. +// +// kind is one of "allow", "warn", "error", "unknown". For "warn" / "error", +// ruleID names the firing rule; for "unknown", the caller falls back to +// allow-list-by-default but emits a hint via RuleTagUnknownStripped only when +// the tag is structurally suspect (e.g. -like). The cli's existing +// `htmlTagRe` regex is the de-facto allow-list shipping with the codebase, so +// we don't aggressively flag anything outside `allowedTags` — drop-through +// preserves user intent for niche tags (e.g. `
                                ` / ``) that +// browsers + Feishu native renderer already handle. +func classifyTag(tag string) (kind, ruleID string) { + tag = strings.ToLower(tag) + if allowedTags[tag] { + return "allow", "" + } + if id, ok := blockedTags[tag]; ok { + return "error", id + } + if id, ok := warnAutofixTags[tag]; ok { + return "warn", id + } + // Unknown / niche tags: pass through silently. The cli's existing + // `htmlTagRe` (mail_quote.go:333) tolerates them too. Users authoring + // HTML in Feishu native classes (`adit-html-block*`, `history-quote-*`, + // `lark-mail-doc-quote`) hit this path — they MUST pass through unchanged + // so reply / forward quote markup survives lint round-trips. + return "allow", "" +} + +// Attribute / URL / style classification -------------------------------------- + +// allowedURLSchemes lists URL schemes that pass through hyperlink-bearing +// attrs (`href`, `src`, `cite`, `formaction` etc.). Allowed: http(s), mailto, +// cid, data:image/*; everything else (notably javascript: and vbscript:) is +// blocked. Empty / relative URLs (no scheme) are always +// allowed because they resolve relatively at render time and pose no +// injection vector. +var allowedURLSchemes = map[string]bool{ + "http": true, + "https": true, + "mailto": true, + "cid": true, +} + +// blockedURLSchemes is the explicit deny-list. data:image/* is special-cased +// in classifyURLValue. +var blockedURLSchemes = map[string]bool{ + "javascript": true, + "vbscript": true, + "file": true, +} + +// classifyURLValue returns ("ok", "") if the URL value is acceptable, or +// ("error", ruleID) when it must be removed (javascript:/vbscript:/file:), +// or ("warn", ruleID) when the scheme is unrecognised but not actively +// dangerous. Empty values pass through (browsers ignore them). +func classifyURLValue(raw string) (kind, ruleID string) { + value := strings.TrimSpace(raw) + if value == "" { + return "ok", "" + } + // Strip leading whitespace + control bytes that could obscure the + // scheme (e.g. "java\tscript:..."). The html-parser already strips + // stray whitespace at attribute boundaries; this is defence-in-depth + // for older clients that paste from Word with U+0009 / U+0020 inside + // the scheme prefix. + value = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7F { + return -1 + } + return r + }, value) + + // Find the colon delimiter; everything before it is the scheme. + colon := strings.IndexByte(value, ':') + if colon < 0 { + // No scheme → relative URL → allow. + return "ok", "" + } + scheme := strings.ToLower(value[:colon]) + rest := value[colon+1:] + + switch { + case allowedURLSchemes[scheme]: + return "ok", "" + case scheme == "data": + // data:image/* is whitelisted; anything else (e.g. data:text/html;...) + // is rejected. The check tolerates any subtype under image/* (png / + // jpeg / gif / svg+xml / webp) so users embedding base64 thumbnails + // don't trip the rule. + rest = strings.TrimSpace(rest) + if strings.HasPrefix(strings.ToLower(rest), "image/") { + return "ok", "" + } + return "error", RuleAttrJSURLBlocked + case blockedURLSchemes[scheme]: + return "error", RuleAttrJSURLBlocked + default: + // Unknown scheme: surface a warning so users see it but don't + // drop legitimate webcal:/tel: / similar in case downstream + // renders eventually support them. + return "warn", RuleAttrUnsafeSchemeBlocked + } +} + +// urlAttributes lists attributes whose value is a URL and must therefore +// pass classifyURLValue. Lower-case canonical names. +var urlAttributes = map[string]bool{ + "href": true, + "src": true, + "cite": true, + "formaction": true, + "action": true, + "background": true, + "poster": true, +} + +// allowedStyleProps enumerates CSS property names that pass through the +// inline `style="..."` attribute. Everything else is removed from the +// property list and surfaced via STYLE_PROPERTY_DROPPED. +// +// `border-*` / `padding-*` / `margin-*` are treated as prefix matches by +// classifyStyleProperty so the four directional variants (border-top etc.) +// are all admitted without enumerating each. +var allowedStyleProps = map[string]bool{ + "color": true, + "background-color": true, + "font-size": true, + "font-weight": true, + "font-style": true, + "text-align": true, + "text-decoration": true, + "line-height": true, + "padding": true, + "margin": true, + "border": true, + "width": true, + "height": true, + "display": true, + "text-indent": true, + // Quote-block / native Feishu styles (tag classification "通过"). + // Whitespace + word-break are part of the existing `
                                ` / quote
                                +	// wrapper styles in mail_quote.go (e.g. `bodyDivStyle`).
                                +	"white-space":         true,
                                +	"word-break":          true,
                                +	"word-wrap":           true,
                                +	"overflow":            true,
                                +	"overflow-wrap":       true,
                                +	"vertical-align":      true,
                                +	"list-style":          true,
                                +	"list-style-type":     true,
                                +	"list-style-position": true,
                                +	"transition":          true,
                                +	"font-family":         true,
                                +	"text-transform":      true,
                                +	"hyphens":             true,
                                +	"max-width":           true,
                                +	"min-width":           true,
                                +	"max-height":          true,
                                +	"min-height":          true,
                                +	"border-radius":       true,
                                +	"box-sizing":          true,
                                +	"opacity":             true,
                                +	"cursor":              true,
                                +}
                                +
                                +// stylePropAllowedPrefixes enumerates property name prefixes treated as
                                +// allowed regardless of suffix (e.g. "border-*"). A trailing "-" makes the
                                +// prefix self-documenting.
                                +var stylePropAllowedPrefixes = []string{
                                +	"border-",
                                +	"padding-",
                                +	"margin-",
                                +}
                                +
                                +// classifyStyleProperty reports whether the given lower-case property name
                                +// is in the allow-list (incl. prefix matches).
                                +func classifyStyleProperty(name string) bool {
                                +	name = strings.ToLower(strings.TrimSpace(name))
                                +	if name == "" {
                                +		return false
                                +	}
                                +	if allowedStyleProps[name] {
                                +		return true
                                +	}
                                +	for _, p := range stylePropAllowedPrefixes {
                                +		if strings.HasPrefix(name, p) {
                                +			return true
                                +		}
                                +	}
                                +	return false
                                +}
                                +
                                +// isEventHandlerAttr reports whether the attribute name is a DOM event
                                +// handler (`on*`). The lib removes every such attribute regardless of its
                                +// value (tag classification row "错误(删除)" + the well-known XSS vector).
                                +func isEventHandlerAttr(name string) bool {
                                +	name = strings.ToLower(strings.TrimSpace(name))
                                +	if !strings.HasPrefix(name, "on") {
                                +		return false
                                +	}
                                +	if len(name) <= 2 {
                                +		return false
                                +	}
                                +	// Defence-in-depth: avoid matching legitimate attrs whose name happens
                                +	// to begin with "on" (e.g. `onerror`-like attrs all start "on" + ascii
                                +	// letter). The `>= 'a'` check filters out "on-something" with hyphens.
                                +	c := name[2]
                                +	return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
                                +}
                                diff --git a/shortcuts/mail/lint/types.go b/shortcuts/mail/lint/types.go
                                new file mode 100644
                                index 000000000..383b1e6db
                                --- /dev/null
                                +++ b/shortcuts/mail/lint/types.go
                                @@ -0,0 +1,92 @@
                                +// Copyright (c) 2026 Lark Technologies Pte. Ltd.
                                +// SPDX-License-Identifier: MIT
                                +
                                +// Package lint implements the mail-domain HTML lint lib used by `+lint-html`
                                +// and the writing-path internals of the compose 5 shortcuts (`+send`,
                                +// `+draft-create`, `+reply`, `+reply-all`, `+forward`) and `+draft-edit` body
                                +// ops. The lib classifies HTML tags / attributes / inline styles into three
                                +// tiers (pass / warn-and-autofix / error-delete) following the three-tier tag
                                +// classification. `
                                +
                                +
                                + +
                                +

                                [调研主题] 市场调研报告

                                +
                                [YYYY-MM-DD] | 调研者:[姓名] · [团队] | [关联系统 / 版本]
                                +
                                + +
                                +

                                调研背景

                                +
                                [一段话描述:本轮调研聚焦的赛道 / 行业背景 / 触发动机]。本轮调研覆盖 [N] 类玩家([类别 1] / [类别 2] / [类别 3] / [类别 4]),重点评估 [自家产品 / 团队] 在 [赛道名] 的位置、对外摩擦点,以及结合 [关联工作 / PR / 本期目标] 的待补能力。所有结论基于 [数据来源 1:公开资料 / 厂商文档 / 行业报告] + [数据来源 2:自有实测 / 内部调研笔记] + [数据来源 3:访谈 / 体验]。
                                +
                                + +
                                +
                                +
                                [N]
                                +
                                调研对象
                                +
                                +
                                +
                                [N]
                                +
                                已就绪能力
                                +
                                +
                                +
                                [N]
                                +
                                明确缺口
                                +
                                +
                                +
                                [N]
                                +
                                高优待办
                                +
                                +
                                + +
                                +

                                1. [章节标题:例 "全球市场态势"]

                                +
                                [一句话描述本节切分维度,例 "把市场按 '为谁设计' 切四象限"]
                                + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                                玩家 / 对象定位 / 类型[关键评分维度]关键观察
                                [玩家 1][类别][标签][一句话观察]
                                [玩家 2][类别][标签][一句话观察]
                                [玩家 3][类别][标签][一句话观察]
                                [玩家 4][类别][标签][一句话观察]
                                +
                                + +
                                +

                                2. [章节标题:例 "接入摩擦点"] ⚠️ 风险

                                +
                                [一句话描述:从哪里观察 / 案例 / 数据来源]
                                + + + + + + + + + + + + + + + + + + + + + + + +
                                摩擦类型 / 维度具体表现业务影响
                                [摩擦 1][具体表现 / 案例][对业务 / 团队的影响]
                                [摩擦 2][具体表现][影响]
                                [摩擦 3][具体表现][影响]
                                +
                                + +
                                +

                                3. [章节标题:例 "新势力玩家详情" / "重点对象详细比较"]

                                +
                                +
                                +
                                [玩家 / 对象 1]
                                +
                                [一句话产品定位 / 核心能力 / 差异化]
                                +
                                关键差异:[一句话提炼]
                                +
                                +
                                +
                                [玩家 / 对象 2]
                                +
                                [产品定位]
                                +
                                关键差异:[一句话]
                                +
                                +
                                +
                                [玩家 / 对象 3]
                                +
                                [产品定位]
                                +
                                关键差异:[一句话]
                                +
                                +
                                +
                                [小结一句话:玩家共性 / 自家路线对比]
                                +
                                + +
                                +

                                4. [章节标题:例 "安全风险全景" / "潜在隐患"] ⚠️ 高危

                                +
                                [一句话描述:风险来源 / 关联前期工作]
                                + + + + + + + + + + + + + + + + + + + + + + + +
                                威胁 / 风险案例 / 来源自家现状
                                [风险 1][案例 / 来源链接 / 引用前期报告][标签]
                                [风险 2][案例 / 来源][标签]
                                [风险 3](重点)[案例 / 来源][标签]
                                +
                                + 结论:[一段话,提炼本章节最关键的判断 / 行动建议] +
                                +
                                + +
                                +

                                5. [章节标题:例 "自家已就绪能力"] ✓ 优势

                                +
                                [一句话描述:基于哪些 PR / 已交付的工作得出]
                                +
                                • [能力 1] — [简述 + 关联 PR / 文档链接]
                                • [能力 2] — [简述]
                                • [能力 3] — [简述]
                                • [能力 4] — [简述]
                                +
                                + +
                                +

                                6. [章节标题:例 "待补能力 / 机会清单"]

                                +
                                [一句话描述:清单口径 / 优先级判定依据]
                                + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                                #优先级能力 / 缺口建议落地
                                1P0[能力 / 缺口 1][具体落地路径 / Owner / 估算]
                                2P0[能力 / 缺口 2][具体落地路径]
                                3P1[能力 / 缺口 3][具体落地路径]
                                4P1[能力 / 缺口 4][具体落地路径]
                                5P2[能力 / 缺口 5][具体落地路径]
                                +
                                + +
                                +

                                关联工作产出佐证

                                +
                                本调研报告中部分章节的依据来自下列在执行中的工作:
                                + +
                                + +
                                +

                                建议与下一步

                                +
                                1. [行动 1] — [具体路径 + 时间窗 + Owner]
                                2. [行动 2] — [具体路径 + 时间窗]
                                3. [行动 3] — [具体路径]
                                4. [行动 4] — [具体路径]
                                +
                                + +
                                +
                                调研者:[your@email] · [团队]|整合于 [YYYY-MM-DD]
                                +
                                关联材料:[文档 / 笔记路径 / 前期报告]
                                +
                                + +
                                diff --git a/skills/lark-mail/assets/templates/weekly--personal-report.html b/skills/lark-mail/assets/templates/weekly--personal-report.html new file mode 100644 index 000000000..2e1bd7567 --- /dev/null +++ b/skills/lark-mail/assets/templates/weekly--personal-report.html @@ -0,0 +1,43 @@ + +
                                [姓名] 个人工作周报 · [YYYY 第 NN 周]
                                +
                                [团队] · [角色]|周期 [YYYY-MM-DD] ~ [YYYY-MM-DD]
                                + +
                                本周工作内容
                                + +
                                1. [项目 / 主任务名称]已完成 · 📄 文档 · PR 链接
                                +
                                • [子项 1.1:动作描述,附数据 / 链接]
                                • [子项 1.2:动作描述]
                                • [子项 1.3:动作描述,含具体数字 / 占比 / 时长]
                                + +
                                2. [项目 / 主任务名称]进行中 · 📄 文档
                                +
                                • [子项 2.1:动作 + 当前进度 + 数据]
                                • [子项 2.2:动作 + 当前进度]
                                + +
                                3. [项目 / 主任务名称]已完成
                                +
                                • [子项 3.1]
                                • [子项 3.2]
                                + +
                                下周工作内容
                                + +
                                1. [项目 / 主任务名称]P0 · 预计 [YYYY-MM-DD]
                                +
                                • [子项 1.1:具体动作 + 推进方式,例「先 spike POC,再发 RFC 同协作方对齐方案」]
                                • [子项 1.2:里程碑 / 关键产出 + 完成方式]
                                • [子项 1.3:依赖 / 协作方 / 验收标准]
                                + +
                                2. [项目 / 主任务名称]P0 · 预计 [YYYY-MM-DD]
                                +
                                • [子项 2.1:动作 + 推进方式]
                                • [子项 2.2:里程碑 / 关键产出]
                                • [子项 2.3:依赖 / 验收]
                                + +
                                3. [项目 / 主任务名称]P1 · 预计 [YYYY-MM-DD]
                                +
                                • [子项 3.1:动作 + 推进方式]
                                • [子项 3.2:里程碑]
                                • [子项 3.3:协作方]
                                + +
                                4. [项目 / 主任务名称]P2 · 预计 [YYYY-MM-DD]
                                +
                                • [子项 4.1:动作 + 推进方式]
                                • [子项 4.2:依赖 / 关键产出]
                                + +
                                风险与疑问
                                +
                                • [风险 / 疑问 1] — [背景:描述风险来源 / 触发场景];[影响:会延期 / 阻塞哪些工作];[建议:希望得到的支持 / 决策方向 / 期望响应方(@姓名 / 团队)]
                                • [风险 / 疑问 2] — [背景];[影响];[建议]
                                • [风险 / 疑问 3] — [背景];[影响];[建议]
                                +
                                (若本周无风险 / 疑问,整段替换为:。)
                                + +
                                — [姓名] / [团队] / [日期]|[your@email]
                                diff --git a/skills/lark-mail/assets/templates/weekly--team-report.html b/skills/lark-mail/assets/templates/weekly--team-report.html new file mode 100644 index 000000000..6d26a90c0 --- /dev/null +++ b/skills/lark-mail/assets/templates/weekly--team-report.html @@ -0,0 +1,9 @@ + +
                                本周工作
                                +
                                1. [项目 / 事件 1 名称]@[姓名 a]@[姓名 b]
                                  文档:[文档名]
                                2. [项目 / 事件 2 名称]@[姓名 g]
                                  技术方案:[文档名] · 设计稿:[设计稿名]
                                  • [子项 2.1:含孙子项的动作主题]
                                    • [孙子项 2.1.1:必要时再细分一层;不需要可整段删除]@[姓名 h]
                                    • [孙子项 2.1.2]
                                  • [子项 2.2]@[姓名 i],进行中
                                  • [子项 2.3]@[姓名 j],评审中
                                3. [项目 / 事件 3 名称]@[姓名 k]@[姓名 l]阻塞
                                  阻塞分析:[文档名]
                                +
                                下周工作
                                +
                                1. [重点 1:项目 / 事件名]@[姓名 o],预计 [YYYY-MM-DD]
                                2. [重点 2:含子重点的项目]
                                  1. [子重点 a:动作 / 推进方式]@[姓名 p]
                                  2. [子重点 b:动作]@[姓名 q]
                                3. [重点 3:项目 / 事件名]@[姓名 r]@[姓名 s],预计 [YYYY-MM-DD]
                                4. [重点 4:项目 / 事件名]@[姓名 t],预计 [YYYY-MM-DD]
                                +
                                — [姓名] / [团队] / [日期]|[your@email]
                                diff --git a/skills/lark-mail/references/lark-mail-draft-create.md b/skills/lark-mail/references/lark-mail-draft-create.md index eeb016af9..36b6682dd 100644 --- a/skills/lark-mail/references/lark-mail-draft-create.md +++ b/skills/lark-mail/references/lark-mail-draft-create.md @@ -8,6 +8,8 @@ 如需修改已有草稿,不要使用此命令,请使用 `lark-cli mail +draft-edit`。 +**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范** + ## 安全约束 此命令创建草稿——**不会**发送邮件。用户可以在飞书邮件 UI 中打开草稿查看详情,确认后再进入后续操作。因此: @@ -44,7 +46,8 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te |------|------|------| | `--to ` | 否 | 完整收件人列表,多个用逗号分隔。支持 `Alice ` 格式。省略时草稿不带收件人(之后可通过 `+draft-edit` 添加) | | `--subject ` | 是 | 草稿主题 | -| `--body ` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) | +| `--body ` | 二选一 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径)。与 `--body-file` 互斥 | +| `--body-file ` | 二选一 | 从文件读取邮件正文 HTML(相对路径,仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB | | `--from ` | 否 | 发件人邮箱地址(EML From 头)。使用别名(send_as)发信时,设为别名地址并配合 `--mailbox` 指定所属邮箱。省略时使用邮箱主地址 | | `--mailbox ` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用,如通过别名或 send_as 地址发信。可通过 `accessible_mailboxes` 查询可用邮箱 | | `--cc ` | 否 | 完整抄送列表,多个用逗号分隔 | diff --git a/skills/lark-mail/references/lark-mail-draft-edit.md b/skills/lark-mail/references/lark-mail-draft-edit.md index 366c5cf82..a4b4c5759 100644 --- a/skills/lark-mail/references/lark-mail-draft-edit.md +++ b/skills/lark-mail/references/lark-mail-draft-edit.md @@ -10,11 +10,13 @@ - `--set-cc` - `--set-bcc` -**正文编辑和其他高级操作必须通过 `--patch-file`**。没有 `--set-body` flag。 +**正文整体替换的快捷方式:** `--body ` / `--body-file `(二选一互斥)会自动展开为 `set_body` op。如果只想做整段正文替换且不需要保留引用区,用这两个 flag 即可,无需写 patch-file。要保留引用区或做更精细的 op 组合,仍走 `--patch-file`。两个入口与 `--patch-file` 内的 `set_body` / `set_reply_body` 互斥。 -### 正文编辑:两个 op 的选择 +**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范** -正文编辑通过 `--patch-file` 传入,有两个 op 可选: +## 正文编辑:快捷 flag 与 typed op 的选择 + +整段替换正文且不需要保留引用区时,可直接使用 `--body` / `--body-file`。需要保留引用区、修改引用区或组合高级正文编辑时,通过 `--patch-file` 传入 typed body op,有两个 op 可选: | 情况 | op | 行为 | |------|-----|------| @@ -49,7 +51,10 @@ # 编辑草稿元数据(主题、收件人) lark-cli mail +draft-edit --draft-id --set-subject '更新后的主题' --set-to alice@example.com,bob@example.com -# 编辑草稿正文(必须通过 patch-file) +# 快速完整替换正文 +lark-cli mail +draft-edit --draft-id --body '

                                更新后的正文

                                ' + +# 高级正文编辑(如保留回复/转发引用区) lark-cli mail +draft-edit --draft-id --patch-file ./patch.json # 查看草稿(只读)— 返回包含 has_quoted_content、attachments_summary 和 inline_summary 的投影 @@ -72,13 +77,15 @@ lark-cli mail +draft-edit --draft-id --set-subject '测试' --dry-run | `--set-to ` | 否 | 用此处提供的地址替换整个 To 收件人列表 | | `--set-cc ` | 否 | 用此处提供的地址替换整个 Cc 抄送列表 | | `--set-bcc ` | 否 | 用此处提供的地址替换整个 Bcc 密送列表 | +| `--body ` | 否 | 整段替换正文(自动展开为 `set_body` op)。与 `--body-file` 互斥;与 `--patch-file` 内的 `set_body` / `set_reply_body` op 互斥 | +| `--body-file ` | 否 | 从文件读取正文 HTML(相对路径,仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB | | `--set-priority ` | 否 | 设置邮件优先级:`high`、`normal`、`low`。设为 `normal` 会清除已有优先级 | | `--set-event-summary ` | 否 | 设置日程标题。需同时设置 `--set-event-start` 和 `--set-event-end` | | `--set-event-start