Compare commits

..

1 Commits

Author SHA1 Message Date
/
823a55a1ef fix: update sheets filter view condition validation 2026-05-27 17:31:19 +08:00
524 changed files with 19842 additions and 52738 deletions

View File

@@ -82,8 +82,6 @@ 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

View File

@@ -49,26 +49,18 @@ linters:
- gocritic
- depguard
- forbidigo
# 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/)
- path-except: (shortcuts/|internal/)
linters:
- forbidigo
- path: internal/vfs/
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer.
# The shortcuts-no-raw-http forbidigo rule below is shortcuts-only;
# internal/ legitimately wraps raw HTTP for the client / credential layer.
- path-except: shortcuts/
text: shortcuts-no-raw-http
linters:
- forbidigo
# 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)
text: errs-typed-only
linters:
- forbidigo
settings:
depguard:
@@ -87,13 +79,6 @@ 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).
# ── 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

View File

@@ -2,84 +2,6 @@
All notable changes to this project will be documented in this file.
## [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
@@ -964,10 +886,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[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

View File

@@ -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 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).
// 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.
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,
// 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.).
// 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).
CheckError: ac.CheckResponse,
})
// MarkRaw: see comment above on the DoAPI path. Skips legacy
// *ExitError enrichment; typed errors flow through unchanged.
// MarkRaw: see comment above on the DoAPI path. Applies equally to
// HandleResponse failures so the raw API error survives to the wire.
if err != nil {
return output.MarkRaw(err)
}

View File

@@ -4,13 +4,11 @@
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"
@@ -672,49 +670,3 @@ 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")
}
}

View File

@@ -17,7 +17,6 @@ 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.
@@ -71,7 +70,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: %w", err)
return "", "", fmt.Errorf("failed to parse user info: %v", err)
}
if resp.Code != 0 {
return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
@@ -111,11 +110,6 @@ 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()
@@ -137,10 +131,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: %w", err)
return nil, fmt.Errorf("failed to parse response: %v", err)
}
if resp.Code != 0 {
return nil, classifyAppInfoErr(apiResp.RawBody, resp.Code, resp.Msg, f, appId)
return nil, fmt.Errorf("API error [%d]: %s", resp.Code, resp.Msg)
}
app := resp.Data.App
@@ -159,21 +153,3 @@ 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)
}

View File

@@ -12,7 +12,6 @@ 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"
@@ -319,54 +318,6 @@ 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
}
@@ -438,8 +389,15 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
if matched != nil && matched != cmd && !matched.SilenceUsage {
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
}
})
}

View File

@@ -9,7 +9,6 @@ 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"
@@ -48,7 +47,7 @@ func authCheckRun(opts *CheckOptions) error {
required := strings.Fields(opts.Scope)
if len(required) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be empty").WithParam("--scope")
return output.ErrValidation("--scope cannot be empty")
}
config, err := f.Config()

View File

@@ -1,167 +0,0 @@
// 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)
}
}

View File

@@ -13,12 +13,9 @@ 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"
@@ -56,9 +53,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 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)")
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)")
}
opts.Ctx = cmd.Context()
if runF != nil {
@@ -124,7 +121,7 @@ func authLoginRun(opts *LoginOptions) error {
}
// Determine UI language from saved config
var lang i18n.Lang
lang := "zh"
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
if app := multi.FindApp(config.ProfileName); app != nil {
lang = app.Lang
@@ -160,14 +157,14 @@ func authLoginRun(opts *LoginOptions) error {
for _, d := range selectedDomains {
if !knownDomains[d] {
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain")
return output.ErrValidation("unknown domain %q, did you mean %q?", d, suggestion)
}
available := make([]string, 0, len(knownDomains))
for k := range knownDomains {
available = append(available, k)
}
sort.Strings(available)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain")
return output.ErrValidation("unknown domain %q, available domains: %s", d, strings.Join(available, ", "))
}
}
}
@@ -175,17 +172,17 @@ func authLoginRun(opts *LoginOptions) error {
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
if len(opts.Exclude) > 0 && !hasAnyOption {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--exclude requires --scope, --domain, or --recommend to be specified").WithParam("--exclude")
return output.ErrValidation("--exclude requires --scope, --domain, or --recommend to be specified")
}
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand)
if err != nil {
return err
}
if result == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected")
return output.ErrValidation("no login options selected")
}
selectedDomains = result.Domains
scopeLevel = result.ScopeLevel
@@ -201,7 +198,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope")
return output.ErrValidation("please specify the scopes to authorize")
}
}
@@ -230,7 +227,7 @@ func authLoginRun(opts *LoginOptions) error {
}
if len(candidateScopes) == 0 && opts.Scope == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options")
return output.ErrValidation("no matching scopes found, check domain/scope options")
}
// Merge --scope additively with the resolved domain scopes.
@@ -250,13 +247,13 @@ func authLoginRun(opts *LoginOptions) error {
if len(opts.Exclude) > 0 {
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
if len(unknown) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
return output.ErrValidation(
"these --exclude scopes are not present in the requested set: %s",
strings.Join(unknown, ", ")).WithParam("--exclude")
strings.Join(unknown, ", "))
}
finalScope = excluded
if strings.TrimSpace(finalScope) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no scopes left after applying --exclude; nothing to authorize").WithParam("--exclude")
return output.ErrValidation("no scopes left after applying --exclude; nothing to authorize")
}
}
@@ -267,7 +264,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 errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err)
return output.ErrAuth("device authorization failed: %v", err)
}
// --no-wait: return immediately with device code and URL
@@ -279,18 +276,12 @@ func authLoginRun(opts *LoginOptions) error {
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"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 <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.",
"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),
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
return nil
}
@@ -312,7 +303,7 @@ func authLoginRun(opts *LoginOptions) error {
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
@@ -333,25 +324,25 @@ func authLoginRun(opts *LoginOptions) error {
"event": "authorization_failed",
"error": result.Message,
}); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
return output.ErrBare(output.ExitAuth)
}
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message)
return output.ErrAuth("authorization failed: %s", result.Message)
}
if result.Token == nil {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned")
return output.ErrAuth("authorization succeeded but no token returned")
}
// Step 6: Get user info
log(msg.AuthSuccess)
sdk, err := f.LarkClient()
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err)
return output.ErrAuth("failed to get SDK: %v", err)
}
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err)
return output.ErrAuth("failed to get user info: %v", err)
}
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
@@ -369,13 +360,13 @@ func authLoginRun(opts *LoginOptions) error {
GrantedAt: now,
}
if err := larkauth.SetStoredToken(storedToken); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save token: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", 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 err
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
}
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
@@ -418,22 +409,22 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
if shouldRemoveLoginRequestedScope(result) {
cleanupRequestedScope()
}
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message)
return output.ErrAuth("authorization failed: %s", result.Message)
}
defer cleanupRequestedScope()
if result.Token == nil {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned")
return output.ErrAuth("authorization succeeded but no token returned")
}
// Get user info
log(msg.AuthSuccess)
sdk, err := f.LarkClient()
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err)
return output.ErrAuth("failed to get SDK: %v", err)
}
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err)
return output.ErrAuth("failed to get user info: %v", err)
}
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
@@ -451,13 +442,13 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
GrantedAt: now,
}
if err := larkauth.SetStoredToken(storedToken); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save token: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", 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 errs.NewInternalError(errs.SubtypeSDKError, "failed to update login profile: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
}
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
@@ -472,18 +463,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 errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err)
return fmt.Errorf("load config: %w", err)
}
app := findProfileByName(multi, profileName)
if app == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName)
return fmt.Errorf("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 errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err)
return fmt.Errorf("save config: %w", err)
}
for _, oldUser := range oldUsers {

View File

@@ -10,7 +10,6 @@ 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 +162,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra
}
if len(selectedDomains) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no domains selected").WithParam("--domain")
return nil, output.ErrValidation("no domains selected")
}
// Compute scope summary

View File

@@ -3,8 +3,6 @@
package auth
import "github.com/larksuite/cli/internal/i18n"
type loginMsg struct {
// Interactive UI (login_interactive.go)
SelectDomains string
@@ -117,8 +115,8 @@ var loginMsgEn = &loginMsg{
}
// getLoginMsg returns the login message bundle for the given language.
func getLoginMsg(lang i18n.Lang) *loginMsg {
if lang.IsEnglish() {
func getLoginMsg(lang string) *loginMsg {
if lang == "en" {
return loginMsgEn
}
return loginMsgZh

View File

@@ -8,8 +8,6 @@ import (
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/i18n"
)
func TestGetLoginMsg_Zh(t *testing.T) {
@@ -33,7 +31,7 @@ func TestGetLoginMsg_En(t *testing.T) {
}
func TestGetLoginMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []i18n.Lang{"", "fr_fr", "ja_jp", "unknown"} {
for _, lang := range []string{"", "fr", "ja", "unknown"} {
msg := getLoginMsg(lang)
if msg != loginMsgZh {
t.Errorf("getLoginMsg(%q) should default to zh", lang)
@@ -63,7 +61,7 @@ func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string)
}
func TestLoginMsg_FormatStrings(t *testing.T) {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
for _, lang := range []string{"zh", "en"} {
msg := getLoginMsg(lang)
// LoginSuccess should contain two %s placeholders (userName, openId)
@@ -104,10 +102,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 []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
for _, lang := range []string{"zh", "en"} {
hint := getLoginMsg(lang).AgentTimeoutHint
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
if lang == i18n.LangZhCN && want == "turn" {
if lang == "zh" && want == "turn" {
want = "本轮"
}
if !strings.Contains(hint, want) {

View File

@@ -8,7 +8,6 @@ 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"
@@ -172,12 +171,25 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
fmt.Fprintln(f.IOStreams.Out, string(b))
return output.ErrBare(output.ExitAuth)
}
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...)
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,
},
}
}
fmt.Fprintln(f.IOStreams.ErrOut)

View File

@@ -1,61 +0,0 @@
// 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)
}
}

View File

@@ -400,11 +400,12 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
if err == nil {
t.Fatal("expected error, got nil")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %v", err)
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
if exitErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
}
got := stderr.String()
for _, want := range []string{
@@ -442,11 +443,12 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
if err == nil {
t.Fatal("expected error, got nil")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %v", err)
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
if exitErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
}
var data map[string]interface{}
@@ -651,11 +653,12 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
Ctx: context.Background(),
Scope: "im:message:send",
})
if err == nil {
t.Fatal("expected error, got nil")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %v", err)
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
if exitErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
}
got := stderr.String()
for _, want := range []string{
@@ -867,90 +870,6 @@ 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",
@@ -1042,11 +961,8 @@ 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",
"come back and notify",
"YOU must execute",
"lark-cli auth login --device-code <device_code>",
"Do NOT cache",
"lark-cli auth login --no-wait --json",
"After the user confirms authorization in a later step",
"lark-cli auth login --device-code device-code",
} {
if !strings.Contains(hint, want) {
t.Fatalf("hint missing %q, got:\n%s", want, hint)

View File

@@ -8,7 +8,6 @@ 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"
@@ -61,7 +60,7 @@ func authLogoutRun(opts *LogoutOptions) error {
}
app.Users = []core.AppUser{}
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
return nil

View File

@@ -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 errs.NewValidationError(errs.SubtypeInvalidArgument, "url is required").WithParam("--url")
return output.Errorf(output.ExitValidation, "missing_url", "url is required")
}
if opts.ASCII {
@@ -75,20 +75,20 @@ func runQRCode(opts *QRCodeOptions) error {
}
if opts.Output == "" {
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")
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.")
}
if opts.Size < 32 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at least 32, got %d", opts.Size).WithParam("--size")
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at least 32, got %d", opts.Size))
}
if opts.Size > 1024 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at most 1024, got %d", opts.Size).WithParam("--size")
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at most 1024, got %d", opts.Size))
}
safePath, err := validate.SafeOutputPath(opts.Output)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
return output.ErrValidation("unsafe output path: %s", 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 errs.NewInternalError(errs.SubtypeSDKError, "failed to write output: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to write output: %v", 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 errs.NewInternalError(errs.SubtypeSDKError, "failed to encode QR code: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to encode QR code: %v", err))
}
err = vfs.WriteFile(outputPath, png, 0644)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write QR code to %s: %v", outputPath, err).WithCause(err)
return output.Errorf(output.ExitInternal, "write_error", fmt.Sprintf("failed to write QR code to %s: %v", outputPath, 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 errs.NewInternalError(errs.SubtypeSDKError, "failed to create QR code: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to create QR code: %v", err))
}
fmt.Fprint(w, q.ToSmallString(false))

View File

@@ -5,6 +5,7 @@ package auth
import (
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
@@ -170,15 +171,29 @@ func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
func TestRunQRCode_MissingURL(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: ""})
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
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")
}
}
func TestRunQRCode_MissingOutput(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
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")
}
}
@@ -188,8 +203,15 @@ func TestRunQRCode_InvalidSize(t *testing.T) {
Size: 16,
Output: "qr.png",
})
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
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")
}
}
@@ -199,8 +221,15 @@ func TestRunQRCode_SizeTooLarge(t *testing.T) {
Size: 2048,
Output: "qr.png",
})
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
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")
}
}
@@ -210,8 +239,12 @@ func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
Size: 256,
Output: "/etc/passwd",
})
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
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)
}
}
@@ -296,8 +329,15 @@ func TestGenerateImageQRCode_WriteError(t *testing.T) {
if err == nil {
t.Fatal("expected error writing to nonexistent directory")
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitInternal {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitInternal)
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")
}
}
@@ -318,7 +358,11 @@ func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
if err == nil {
t.Fatal("expected error for empty string")
}
if err == nil {
t.Fatal("expected error, got nil")
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")
}
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -51,23 +50,11 @@ func authScopesRun(opts *ScopesOptions) error {
return err
}
fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n")
appInfo, err := getAppInfoFn(opts.Ctx, f, config.AppID)
appInfo, err := getAppInfo(opts.Ctx, f, config.AppID)
if err != nil {
// 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)
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.")
}
if opts.Format == "pretty" {
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)

View File

@@ -1,121 +0,0 @@
// 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")
}
}

View File

@@ -61,6 +61,7 @@ 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)
@@ -85,6 +86,29 @@ 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:

View File

@@ -117,13 +117,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
// inherited by every subcommand, turning unknown-flag errors into a
// structured "did you mean" envelope.
rootCmd.SilenceUsage = true
rootCmd.SetFlagErrorFunc(flagDidYouMean)
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {

View File

@@ -12,10 +12,8 @@ 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"
@@ -39,10 +37,8 @@ type BindOptions struct {
// this flag because its own prompts already require human confirmation.
Force bool
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
Lang string
langExplicit bool // true when --lang was explicitly passed
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
// the account being bound. Populated after resolveAccount; TUI stages
@@ -59,7 +55,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, UILang: i18n.LangZhCN}
opts := &BindOptions{Factory: f}
cmd := &cobra.Command{
Use: "bind",
@@ -106,7 +102,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", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
cmdutil.SetRisk(cmd, "write")
return cmd
@@ -151,7 +147,7 @@ func configBindRun(opts *BindOptions) error {
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
return err
}
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
applyPreferences(appConfig, opts)
noticeUserDefaultRisk(opts)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
@@ -182,7 +178,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 "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit).WithParam("--source")
return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit)
}
var detected string
@@ -199,26 +195,23 @@ 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 "", 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")
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")
}
// 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. Picker offers 2 options (中文 / English) and
// drives BOTH opts.Lang (preference) and opts.UILang (TUI rendering).
// env already pinned it.
if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection()
lang, err := promptLangSelection("")
if err != nil {
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
return "", err
}
opts.Lang = string(lang)
opts.UILang = lang
opts.Lang = lang
}
if explicit != "" {
@@ -230,10 +223,9 @@ func finalizeSource(opts *BindOptions) (string, error) {
if opts.IsTUI {
return tuiSelectSource(opts)
}
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")
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")
}
// reconcileExistingBinding reads any existing config at configPath and decides
@@ -253,7 +245,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
return existingBinding{}, err
}
if action == "cancel" {
msg := getBindMsg(opts.UILang)
msg := getBindMsg(opts.Lang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
return existingBinding{Cancelled: true}, nil
}
@@ -337,10 +329,9 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
if !hasStrictBotLock(previousConfigBytes) {
return nil
}
msg := getBindMsg(opts.UILang)
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite,
"config bind --force", "%s", msg.IdentityEscalationMessage).
WithHint("%s", msg.IdentityEscalationHint)
msg := getBindMsg(opts.Lang)
return output.ErrWithHint(output.ExitValidation, "bind",
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
}
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
@@ -356,23 +347,14 @@ func noticeUserDefaultRisk(opts *BindOptions) {
if opts.IsTUI || opts.Identity != "user-default" {
return
}
msg := getBindMsg(opts.UILang)
msg := getBindMsg(opts.Lang)
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.
// 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) {
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
switch opts.Identity {
case "bot-only":
sm := core.StrictModeBot
@@ -383,23 +365,9 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.L
appConfig.StrictMode = &sm
appConfig.DefaultAs = core.AsUser
}
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 opts.Lang != "" {
appConfig.Lang = opts.Lang
}
if app := multi.CurrentAppConfig(""); app != nil {
return app.Lang
}
return ""
}
// commitBinding finalizes the bind: atomic write of the new workspace config,
@@ -411,21 +379,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 errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "bind",
"failed to create workspace directory: %v", err)
}
data, err := json.MarshalIndent(multi, "", " ")
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "bind",
"failed to marshal config: %v", err)
}
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err)
return output.Errorf(output.ExitInternal, "bind",
"failed to write config %s: %v", configPath, err)
}
replaced := previousConfigBytes != nil
// 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)
msg := getBindMsg(opts.Lang)
display := sourceDisplayName(source)
if replaced {
@@ -433,11 +401,7 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
}
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
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))
}
fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice)
// 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
@@ -455,17 +419,12 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
"replaced": replaced,
"identity": opts.Identity,
}
// 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)
brand := brandDisplay(string(appConfig.Brand), opts.Lang)
switch opts.Identity {
case "bot-only":
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand)
case "user-default":
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display)
}
resultJSON, _ := json.Marshal(envelope)
@@ -502,7 +461,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.UILang)
msg := getBindMsg(opts.Lang)
var source string
// Pre-select based on detected env signals
@@ -527,7 +486,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.SelectSource).
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.UILang))).
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))).
Options(
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
@@ -549,7 +508,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.UILang)
msg := getBindMsg(opts.Lang)
options := make([]huh.Option[int], 0, len(candidates))
for i, c := range candidates {
label := c.AppID
@@ -563,7 +522,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.UILang))).
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))).
Options(options...).
Value(&selected),
),
@@ -580,7 +539,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.UILang)
msg := getBindMsg(opts.Lang)
// Build existing binding summary
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
@@ -629,14 +588,9 @@ func validateBindFlags(opts *BindOptions) error {
switch opts.Identity {
case "bot-only", "user-default":
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity")
return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity)
}
}
lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil {
return err
}
opts.Lang = string(lang)
return nil
}
@@ -652,8 +606,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.UILang)
brand := brandDisplay(opts.Brand, opts.UILang)
msg := getBindMsg(opts.Lang)
brand := brandDisplay(opts.Brand, opts.Lang)
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
var value string

View File

@@ -3,8 +3,6 @@
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
@@ -86,11 +84,6 @@ 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{
@@ -123,8 +116,6 @@ var bindMsgZh = &bindMsg{
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
LangPreferenceSet: "语言偏好已设置:%s",
}
var bindMsgEn = &bindMsg{
@@ -159,13 +150,10 @@ 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",
}
// getBindMsg picks the zh/en TUI bundle; non-English falls back to zh.
func getBindMsg(lang i18n.Lang) *bindMsg {
if lang.IsEnglish() {
func getBindMsg(lang string) *bindMsg {
if lang == "en" {
return bindMsgEn
}
return bindMsgZh
@@ -176,11 +164,11 @@ func getBindMsg(lang i18n.Lang) *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 string, lang i18n.Lang) string {
func brandDisplay(brand, lang string) string {
if brand == "lark" || brand == "Lark" || brand == "LARK" {
return "Lark"
}
if lang.IsEnglish() {
if lang == "en" {
return "Feishu"
}
return "飞书"

View File

@@ -16,15 +16,12 @@ 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 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.).
// typed validation error — they normalize to the same wantDetail fields.
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
t.Helper()
if err == nil {
@@ -54,18 +51,7 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.Er
}
return
}
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)
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError; error = %v", err, err)
}
// assertEnvelope decodes stdout and checks it matches want exactly — every key
@@ -134,229 +120,14 @@ func TestConfigBindCmd_LangDefault(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "" {
t.Errorf("Lang = %q, want default %q (unset)", gotOpts.Lang, "")
if gotOpts.Lang != "zh" {
t.Errorf("Lang = %q, want default %q", gotOpts.Lang, "zh")
}
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) {
@@ -383,7 +154,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: "validation",
Type: "bind",
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",
})
@@ -422,7 +193,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: "validation",
Type: "bind",
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",
})
@@ -438,7 +209,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: "validation",
Type: "bind",
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",
})
@@ -566,8 +337,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.ExitAuth, output.ErrDetail{
Type: "config",
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "hermes",
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
Hint: "verify Hermes is installed and configured at " + envPath,
})
@@ -584,8 +355,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.ExitAuth, output.ErrDetail{
Type: "config",
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "openclaw",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify OpenClaw is installed and configured",
})
@@ -732,7 +503,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: "validation",
Type: "bind",
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",
})
@@ -750,8 +521,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.ExitAuth, output.ErrDetail{
Type: "config",
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "lark-channel",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify lark-channel-bridge is installed and configured",
})
@@ -770,8 +541,8 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "lark-channel",
Message: "accounts.app.id missing in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
})
@@ -789,8 +560,8 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "lark-channel",
Message: "accounts.app.secret is empty in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
})
@@ -1141,8 +912,12 @@ func TestConfigBindRun_OpenClawMultiAccount_MissingAppID(t *testing.T) {
if err == nil {
t.Fatal("expected error for multi-account without --app-id, got nil")
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
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)
}
}
@@ -1188,7 +963,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: "validation",
Type: "openclaw",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
}
wantWorkFirst := base
@@ -1196,17 +971,20 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
wantPersonalFirst := base
wantPersonalFirst.Hint = "available app IDs:\n cli_personal_222 (personal)\n cli_work_111 (work)"
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; err = %v", err, err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
}
if !reflect.DeepEqual(*exitErr.Detail, wantWorkFirst) &&
!reflect.DeepEqual(*exitErr.Detail, wantPersonalFirst) {
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
got, wantWorkFirst, wantPersonalFirst)
*exitErr.Detail, wantWorkFirst, wantPersonalFirst)
}
}
@@ -1231,7 +1009,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: "validation",
Type: "openclaw",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only_one",
})
@@ -1363,19 +1141,11 @@ func TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce(t *testing.T) {
Identity: "user-default",
})
msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default
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)
}
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "bind",
Message: msg.IdentityEscalationMessage,
Hint: msg.IdentityEscalationHint,
})
// Config on disk must remain untouched — the gate runs before
// commitBinding writes anything.
@@ -1536,8 +1306,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.ExitAuth, output.ErrDetail{
Type: "config",
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "hermes",
Message: "FEISHU_APP_ID not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
})
@@ -1556,8 +1326,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.ExitAuth, output.ErrDetail{
Type: "config",
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "hermes",
Message: "FEISHU_APP_SECRET not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
})
@@ -1582,8 +1352,8 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "openclaw",
Message: "openclaw.json missing channels.feishu section",
Hint: "configure Feishu in OpenClaw first",
})
@@ -1610,8 +1380,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.ExitAuth, output.ErrDetail{
Type: "config",
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "openclaw",
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
Hint: "configure channels.feishu.appSecret in openclaw.json",
})
@@ -1672,8 +1442,8 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "openclaw",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",
})
@@ -1704,14 +1474,10 @@ func TestGetBindMsg_En(t *testing.T) {
}
}
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)
}
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)
}
}
@@ -1874,36 +1640,3 @@ 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)
}
}

View File

@@ -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, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported source: %s", source).WithParam("--source")
return nil, output.ErrValidation("unsupported source: %s", source)
}
}
@@ -85,10 +85,11 @@ func selectCandidate(
// from ListCandidates itself and never reach here.
switch src {
case "openclaw":
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "no Feishu app configured in openclaw.json").
WithHint("configure channels.feishu.appId in openclaw.json")
return nil, output.ErrWithHint(output.ExitValidation, src,
"no Feishu app configured in openclaw.json",
"configure channels.feishu.appId in openclaw.json")
default:
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "%s: no app configured", src)
return nil, output.ErrValidation("%s: no app configured", src)
}
}
@@ -98,9 +99,9 @@ func selectCandidate(
return &candidates[i], nil
}
}
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")
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)))
}
if len(candidates) == 1 {
@@ -111,9 +112,9 @@ func selectCandidate(
return tuiPrompt(candidates)
}
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "multiple accounts in %s; pass --app-id <id>", cfgBase).
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
WithParam("--app-id")
return nil, output.ErrWithHint(output.ExitValidation, src,
fmt.Sprintf("multiple accounts in %s; pass --app-id <id>", cfgBase),
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
}
// formatCandidates renders candidates as "AppID (Label)" lines for error hints.
@@ -148,13 +149,14 @@ 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, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
WithHint("verify OpenClaw is installed and configured").
WithCause(err)
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
fmt.Sprintf("cannot read %s: %v", b.path, err),
"verify OpenClaw is installed and configured")
}
if cfg.Channels.Feishu == nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "openclaw.json missing channels.feishu section").
WithHint("configure Feishu in OpenClaw first")
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
"openclaw.json missing channels.feishu section",
"configure Feishu in OpenClaw first")
}
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
@@ -170,7 +172,8 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
return nil, output.Errorf(output.ExitInternal, "openclaw",
"internal: Build called before ListCandidates")
}
var selected *binding.CandidateApp
@@ -181,25 +184,26 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
}
}
if selected == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID)
return nil, output.Errorf(output.ExitInternal, "openclaw",
"internal: appID %q not in candidates", appID)
}
if selected.AppSecret.IsZero() {
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")
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")
}
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
if err != nil {
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)
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))
}
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
WithHint("use file: reference in config to bypass keychain").
WithCause(err)
return nil, output.Errorf(output.ExitInternal, "openclaw",
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
}
return &core.AppConfig{
@@ -225,14 +229,15 @@ func (b *hermesBinder) ConfigPath() string { return b.path }
func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
envMap, err := readDotenv(b.path)
if err != nil {
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)
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))
}
appID := envMap["FEISHU_APP_ID"]
if appID == "" {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "FEISHU_APP_ID not found in %s", b.path).
WithHint("run 'hermes setup' to configure Feishu credentials")
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")
}
b.envMap = envMap
return []Candidate{{AppID: appID, Label: "default"}}, nil
@@ -240,22 +245,24 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
if b.envMap == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
return nil, output.Errorf(output.ExitInternal, "hermes",
"internal: Build called before ListCandidates")
}
if b.envMap["FEISHU_APP_ID"] != appID {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match env", appID)
return nil, output.Errorf(output.ExitInternal, "hermes",
"internal: appID %q does not match env", appID)
}
appSecret := b.envMap["FEISHU_APP_SECRET"]
if appSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "FEISHU_APP_SECRET not found in %s", b.path).
WithHint("run 'hermes setup' to configure Feishu credentials")
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")
}
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
WithHint("use file: reference in config to bypass keychain").
WithCause(err)
return nil, output.Errorf(output.ExitInternal, "hermes",
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
}
return &core.AppConfig{
@@ -283,13 +290,14 @@ 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, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
WithHint("verify lark-channel-bridge is installed and configured").
WithCause(err)
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")
}
if cfg.Accounts.App.ID == "" {
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")
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")
}
b.cfg = cfg
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
@@ -297,30 +305,32 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
return nil, output.Errorf(output.ExitInternal, "lark-channel",
"internal: Build called before ListCandidates")
}
if b.cfg.Accounts.App.ID != appID {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match config", appID)
return nil, output.Errorf(output.ExitInternal, "lark-channel",
"internal: appID %q does not match config", appID)
}
if b.cfg.Accounts.App.Secret.IsZero() {
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")
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")
}
// 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, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err).
WithHint("check appSecret configuration in %s", b.path).
WithCause(err)
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))
}
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
WithHint("use file: reference in config to bypass keychain").
WithCause(err)
return nil, output.Errorf(output.ExitInternal, "lark-channel",
"keychain unavailable: %v", err)
}
return &core.AppConfig{
@@ -379,12 +389,10 @@ func resolveHermesEnvPath() string {
}
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
// source config. LARK_CHANNEL_CONFIG lets a host point bind at a projected
// single-account config without changing lark-cli's target config directory.
// 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.
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)

View File

@@ -4,7 +4,6 @@
package config
import (
"path/filepath"
"reflect"
"testing"
@@ -51,8 +50,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.ExitAuth, output.ErrDetail{
Type: "config",
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "openclaw",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",
})
@@ -64,8 +63,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.ExitAuth, output.ErrDetail{
Type: "config",
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: "hermes: no app configured",
})
}
@@ -101,7 +100,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Type: "openclaw",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
})
@@ -118,7 +117,7 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
}
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Type: "openclaw",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
})
@@ -153,7 +152,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: "validation",
Type: "openclaw",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only",
})
@@ -174,27 +173,3 @@ 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)
}
}

View File

@@ -16,7 +16,6 @@ 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"
)
@@ -126,11 +125,15 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
t.Fatal("expected error")
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitAuth)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
}
if !strings.Contains(err.Error(), "no active profile") {
t.Fatalf("error = %v, want to contain 'no active profile'", err)
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)
}
}
@@ -148,9 +151,8 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// --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.Lang != "en" {
t.Errorf("expected Lang en, got %s", gotOpts.Lang)
}
if !gotOpts.langExplicit {
t.Error("expected langExplicit=true when --lang is passed")
@@ -171,82 +173,14 @@ func TestConfigInitCmd_LangDefault(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "" {
t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang)
if gotOpts.Lang != "zh" {
t.Errorf("expected default Lang zh, got %s", 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
@@ -465,65 +399,16 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
if matched != nil && matched != cmd && !matched.SilenceUsage {
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
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")
}
})
}
}
// 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)
}
})
}

View File

@@ -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 errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value)
return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value)
}
app.DefaultAs = core.Identity(value)
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
return nil

View File

@@ -15,11 +15,9 @@ 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"
)
@@ -33,13 +31,9 @@ type ConfigInitOptions struct {
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
Brand string
New bool
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]
Lang string
langExplicit bool // true when --lang was explicitly passed
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
@@ -51,7 +45,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, UILang: i18n.LangZhCN}
opts := &ConfigInitOptions{Factory: f}
cmd := &cobra.Command{
Use: "init",
@@ -69,9 +63,6 @@ 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
}
@@ -86,7 +77,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", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
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")
@@ -94,25 +85,6 @@ 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.
@@ -160,7 +132,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: i18n.Lang(lang), Users: []core.AppUser{},
AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{},
}},
}
return core.SaveMultiAppConfig(config)
@@ -174,13 +146,7 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
}
cleanupOldConfig(existing, f, appId)
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)))
return saveAsOnlyApp(appId, secret, brand, lang)
}
// saveAsProfile appends or updates a named profile in the config.
@@ -201,10 +167,11 @@ 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 = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
multi.Apps[idx].Lang = lang
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
@@ -215,7 +182,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: i18n.Lang(lang),
Lang: lang,
Users: []core.AppUser{},
})
}
@@ -246,29 +213,9 @@ 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
WithParam("--app-secret")
return output.ErrValidation("App Secret cannot be empty for new configuration")
}
var app *core.AppConfig
@@ -276,25 +223,22 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
app = &existing.Apps[idx]
} else {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile").
WithParam("--app-secret")
return output.ErrValidation("App Secret cannot be empty for new profile")
}
} else {
app = existing.CurrentAppConfig("")
if app == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
WithParam("--app-secret")
return output.ErrValidation("App Secret cannot be empty for new configuration")
}
}
if app.AppId != appID {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty when changing App ID").
WithParam("--app-secret")
return output.ErrValidation("App Secret cannot be empty when changing App ID")
}
app.AppId = appID
app.Brand = brand
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
app.Lang = lang
return core.SaveMultiAppConfig(existing)
}
@@ -306,13 +250,13 @@ func configInitRun(opts *ConfigInitOptions) error {
scanner := bufio.NewScanner(f.IOStreams.In)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to read secret from stdin: %v", err).WithCause(err)
return output.ErrValidation("failed to read secret from stdin: %v", err)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret")
return output.ErrValidation("stdin is empty, expected app secret")
}
opts.appSecret = strings.TrimSpace(scanner.Text())
if opts.appSecret == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty")
return output.ErrValidation("app secret read from stdin is empty")
}
}
@@ -324,7 +268,7 @@ func configInitRun(opts *ConfigInitOptions) error {
// Validate --profile name if set
if opts.ProfileName != "" {
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
return output.ErrValidation("%v", err)
}
}
@@ -333,33 +277,35 @@ 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 errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", 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})
return nil
}
// 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).
// For interactive modes, prompt language selection if --lang was not explicitly set
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
lang, err := promptLangSelection()
savedLang := ""
if existing != nil {
if app := existing.CurrentAppConfig(""); app != nil {
savedLang = app.Lang
}
}
lang, err := promptLangSelection(savedLang)
if err != nil {
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
return err
}
opts.Lang = string(lang)
opts.UILang = lang
opts.Lang = lang
}
msg := getInitMsg(opts.UILang)
msg := getInitMsg(opts.Lang)
// Mode 3: Create new app directly (--new)
if opts.New {
@@ -368,17 +314,16 @@ func configInitRun(opts *ConfigInitOptions) error {
return err
}
if result == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
return output.ErrValidation("app creation returned no result")
}
existing, _ := core.LoadMultiAppConfig()
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
return nil
}
@@ -390,8 +335,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return err
}
if result == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
WithParam("--app-id")
return output.ErrValidation("App ID and App Secret cannot be empty")
}
existing, _ := core.LoadMultiAppConfig()
@@ -400,31 +344,34 @@ 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 errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
if err := wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil {
return err
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)
}
} else {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
WithParam("--app-id")
return output.ErrValidation("App ID and App Secret cannot be empty")
}
if result.Mode == "existing" {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
}
printLangPreferenceConfirmation(opts)
return nil
}
// Non-terminal: cannot run interactive mode, guide user to --new
if !f.IOStreams.IsTerminal {
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.")
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.")
}
// Mode 5: Legacy interactive (readline fallback)
@@ -452,7 +399,7 @@ func configInitRun(opts *ConfigInitOptions) error {
}
appIdInput, err := readLine(prompt)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
return output.ErrValidation("%s", err)
}
prompt = "App Secret"
@@ -461,7 +408,7 @@ func configInitRun(opts *ConfigInitOptions) error {
}
appSecretInput, err := readLine(prompt)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
return output.ErrValidation("%s", err)
}
prompt = "Brand (lark/feishu)"
@@ -472,7 +419,7 @@ func configInitRun(opts *ConfigInitOptions) error {
}
brandInput, err := readLine(prompt)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
return output.ErrValidation("%s", err)
}
resolvedAppId := appIdInput
@@ -494,18 +441,16 @@ func configInitRun(opts *ConfigInitOptions) error {
}
if resolvedAppId == "" || resolvedSecret.IsZero() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
WithParam("--app-id")
return output.ErrValidation("App ID and App Secret cannot be empty")
}
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
return nil
}

View File

@@ -6,17 +6,16 @@ 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.
@@ -126,16 +125,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
}, nil
}
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")
if appID == "" || appSecret == "" {
return nil, output.ErrValidation("App ID and App Secret cannot be empty")
}
return &configInitResult{
@@ -177,12 +168,10 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
}
// Step 1: Request app registration (begin)
// Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode.
httpClient := transport.NewHTTPClient(0)
httpClient := &http.Client{}
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
return nil, output.ErrAuth("app registration failed: %v", err)
}
// Step 2: Build and display verification URL + QR code
@@ -210,7 +199,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, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
return nil, output.ErrAuth("%v", err)
}
// Step 4: Handle Lark brand special case
@@ -219,12 +208,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, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
return nil, output.ErrAuth("lark endpoint retry failed: %v", err)
}
}
if result.ClientID == "" || result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
return nil, output.ErrAuth("app registration succeeded but missing client_id or client_secret")
}
// Determine final brand from response

View File

@@ -7,7 +7,6 @@ import (
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/i18n"
)
type initMsg struct {
@@ -27,10 +26,6 @@ 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{
@@ -48,7 +43,6 @@ var initMsgZh = &initMsg{
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
LangPreferenceSet: "语言偏好已设置:%s",
}
var initMsgEn = &initMsg{
@@ -66,27 +60,29 @@ 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",
}
// getInitMsg picks the zh/en TUI bundle; non-English falls back to zh.
func getInitMsg(lang i18n.Lang) *initMsg {
if lang.IsEnglish() {
func getInitMsg(lang string) *initMsg {
if lang == "en" {
return initMsgEn
}
return initMsgZh
}
// promptLangSelection shows the 中文/English picker and returns the chosen locale.
func promptLangSelection() (i18n.Lang, error) {
lang := i18n.LangZhCN
// 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"
}
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[i18n.Lang]().
huh.NewSelect[string]().
Title("Language / 语言").
Options(
huh.NewOption("中文", i18n.LangZhCN),
huh.NewOption("English", i18n.LangEnUS),
huh.NewOption("中文", "zh"),
huh.NewOption("English", "en"),
).
Value(&lang),
),

View File

@@ -6,8 +6,6 @@ package config
import (
"fmt"
"testing"
"github.com/larksuite/cli/internal/i18n"
)
func TestGetInitMsg_Zh(t *testing.T) {
@@ -31,7 +29,7 @@ func TestGetInitMsg_En(t *testing.T) {
}
func TestGetInitMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []i18n.Lang{"", "unknown", "xyz", "invalid"} {
for _, lang := range []string{"", "fr", "ja", "unknown"} {
msg := getInitMsg(lang)
if msg != initMsgZh {
t.Errorf("getInitMsg(%q) should default to zh", lang)
@@ -64,7 +62,6 @@ 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 == "" {
@@ -74,7 +71,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
}
func TestInitMsg_FormatStrings(t *testing.T) {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
for _, lang := range []string{"zh", "en"} {
msg := getInitMsg(lang)
// AppCreated and ConfigSaved should contain %s for App ID
got := fmt.Sprintf(msg.AppCreated, "cli_test123")
@@ -87,37 +84,3 @@ 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)
}
})
}
}

View File

@@ -1,133 +0,0 @@
// 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)
}
}

View File

@@ -8,7 +8,6 @@ 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"
@@ -54,10 +53,12 @@ func configKeychainDowngradeRun(f *cmdutil.Factory) error {
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)
return output.ErrWithHint(
output.ExitAPI,
"config",
fmt.Sprintf("keychain downgrade failed: %v", err),
"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.",
)
}
switch result {

View File

@@ -6,8 +6,8 @@
package config
import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -21,7 +21,7 @@ func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
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 output.ErrValidation("keychain-downgrade is only supported on macOS")
},
}
return cmd

View File

@@ -82,8 +82,8 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
"version": p.Version,
"capabilities": p.Capabilities,
}
if len(p.Rules) > 0 {
entry["rules"] = p.Rules
if p.Rule != nil {
entry["rule"] = p.Rule
}
entry["hooks"] = map[string]any{
"observers": p.Observers,

View File

@@ -59,20 +59,16 @@ func runConfigPolicyShow(f *cmdutil.Factory) error {
"source_name": sourceName,
"denied_paths": active.DeniedPaths,
}
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,
})
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,
}
out["rules"] = rules
}
output.PrintJson(f.IOStreams.Out, out)
return nil

View File

@@ -57,7 +57,7 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
MaxRisk: "read",
}
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rules: []*platform.Rule{rule},
Rule: rule,
Source: cmdpolicy.ResolveSource{
Kind: cmdpolicy.SourcePlugin,
Name: "secaudit",
@@ -83,16 +83,12 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
if got["denied_paths"] != float64(42) {
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
}
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)
ruleMap, ok := got["rule"].(map[string]any)
if !ok {
t.Fatalf("rules[0] wrong type")
t.Fatalf("rule field missing or wrong type")
}
if ruleMap["name"] != "secaudit" {
t.Errorf("rules[0].name = %v", ruleMap["name"])
t.Errorf("rule.name = %v", ruleMap["name"])
}
}
@@ -105,7 +101,7 @@ func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
t.Cleanup(cmdpolicy.ResetActiveForTesting)
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rules: []*platform.Rule{{Name: "my-yaml-rule"}},
Rule: &platform.Rule{Name: "my-yaml-rule"},
Source: cmdpolicy.ResolveSource{
Kind: cmdpolicy.SourceYAML,
Name: "/Users/alice/.lark-cli/policy.yml",

View File

@@ -6,7 +6,6 @@ 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"
@@ -43,14 +42,14 @@ func configRemoveRun(opts *ConfigRemoveOptions) error {
config, err := core.LoadMultiAppConfig()
if err != nil || config == nil || len(config.Apps) == 0 {
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured yet")
return output.ErrValidation("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 errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
// Clean up keychain entries for all apps after config is cleared.

View File

@@ -9,7 +9,6 @@ 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"
@@ -48,14 +47,14 @@ func configShowRun(opts *ConfigShowOptions) error {
if errors.Is(err, os.ErrNotExist) {
return core.NotConfiguredError()
}
return errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
}
if config == nil || len(config.Apps) == 0 {
return core.NotConfiguredError()
}
app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli profile list")
}
users := "(no logged-in users)"
if len(app.Users) > 0 {

View File

@@ -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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with --global").WithParam("--reset")
return output.ErrValidation("--reset cannot be used with --global")
}
if len(args) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with a value argument").WithParam("--reset")
return output.ErrValidation("--reset cannot be used with a value argument")
}
app.StrictMode = nil
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid value %q, valid values: bot | user | off", value)
return output.ErrValidation("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 errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {

View File

@@ -19,7 +19,6 @@ 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"
)
@@ -153,9 +152,7 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
}
}
// 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)
httpClient := &http.Client{}
mcpURL := ep.MCP + "/mcp"
type probeResult struct {

View File

@@ -23,8 +23,12 @@ 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). Existing
// Hint text is preserved; scopes are appended on a new line.
// 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.
func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
if err == nil || f == nil {
return
@@ -51,10 +55,12 @@ 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.
// locally. Matches pre-PR behaviour byte-for-byte; lives on the legacy
// envelope path until per-domain stage-2 typed migration.
//
// Deprecated: enrichment for the legacy envelope; the typed path is
// applyNeedAuthorizationHint above.
// 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.
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr == nil || exitErr.Detail == nil {
return
@@ -149,7 +155,47 @@ func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []s
if methodMap == nil {
return nil
}
return registry.DeclaredScopesForMethod(methodMap, identity)
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
}
// shortcutSupportsIdentity reports whether a shortcut supports the requested

View File

@@ -10,7 +10,6 @@ import (
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest"
)
const maxSuggestions = 3
@@ -29,7 +28,7 @@ func suggestEventKeys(input string) []string {
hits = append(hits, match{def.Key, 0})
continue
}
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
if d := levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d})
}
}
@@ -70,3 +69,34 @@ func unknownEventKeyErr(key string) error {
"Run 'lark-cli event list' to see available keys.",
)
}
// levenshtein computes classic edit distance (two-row DP).
func levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}

View File

@@ -10,6 +10,27 @@ import (
_ "github.com/larksuite/cli/events"
)
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "abc", 3},
{"kitten", "kitten", 0},
{"kitten", "sitten", 1},
{"kitten", "sitting", 3},
{"飞书", "飞书", 0},
{"飞书", "飞s", 1},
}
for _, tc := range cases {
if got := levenshtein(tc.a, tc.b); got != tc.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestSuggestEventKeys(t *testing.T) {
cases := []struct {
name string

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
func TestUnknownFlagName(t *testing.T) {
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --query", "query", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagName(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
}
}
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().String("find", "", "")
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
}
detail, _ := exitErr.Detail.Detail.(map[string]any)
valid, _ := detail["valid_flags"].([]string)
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
t.Errorf("valid_flags should list find & range, got %v", valid)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Type != "flag_error" {
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
}
}

View File

@@ -36,71 +36,47 @@ 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 {
// 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
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 = ""
}
rules, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
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{
PluginRules: pluginRules,
YAMLRules: yamlRules,
YAMLRule: yamlRule,
YAMLPath: yamlPath,
})
if err != nil {
cmdpolicy.SetActive(nil)
return err
}
if len(rules) == 0 {
if rule == nil {
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
return nil
}
// 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)
engine := cmdpolicy.New(rule)
decisions := engine.EvaluateAll(rootCmd)
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName)
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
cmdpolicy.Apply(rootCmd, denied)
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rules: rules,
Rule: rule,
Source: source,
DeniedPaths: len(denied),
})

View File

@@ -13,8 +13,6 @@ 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"
)
@@ -186,39 +184,6 @@ 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.

View File

@@ -14,7 +14,6 @@ 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"
)
@@ -41,7 +40,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", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().StringVar(&lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
_ = cmd.MarkFlagRequired("name")
@@ -56,12 +55,6 @@ 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")
@@ -122,7 +115,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
AppId: appID,
AppSecret: secret,
Brand: parsedBrand,
Lang: i18n.Lang(lang),
Lang: lang,
Users: []core.AppUser{},
})

View File

@@ -13,7 +13,6 @@ 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"
)
@@ -52,56 +51,6 @@ 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{

View File

@@ -4,30 +4,29 @@
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"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const rootLong = `lark-cli — Lark/Feishu CLI tool.
@@ -202,59 +201,43 @@ func configureFlagCompletions(args []string) {
// and returns the process exit code.
//
// Dispatch order:
// 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
// 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
// errs.CategoryOf via ExitCodeOf.
// 3. Legacy *output.ExitError: asExitError adapts it to the legacy
// envelope, written via WriteErrorEnvelope.
// 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+.
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
// 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)
}
}
// 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
}
// *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 typedExit
return output.ExitCodeOf(err)
}
if exitErr := asExitError(err); exitErr != nil {
@@ -273,19 +256,52 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return 1
}
// 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
// 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())
}
// asExitError converts known structured error types to *output.ExitError.
// Returns nil for unrecognized errors (e.g. cobra flag errors).
//
// Deprecated: legacy *output.ExitError bridge.
// Deprecated: legacy *output.ExitError bridge; removed after typed migration.
func asExitError(err error) *output.ExitError {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
@@ -310,12 +326,6 @@ func asExitError(err error) *output.ExitError {
func installUnknownSubcommandGuard(cmd *cobra.Command) {
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
cmd.RunE = unknownSubcommandRunE
// Route an unknown subcommand to unknownSubcommandRunE even when flags
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
// consumes no flags itself, so unknown flags belong to the (missing)
// subcommand; whitelisting them here prevents cobra from erroring on the
// flag first and printing usage instead of our structured suggestion.
cmd.FParseErrWhitelist.UnknownFlags = true
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
@@ -339,12 +349,10 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
}
unknown := args[0]
available := availableSubcommandNames(cmd)
suggestions := suggest.Closest(unknown, available, 6)
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
if len(suggestions) > 0 {
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
strings.Join(suggestions, ", "), cmd.CommandPath())
if len(available) > 0 {
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
}
return &output.ExitError{
Code: output.ExitValidation,
@@ -355,7 +363,6 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
Detail: map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
},
},
@@ -378,81 +385,6 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
return subs
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
// --find, where edit distance alone finds nothing). Other flag errors stay
// structured but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "flag_error",
Message: ferr.Error(),
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
},
}
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 {
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
Hint: hint,
Detail: map[string]any{
"unknown": "--" + name,
"command_path": c.CommandPath(),
"suggestions": suggestions,
"valid_flags": valid,
},
},
}
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
// those structured but generic — hallucinated flags are essentially always long.
func unknownFlagName(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
rest := msg[i+len(p):]
if j := strings.IndexAny(rest, " \t"); j >= 0 {
rest = rest[:j]
}
return rest, true
}
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
// the valid_flags detail).
func visibleFlagNames(c *cobra.Command) []string {
var names []string
c.Flags().VisitAll(func(f *pflag.Flag) {
if !f.Hidden {
names = append(names, f.Name)
}
})
sort.Strings(names)
return names
}
// installTipsHelpFunc wraps the default help function to append a TIPS section
// when a command has tips set via cmdutil.SetTips. It also force-shows global
// flags that are normally hidden in single-app mode (currently --profile)
@@ -485,55 +417,65 @@ func installTipsHelpFunc(root *cobra.Command) {
})
}
// 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.
// 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
//
// Deprecated: legacy *output.ExitError enrichment; typed PermissionError
// values produced by errclass.BuildAPIError already carry MissingScopes +
// ConsoleURL directly.
// 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 enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr.Detail == nil {
if exitErr.Detail == nil || exitErr.Detail.Type != "permission" {
return
}
// 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" {
// Extract required scopes from API error detail (shared helper)
scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
if len(scopes) == 0 {
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
}
// 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)
// 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)
// Clear raw API detail — useful info is now in message/hint/console_url.
exitErr.Detail.Detail = nil
identity := string(f.ResolvedIdentity)
if identity == "" {
identity = "user"
}
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
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
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
}
}

View File

@@ -281,7 +281,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "validation",
Type: "command_denied",
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: "validation",
Type: "command_denied",
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: "validation",
Type: "command_denied",
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)",
},

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
"testing"
@@ -21,7 +20,6 @@ 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"
)
@@ -139,96 +137,81 @@ func TestIsCompletionCommand(t *testing.T) {
// TestPromoteConfigError_* lives with the implementation in
// internal/errcompat/promote_test.go.
// 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) {
// 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) {
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"},
}
t.Run("21000 challenge_required", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
for _, tc := range cases {
t.Run(tc.name, 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.SubtypeChallengeRequired,
Code: 21000,
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: tc.subtype,
Code: tc.code,
Message: "blocked by access policy",
Hint: "complete challenge in your browser",
},
ChallengeURL: "https://example.com/challenge",
}
gotExit := handleRootError(f, spErr)
if gotExit != int(output.ExitContentSafety) {
t.Errorf("exit code = %d, want %d (ExitContentSafety)", gotExit, output.ExitContentSafety)
}
gotExit := handleRootError(f, spErr)
if gotExit != 1 {
t.Errorf("exit code = %d, want 1 (legacy carve-out)", gotExit)
}
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"])
}
})
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])
}
}
})
}
}
// newAuthErrorWithNeedAuthMarker builds a typed *errs.AuthenticationError whose Message
@@ -247,77 +230,6 @@ 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
@@ -445,136 +357,3 @@ 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)
}
}
}

View File

@@ -9,13 +9,11 @@ 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"
@@ -224,7 +222,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
if opts.PageAll && opts.Output != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --page-all are mutually exclusive").WithParam("--output")
return output.ErrValidation("--output and --page-all are mutually exclusive")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
@@ -273,10 +271,12 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
// 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
// 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())
if opts.PageAll {
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
@@ -300,6 +300,51 @@ 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 {
@@ -321,7 +366,9 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
}
}
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing)
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 nil
}
@@ -341,24 +388,9 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
}
}
recommended := registry.SelectRecommendedScope(scopes, "user")
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)
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))
}
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
@@ -380,7 +412,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
return client.RawApiRequest{}, nil, err
}
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--params and --data cannot both read from stdin (-)").WithParam("--params")
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
if err != nil {
@@ -397,14 +429,13 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
}
val, ok := params[name]
if !ok || util.IsEmptyValue(val) {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required path parameter: %s", name).
WithHint("lark-cli schema %s", schemaPath).
WithParam(name)
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("missing required path parameter: %s", name),
fmt.Sprintf("lark-cli schema %s", schemaPath))
}
valStr := fmt.Sprintf("%v", val)
if err := validate.ResourceName(valStr, name); err != nil {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(name).WithCause(err)
return client.RawApiRequest{}, nil, output.ErrValidation("%s", err)
}
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
delete(params, name)
@@ -420,10 +451,9 @@ 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, errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required query parameter: %s", name).
WithHint("lark-cli schema %s", schemaPath).
WithParam(name)
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("missing required query parameter: %s", name),
fmt.Sprintf("lark-cli schema %s", schemaPath))
}
if exists && !util.IsEmptyValue(value) {
queryParams[name] = value
@@ -458,7 +488,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
return client.RawApiRequest{}, nil, err
}
if _, ok := dataFields.(map[string]any); !ok {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a JSON object when used with --file").WithParam("--data")
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
}
}

View File

@@ -113,11 +113,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
}
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
// back to pointing at --help; the full machine-readable list lives in
// detail.available below (which also excludes hidden commands).
if !strings.Contains(exitErr.Detail.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
}
if strings.Contains(exitErr.Detail.Hint, "+secret") {
t.Error("hidden commands must not appear in the hint")
}
detail, ok := exitErr.Detail.Detail.(map[string]any)

View File

@@ -34,12 +34,10 @@ 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` — 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).
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.
## Wire format
@@ -75,11 +73,9 @@ 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` |
`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.
Carve-out: `SecurityPolicyError` keeps the legacy
`{type: "auth_error", code: "challenge_required"|"access_denied", ...}`
envelope until its consumers migrate. Removal is staged in **Migration**.
## Categories
@@ -119,11 +115,10 @@ Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
cmd/root.go handleRootError dispatches:
├─ output.ErrBare(code) → no envelope (stdout already written); exit = code
├─ *errs.SecurityPolicyError → legacy "auth_error" JSON envelope; exit 1
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6)
├─ *core.ConfigError → promoted to typed via errcompat ↑
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
├─ *core.ConfigError → asExitError adapts to legacy envelope
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
└─ untyped / Cobra error → plain "Error: <msg>" (no envelope); exit 1
```
@@ -132,31 +127,6 @@ 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`.
## Consumers
### Go (in-process)
@@ -213,25 +183,17 @@ 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.NewValidationError(subtype, msg).WithParam("--flag")` |
| Login required | `errs.NewAuthenticationError(errs.SubtypeTokenMissing, msg)` |
| Bad user input | `&errs.ValidationError{...}` or `output.ErrValidation(msg)` |
| Login required | `&errs.AuthenticationError{...}` |
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
| Local config missing | `errs.NewConfigError(errs.SubtypeNotConfigured, msg)` |
| Transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTimeout, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport`) |
| Local config missing | `&errs.ConfigError{...}` |
| Transport failure | `&errs.NetworkError{...}` |
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
| 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)` |
| 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{...}` |
### Authoring discipline
@@ -280,9 +242,8 @@ 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` 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**.)
(Legacy `*output.ExitError` and `SecurityPolicyError` retain hand-set
codes during stage 1.)
#### Split `Message`, `Hint`, and `Cause`
@@ -304,10 +265,15 @@ do not inline its `.Error()` into `Message`.
Conforming:
```go
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)
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,
}
```
Non-conforming:
@@ -328,51 +294,43 @@ For positional arguments, use the canonical name without dashes
### Constructing typed errors
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:
The minimal struct literal:
```go
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--data must be a valid JSON object: %v", parseErr).
WithParam("--data")
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",
}
```
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 but are `// Deprecated:` — new code goes
through the builder.
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).
#### 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 (builder form):
1440")`. The typed migration target:
```go
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
duration := runtime.Int("duration-minutes")
if duration < 1 || duration > 1440 {
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")
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",
}
}
_, err := runtime.DoAPI(req, opts)
@@ -402,7 +360,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.NewValidationError(errs.SubtypeInvalidArgument, "invalid --data: %v", jsonErr).WithCause(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.ValidationError{Problem: ..., Cause: 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))` |
@@ -433,11 +391,8 @@ through `runtime.DoAPI`.
#### Add a Subtype
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`).
1. Add a constant in `errs/subtypes.go` (framework) or
`errs/subtypes_service_<name>.go` (service).
2. If it maps from a Lark code, register the mapping in
`internal/errclass/codemeta_<service>.go`.
3. Add a dispatch test in `internal/errclass/classify_test.go`.
@@ -454,9 +409,10 @@ emits a warning to keep them visible.
Rare; the existing structs cover the 9 Categories with room. If you must:
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.
1. Add the struct in `errs/types.go` embedding `errs.Problem`, with a
nil-receiver-safe `Unwrap()` if it carries `Cause`.
2. Add an `IsXxx` predicate in `errs/predicates.go`.
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_builder_test.go`.
3. Add a wire-format pin in `errs/marshal_test.go`.
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the
@@ -492,36 +448,51 @@ will be removed once business migration completes.
## Migration
**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.
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.
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.
Stages:
### Current state
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`.
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.
During migration, helper assertions accept both shapes (see
`shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`)
so the build stays green domain-by-domain.
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 24 — 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
Before / after at a call site (illustrative — actually performed in
stage 3):
```go
// before (legacy)
@@ -531,16 +502,6 @@ 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

View File

@@ -55,28 +55,6 @@ 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"},
@@ -138,26 +116,33 @@ func TestConfigError_MarshalJSON(t *testing.T) {
func TestNetworkError_MarshalJSON(t *testing.T) {
ne := &NetworkError{
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTransport, Message: "transport"},
CauseKind: "timeout",
}
b, _ := json.Marshal(ne)
s := string(b)
for _, want := range []string{
`"type":"network"`,
`"subtype":"timeout"`,
`"subtype":"transport"`,
`"cause":"timeout"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
if strings.Contains(s, `"cause"`) {
t.Errorf("cause field should no longer be on the wire; got %s", 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)
}
}
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)
@@ -166,39 +151,19 @@ 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)
}
}
}
// 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))
// 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)
}
}
@@ -220,32 +185,6 @@ 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"},

View File

@@ -86,12 +86,3 @@ 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)
}

View File

@@ -14,21 +14,16 @@ 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"`
Troubleshooter string `json:"troubleshooter,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"`
Retryable bool `json:"retryable,omitempty"`
}
// Error satisfies the standard `error` interface. A nil receiver is treated

View File

@@ -34,8 +34,7 @@ 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
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)
SubtypeAppNotInstalled Subtype = "app_not_installed" // app not enabled / not installed in this tenant
)
// CategoryConfig subtypes
@@ -47,11 +46,7 @@ const (
// CategoryNetwork subtypes
const (
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
SubtypeNetworkTransport Subtype = "transport" // transport-layer failure (timeout / TLS / DNS / 5xx); see NetworkError.CauseKind
)
// CategoryAPI subtypes
@@ -62,10 +57,6 @@ 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)
@@ -78,12 +69,7 @@ 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
const (
SubtypeConfirmationRequired Subtype = "confirmation_required" // high-risk operation needs explicit --yes
)
// CategoryConfirmation subtypes intentionally have no declarations yet.

View File

@@ -0,0 +1,21 @@
// 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_<name>.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"
)

View File

@@ -3,59 +3,6 @@
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.
@@ -75,60 +22,6 @@ 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) 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.
@@ -146,150 +39,17 @@ func (e *AuthenticationError) Unwrap() error {
return e.Cause
}
// Error is nil-receiver safe; see ValidationError.Error.
func (e *AuthenticationError) Error() string {
if e == nil {
return ""
}
return e.Problem.Error()
}
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:"-"`
MissingScopes []string `json:"missing_scopes,omitempty"`
Identity string `json:"identity,omitempty"`
ConsoleURL string `json:"console_url,omitempty"`
}
// 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.
// 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"`
@@ -304,63 +64,15 @@ func (e *ConfigError) Unwrap() error {
return e.Cause
}
// 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.
// 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.
type NetworkError struct {
Problem
Cause error `json:"-"`
CauseKind string `json:"cause,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
@@ -371,112 +83,13 @@ func (e *NetworkError) Unwrap() error {
return e.Cause
}
// 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.
// APIError is the typed error for CategoryAPI (catch-all for classified Lark API
// business errors). Detail preserves the raw Lark error map for diagnostics.
type APIError struct {
Problem
Cause error `json:"-"`
Detail map[string]any `json:"detail,omitempty"`
}
// 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 {
@@ -493,125 +106,14 @@ 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:"-"`
}
// 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.
// 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:"-"`
@@ -625,127 +127,10 @@ 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" | "unknown".
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
// it is intentionally not serialized.
// Risk is one of: "read" | "write" | "high-risk-write".
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
}

View File

@@ -1,24 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs_test
package errs
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
// ============================== JSON shape & embed ==============================
func TestPermissionErrorJSONShape(t *testing.T) {
perm := &errs.PermissionError{
Problem: errs.Problem{
Category: errs.CategoryAuthorization,
Subtype: errs.SubtypeMissingScope,
perm := &PermissionError{
Problem: Problem{
Category: CategoryAuthorization,
Subtype: SubtypeMissingScope,
Message: "x",
},
MissingScopes: []string{"docx:document"},
@@ -57,35 +53,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 := &errs.PermissionError{
Problem: errs.Problem{
Category: errs.CategoryAuthorization,
Subtype: errs.SubtypeMissingScope,
perm := &PermissionError{
Problem: Problem{
Category: CategoryAuthorization,
Subtype: SubtypeMissingScope,
Message: "missing",
},
}
var p *errs.Problem
var p *Problem
if errors.As(perm, &p) {
t.Errorf("errors.As(*PermissionError, &*Problem) unexpectedly succeeded; Go embed semantic changed")
}
got, ok := errs.ProblemOf(perm)
got, ok := 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 != errs.CategoryAuthorization {
t.Errorf("extracted Problem.Category = %q, want %q", got.Category, errs.CategoryAuthorization)
if got.Category != CategoryAuthorization {
t.Errorf("extracted Problem.Category = %q, want %q", got.Category, CategoryAuthorization)
}
}
func TestSecurityPolicyErrorUnwrap(t *testing.T) {
orig := errors.New("transport stalled")
spe := &errs.SecurityPolicyError{
Problem: errs.Problem{Category: errs.CategoryPolicy, Subtype: errs.Subtype("challenge_required"), Message: "blocked"},
spe := &SecurityPolicyError{
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("challenge_required"), Message: "blocked"},
Cause: orig,
}
if got := errors.Unwrap(spe); got != orig {
@@ -110,12 +106,12 @@ func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
name string
call func() error
}{
{"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() }},
{"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() }},
}
for _, c := range checks {
t.Run(c.name, func(t *testing.T) {
@@ -131,44 +127,6 @@ 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
@@ -179,12 +137,12 @@ func TestTypedErrors_UnwrapPropagatesCause(t *testing.T) {
name string
err interface{ Unwrap() error }
}{
{"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}},
{"ValidationError", &ValidationError{Cause: cause}},
{"AuthenticationError", &AuthenticationError{Cause: cause}},
{"ConfigError", &ConfigError{Cause: cause}},
{"NetworkError", &NetworkError{Cause: cause}},
{"SecurityPolicyError", &SecurityPolicyError{Cause: cause}},
{"InternalError", &InternalError{Cause: cause}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -194,387 +152,3 @@ 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()
})
}
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)
}
})
}

View File

@@ -1,156 +0,0 @@
// 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
}

View File

@@ -1,328 +0,0 @@
// 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
}

View File

@@ -18,8 +18,6 @@ const (
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.
@@ -41,21 +39,5 @@ func Keys() []event.KeyDefinition {
},
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},
},
}
}

View File

@@ -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, ≥1 per plugin | Denies whole subtrees |
| `Restrict(Rule)` | Bootstrap-time, single per binary | Denies whole subtrees |
### Plugin lifecycle
@@ -102,17 +102,10 @@ 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`.
- 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.
- 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.
- 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.
@@ -122,8 +115,7 @@ 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. With several rules this is per-rule: an unannotated command
is allowed as long as one rule that opts in also grants it.
adoption.
- 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
@@ -152,7 +144,8 @@ 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 |
| `multiple_restrict_plugins` | Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | 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 |
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
| `install_panic` | `Plugin.Install` panicked | Yes |
@@ -172,7 +165,6 @@ 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:

View File

@@ -37,7 +37,7 @@ type Builder struct {
caps Capabilities
actions []func(Registrar)
rules []*Rule
rule *Rule
hookNames map[string]bool
errs []error
@@ -125,8 +125,7 @@ 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). It may be called more than once; each call adds one scoped
// Rule and the engine OR-combines them.
// FailOpen).
func (b *Builder) Restrict(rule *Rule) *Builder {
if rule == nil {
b.errs = append(b.errs, errors.New("Restrict(nil): rule must not be nil"))
@@ -134,14 +133,7 @@ func (b *Builder) Restrict(rule *Rule) *Builder {
}
b.caps.Restricts = true
b.caps.FailurePolicy = FailClosed
// 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)
b.rule = rule
return b
}
@@ -151,7 +143,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 len(b.rules) > 0 && b.caps.FailurePolicy == FailOpen {
if b.rule != nil && b.caps.FailurePolicy == FailOpen {
b.errs = append(b.errs, errors.New(
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
}
@@ -163,7 +155,7 @@ func (b *Builder) Build() (Plugin, error) {
version: b.version,
caps: b.caps,
actions: b.actions,
rules: b.rules,
rule: b.rule,
}, nil
}
@@ -206,15 +198,15 @@ type builtPlugin struct {
version string
caps Capabilities
actions []func(Registrar)
rules []*Rule
rule *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 {
for _, rule := range p.rules {
r.Restrict(rule)
if p.rule != nil {
r.Restrict(p.rule)
}
for _, action := range p.actions {
action(r)

View File

@@ -17,8 +17,7 @@ type recorder struct {
observers int
wrappers int
lifecycles int
rule *platform.Rule // last rule (existing single-rule assertions)
rules []*platform.Rule // every rule, in Restrict order
rule *platform.Rule
}
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
@@ -26,39 +25,7 @@ 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
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 (r *recorder) Restrict(rule *platform.Rule) { r.rule = rule }
func TestBuilder_basicAssembly(t *testing.T) {
p, err := platform.NewPlugin("audit", "0.1.0").

View File

@@ -13,10 +13,9 @@ 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 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).
// Restrict may be called at most once per plugin; multiple 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.
@@ -30,9 +29,8 @@ type Registrar interface {
// On registers a lifecycle handler for the given event.
On(event LifecycleEvent, hookName string, fn LifecycleHandler)
// 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 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(r *Rule)
}

View File

@@ -14,7 +14,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/util"
)
// 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 transport.Fallback()
return util.FallbackTransport()
}
// RoundTrip implements http.RoundTripper.

View File

@@ -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: %w", err)
return nil, fmt.Errorf("token refresh parse error: %v", err)
}
return data, nil
}

View File

@@ -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: %w", err)
return fmt.Errorf("failed to parse response: %v", err)
}
if resp.Code != 0 {
return fmt.Errorf("[%d] %s", resp.Code, resp.Msg)

View File

@@ -5,130 +5,91 @@ package client
import (
"bytes"
"crypto/x509"
"encoding/json"
"errors"
"net"
"fmt"
"io"
"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 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).
// 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.
func WrapDoAPIError(err error) error {
if err == nil {
return nil
}
// (1) Pass-through any typed errs.* error.
var existing *output.ExitError
if errors.As(err, &existing) {
return err
}
if _, ok := errs.ProblemOf(err); ok {
return err
}
// (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)
if isJSONDecodeError(err, false) {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
}
// (3) Otherwise classify as a network failure with a chain-derived subtype.
return errs.NewNetworkError(classifyNetworkSubtype(err),
"API call failed: %v", err).
WithCause(err)
return output.ErrNetwork("API call failed: %v", err)
}
// 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.
// 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.
func WrapJSONResponseParseError(err error, body []byte) error {
if err == nil {
return nil
}
var e *errs.InternalError
if len(bytes.TrimSpace(body)) == 0 {
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)
return output.ErrWithHint(output.ExitAPI, "api_error",
"API returned an empty JSON response body", rawAPIJSONHint)
}
return e.WithHint("%s", rawAPIJSONHint).WithCause(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)
}
// 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 {
func isJSONDecodeError(err error, allowEOF bool) bool {
var syntaxErr *json.SyntaxError
var unmarshalTypeErr *json.UnmarshalTypeError
if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalTypeErr) {
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 strings.Contains(msg, "unexpected end of JSON input") ||
strings.Contains(msg, "cannot unmarshal") {
if allowEOF && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
return true
}
lower := strings.ToLower(msg)
return strings.Contains(lower, "invalid character") && strings.Contains(lower, "json")
msg := err.Error()
if allowEOF && strings.Contains(msg, "unexpected EOF") {
return true
}
return strings.Contains(msg, "unexpected end of JSON input") ||
strings.Contains(msg, "invalid character") ||
strings.Contains(msg, "cannot unmarshal")
}

View File

@@ -4,312 +4,173 @@
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"
)
// ─────────────────────────────────────────────────────────────────────────────
// 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).
// ─────────────────────────────────────────────────────────────────────────────
// 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)
func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
if err == nil {
t.Fatal("expected error")
}
if ie.Category != errs.CategoryInternal {
t.Errorf("Category = %v, want %v", ie.Category, errs.CategoryInternal)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
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 JSON diagnostic message, got %#v", exitErr.Detail)
}
}
// 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)
func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{"))
if err == nil {
t.Fatal("expected error")
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
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)
}
}
// 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_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_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)
// 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)
}
if ne.Subtype != errs.SubtypeNetworkTLS {
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS)
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)
}
}
// 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)
// 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"},
}
if ne.Subtype != errs.SubtypeNetworkTLS {
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS)
}
}
// 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, 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 pass-through, got %T %v", got, 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 "<nil>"
}
return exitErr.Detail.Type
}(),
tc.wantType, exitErr.Detail)
}
})
}
}
// 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)
// 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) {
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}},
}
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)
}
})
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 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) {
// 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}
outer := &errs.AuthenticationError{
Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired, Message: "expired"},
Cause: jsonErr,
}
authWrappingJSON := fmt.Errorf("%w: wrapped %w", output.ErrAuth("token expired"), 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())
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)
}
}

View File

@@ -18,12 +18,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/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
@@ -52,38 +48,16 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s
if err != nil {
var unavailableErr *credential.TokenUnavailableError
if errors.As(err, &unavailableErr) {
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 "", output.ErrAuth("no access token available for %s", as)
}
return "", err
}
if result.Token == "" {
return "", newTokenMissingError(as, nil)
return "", output.ErrAuth("no access token available for %s", as)
}
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.
@@ -119,14 +93,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. Today that wire shape is still the legacy
// *output.ExitError envelope (network / api_error); future framework-
// (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
// 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.*) flow
// through unchanged.
// resolveAccessToken's missing-credential paths, or a typed *errs.* from
// future stages) 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
@@ -203,7 +177,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, errs.NewNetworkError(errs.SubtypeNetworkTransport, "stream request failed: %s", err).WithCause(err)
return nil, output.ErrNetwork("stream request failed: %s", err)
}
// Apply headers from opts
@@ -221,7 +195,7 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
resp, err := httpClient.Do(httpReq)
if err != nil {
cancel()
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
return nil, output.ErrNetwork("stream request failed: %s", err)
}
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
@@ -230,34 +204,15 @@ 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))
subtype := errs.SubtypeNetworkTransport
if resp.StatusCode >= 500 {
subtype = errs.SubtypeNetworkServer
}
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)
return nil, output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
}
netErr = netErr.WithCode(resp.StatusCode)
if logID := streamLogID(resp.Header); logID != "" {
netErr = netErr.WithLogID(logID)
}
return nil, netErr
return nil, output.ErrNetwork("HTTP %d", resp.StatusCode)
}
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
@@ -283,10 +238,10 @@ func buildStreamURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error)
pathKey := strings.TrimPrefix(segment, ":")
pathValue, ok := req.PathParams[pathKey]
if !ok {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "missing path param %q for %s", pathKey, req.ApiPath).WithParam(pathKey)
return "", output.ErrValidation("missing path param %q for %s", pathKey, req.ApiPath)
}
if pathValue == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "empty path param %q for %s", pathKey, req.ApiPath).WithParam(pathKey)
return "", output.ErrValidation("empty path param %q for %s", pathKey, req.ApiPath)
}
pathSegs = append(pathSegs, url.PathEscape(pathValue))
}
@@ -312,7 +267,7 @@ func buildStreamBody(body interface{}) (io.Reader, string, error) {
default:
payload, err := json.Marshal(typed)
if err != nil {
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "failed to encode request body: %s", err).WithCause(err)
return nil, "", output.Errorf(output.ExitInternal, "api_error", "failed to encode request body: %s", err)
}
return bytes.NewReader(payload), "application/json", nil
}
@@ -333,9 +288,11 @@ 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 — otherwise an empty
// for everything else) instead of a bare fmt.Errorf. Without this, an empty
// or malformed page body would surface to the root handler as a plain-text
// "Error: ..." line and bypass the JSON stderr envelope contract.
// "Error: ..." line, bypassing the JSON stderr envelope contract. Stage-4
// framework-boundary migration will flip this wrapper to typed
// *errs.InternalError / *errs.NetworkError.
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
resp, err := c.DoAPI(ctx, request)
if err != nil {
@@ -489,23 +446,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)
// 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}.
// 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.
func (c *APIClient) CheckResponse(result interface{}, identity core.Identity) error {
resultMap, ok := result.(map[string]interface{})
if !ok || resultMap == nil {
return nil
}
if code, _ := util.ToFloat64(resultMap["code"]); code == 0 {
code, _ := util.ToFloat64(resultMap["code"])
if code == 0 {
return nil
}
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)
larkCode := int(code)
msg, _ := resultMap["msg"].(string)
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
}

View File

@@ -9,7 +9,6 @@ import (
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
@@ -19,8 +18,6 @@ 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"
@@ -431,39 +428,6 @@ 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{}
@@ -472,93 +436,17 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T
return nil, &credential.TokenUnavailableError{Source: "test", Type: spec.Type}
}
// 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.
// 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 <identity>"),
// DoSDKRequest must surface it with the original auth classification —
// 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_AuthFailureSurfacesTypedAuthenticationError(t *testing.T) {
func TestDoSDKRequest_AuthFailurePreservesAuthCategory(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
@@ -573,20 +461,22 @@ func TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError(t *testing.T)
if err == nil {
t.Fatal("expected auth error, got nil")
}
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)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if authErr.Subtype != errs.SubtypeTokenMissing {
t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
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)
}
}
// TestDoSDKRequest_TransportFailureWrapsAsNetwork pins that genuinely untyped
// SDK transport errors get the typed network classification via WrapDoAPIError.
// SDK transport errors get the network classification via WrapDoAPIError.
// io.ErrUnexpectedEOF from a RoundTripper surfaces through net/http as a
// *url.Error, which the wrap classifier reaches as the transport-error
// fallback (no specific subtype matches — falls back to transport).
// *url.Error, which the wrap classifier recognises as a transport error.
func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return nil, io.ErrUnexpectedEOF
@@ -601,29 +491,25 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
if err == nil {
t.Fatal("expected error from broken transport, got nil")
}
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Fatalf("expected *errs.NetworkError, got %T (%v)", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if netErr.Category != errs.CategoryNetwork {
t.Errorf("Category = %v, want %v", netErr.Category, errs.CategoryNetwork)
if exitErr.Code != output.ExitNetwork {
t.Fatalf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
}
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)
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
t.Fatalf("Detail.Type = %v, want network", exitErr.Detail)
}
}
// 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".
// 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.
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{
@@ -643,20 +529,17 @@ func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
if err == nil {
t.Fatal("expected JSON parse error, got nil")
}
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError, got %T (%v)", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if intErr.Category != errs.CategoryInternal {
t.Errorf("Category = %v, want %v", intErr.Category, errs.CategoryInternal)
if exitErr.Code != output.ExitAPI {
t.Fatalf("Code = %d, want %d (api)", exitErr.Code, output.ExitAPI)
}
if intErr.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %v, want %v", intErr.Subtype, errs.SubtypeInvalidResponse)
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
t.Fatalf("Detail.Type = %v, want api_error", exitErr.Detail)
}
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)
if exitErr.Detail.Hint != rawAPIJSONHint {
t.Errorf("Detail.Hint = %q, want rawAPIJSONHint", exitErr.Detail.Hint)
}
}

View File

@@ -1,51 +0,0 @@
// 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")
}
}

View File

@@ -14,7 +14,6 @@ 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"
@@ -53,10 +52,12 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
}
check := opts.CheckError
if check == nil {
// 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.
// 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.
check = func(r interface{}, id core.Identity) error {
return (&APIClient{}).CheckResponse(r, id)
}
@@ -64,20 +65,9 @@ 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)
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)
return output.Errorf(httpExitCode(resp.StatusCode), "http_error", "HTTP %d: %s", resp.StatusCode, body)
}
// JSON responses: always check for business errors before saving.
@@ -112,9 +102,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
// Non-JSON (binary) responses.
if opts.JqExpr != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--jq requires a JSON response (got Content-Type: %s)", ct).
WithParam("--jq")
return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct)
}
if opts.OutputPath != "" {
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
@@ -123,7 +111,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 classifySaveErr(err)
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
}
fmt.Fprintf(opts.ErrOut, "binary response detected (Content-Type: %s), saved to file\n", ct)
output.PrintJson(opts.Out, meta)
@@ -133,23 +121,12 @@ 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 classifySaveErr(err)
return output.Errorf(output.ExitInternal, "file_error", "%s", 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.
@@ -183,13 +160,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: %w", err)
return nil, fmt.Errorf("unsafe output path: %s", err)
case errors.As(err, &me):
return nil, fmt.Errorf("create directory: %w", err)
return nil, fmt.Errorf("create directory: %s", err)
case errors.As(err, &we):
return nil, fmt.Errorf("cannot write file: %w", err)
return nil, fmt.Errorf("cannot write file: %s", err)
default:
return nil, fmt.Errorf("cannot write file: %w", err)
return nil, fmt.Errorf("cannot write file: %s", err)
}
}
@@ -248,3 +225,12 @@ 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
}

View File

@@ -15,7 +15,6 @@ 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"
)
@@ -295,12 +294,9 @@ 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 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))
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)
}
}
@@ -316,12 +312,9 @@ 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 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))
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)
}
}

View File

@@ -15,12 +15,8 @@ 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 {
Rules []*platform.Rule
Rule *platform.Rule
Source ResolveSource
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
}
@@ -60,26 +56,20 @@ func GetActive() *ActivePolicy {
return cloneActivePolicy(activePolicy)
}
// 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.
// 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.
func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
if in == nil {
return nil
}
cp := *in
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
}
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
}
return &cp
}

View File

@@ -17,7 +17,6 @@ package cmdpolicy
import (
"fmt"
"strings"
"github.com/bmatcuk/doublestar/v4"
"github.com/spf13/cobra"
@@ -37,45 +36,16 @@ type Decision struct {
Reason string // human-readable
}
// 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.
// Engine evaluates a Rule against the command tree. It is stateless except
// for the Rule snapshot it was constructed with.
type Engine struct {
rules []*platform.Rule
rule *platform.Rule
}
// 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.
// New returns an Engine bound to a Rule. A nil Rule means "no user-layer
// restriction" -- EvaluateOne always returns Allowed=true.
func New(rule *platform.Rule) *Engine {
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}
return &Engine{rule: rule}
}
// EvaluateAll walks the command tree and evaluates every **runnable**
@@ -111,29 +81,27 @@ 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. 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).
// Allowed=true when the engine has no Rule.
func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
if len(e.rules) == 0 {
if e.rule == nil {
return Decision{Allowed: true}
}
r := e.rule
path := CanonicalPath(cmd)
if IsDiagnosticPath(path) {
return Decision{Allowed: true}
}
// 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.
// A registered Rule expresses intent over the closed risk taxonomy
// (read / write / high-risk-write). Two ways a command can fall
// outside that taxonomy:
//
// The "absent" case (no risk_level annotation at all) is per-rule:
// each rule's AllowUnannotated decides, so it lives inside evalRule.
// - "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.
cmdRiskStr, hasRisk := cmdmeta.Risk(cmd)
cmdRisk := platform.Risk(cmdRiskStr)
var (
@@ -149,31 +117,7 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
Reason: fmt.Sprintf("invalid risk %q; did you mean %q?", cmdRiskStr, suggestRisk(cmdRiskStr)),
}
}
}
// 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 {
} else if !r.AllowUnannotated {
return Decision{
Allowed: false,
ReasonCode: "risk_not_annotated",
@@ -181,9 +125,7 @@ func evalRule(r *platform.Rule, path string, cmd *cobra.Command, hasRisk bool, c
}
}
// 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.
// Axis 1: Deny has priority.
if matched, ok := firstMatch(r.Deny, path); ok {
return Decision{
Allowed: false,
@@ -229,34 +171,6 @@ func evalRule(r *platform.Rule, path string, cmd *cobra.Command, hasRisk bool, c
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

View File

@@ -398,93 +398,6 @@ 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/.

View File

@@ -33,69 +33,44 @@ type PluginRule struct {
type Sources struct {
PluginRules []PluginRule
YAMLRules []*platform.Rule
YAMLRule *platform.Rule
YAMLPath string
}
var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one plugin may own the policy")
var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one is permitted")
// 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)
// 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)
}
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)
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)
}
return rules, ResolveSource{Kind: SourcePlugin, Name: owners[0]}, nil
return pr.Rule, ResolveSource{Kind: SourcePlugin, Name: pr.PluginName}, nil
}
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)
}
if s.YAMLRule != nil {
if err := ValidateRule(s.YAMLRule); err != nil {
return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", s.YAMLPath, err)
}
return s.YAMLRules, ResolveSource{Kind: SourceYAML, Name: s.YAMLPath}, nil
return s.YAMLRule, 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.YAMLRules. A
// present file yields one or more rules (see yaml.Parse).
func LoadYAMLPolicy(path string) ([]*platform.Rule, error) {
// so callers can pass the result straight into Sources.YAMLRule.
func LoadYAMLPolicy(path string) (*platform.Rule, error) {
if path == "" {
return nil, nil
}
@@ -109,9 +84,9 @@ func LoadYAMLPolicy(path string) ([]*platform.Rule, error) {
if err != nil {
return nil, fmt.Errorf("read policy yaml %q: %w", path, err)
}
rules, err := pyaml.Parse(data)
rule, err := pyaml.Parse(data)
if err != nil {
return nil, fmt.Errorf("policy yaml %q: %w", path, err)
}
return rules, nil
return rule, nil
}

View File

@@ -21,45 +21,23 @@ func TestResolve_singlePluginWins(t *testing.T) {
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 1 || got[0] != rule || src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" {
if got != 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}},
YAMLRules: []*platform.Rule{yamlRule},
YAMLRule: yamlRule,
YAMLPath: "/some/policy.yml",
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 1 || got[0].Name != "from-plugin" || src.Kind != cmdpolicy.SourcePlugin {
if got.Name != "from-plugin" || src.Kind != cmdpolicy.SourcePlugin {
t.Fatalf("plugin should shadow yaml, got %+v / %+v", got, src)
}
}
@@ -67,13 +45,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{
YAMLRules: []*platform.Rule{yamlRule},
YAMLPath: "/some/policy.yml",
YAMLRule: yamlRule,
YAMLPath: "/some/policy.yml",
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 1 || got[0].Name != "from-yaml" || src.Kind != cmdpolicy.SourceYAML {
if got.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" {
@@ -81,36 +59,19 @@ 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 len(got) != 0 || src.Kind != cmdpolicy.SourceNone {
t.Fatalf("expected (empty, SourceNone), got (%v, %+v)", got, src)
if got != nil || src.Kind != cmdpolicy.SourceNone {
t.Fatalf("expected (nil, SourceNone), got (%v, %+v)", got, src)
}
}
// 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) {
// 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) {
_, _, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: []cmdpolicy.PluginRule{
{PluginName: "a", Rule: &platform.Rule{Name: "a"}},
@@ -123,26 +84,26 @@ func TestResolve_multipleRestrictPluginsIsError(t *testing.T) {
}
// LoadYAMLPolicy: missing file returns (nil, nil) silently so callers
// can pass the result straight into Sources.YAMLRules without special-
// can pass the result straight into Sources.YAMLRule without special-
// casing not-exist.
func TestLoadYAMLPolicy_missingIsSilent(t *testing.T) {
missing := filepath.Join(t.TempDir(), "absent-policy.yml")
rules, err := cmdpolicy.LoadYAMLPolicy(missing)
rule, err := cmdpolicy.LoadYAMLPolicy(missing)
if err != nil {
t.Fatalf("missing yaml should not error, got %v", err)
}
if rules != nil {
t.Fatalf("missing yaml should return nil rules, got %+v", rules)
if rule != nil {
t.Fatalf("missing yaml should return nil rule, got %+v", rule)
}
}
func TestLoadYAMLPolicy_emptyPathIsNoop(t *testing.T) {
rules, err := cmdpolicy.LoadYAMLPolicy("")
rule, err := cmdpolicy.LoadYAMLPolicy("")
if err != nil {
t.Fatalf("empty path should not error, got %v", err)
}
if rules != nil {
t.Fatalf("empty path should return nil rules, got %+v", rules)
if rule != nil {
t.Fatalf("empty path should return nil rule, got %+v", rule)
}
}
@@ -152,11 +113,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)
}
rules, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
rule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
if err != nil {
t.Fatalf("LoadYAMLPolicy err: %v", err)
}
if len(rules) != 1 || rules[0].Name != "from-yaml" {
t.Fatalf("expected one rule with name=from-yaml, got %+v", rules)
if rule == nil || rule.Name != "from-yaml" {
t.Fatalf("expected rule with name=from-yaml, got %+v", rule)
}
}

View File

@@ -5,7 +5,6 @@ package cmdpolicy
import (
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/suggest"
)
// suggestRisk returns the closest valid Risk literal by edit distance
@@ -21,9 +20,9 @@ func suggestRisk(bad string) string {
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
}
best := string(candidates[0])
bestDist := suggest.Levenshtein(lowered, best)
bestDist := levenshtein(lowered, best)
for _, c := range candidates[1:] {
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist {
if d := levenshtein(lowered, string(c)); d < bestDist {
bestDist, best = d, string(c)
}
}
@@ -41,3 +40,47 @@ func toLower(s string) string {
}
return string(b)
}
// levenshtein computes the classic edit distance between two strings.
// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set
// makes raw performance irrelevant — clarity beats trickiness here.
func levenshtein(a, b string) int {
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
prev := make([]int, len(b)+1)
curr := make([]int, len(b)+1)
for j := 0; j <= len(b); j++ {
prev[j] = j
}
for i := 1; i <= len(a); i++ {
curr[0] = i
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[j] = min3(
prev[j]+1, // deletion
curr[j-1]+1, // insertion
prev[j-1]+cost, // substitution
)
}
prev, curr = curr, prev
}
return prev[len(b)]
}
func min3(a, b, c int) int {
m := a
if b < m {
m = b
}
if c < m {
m = c
}
return m
}

View File

@@ -29,3 +29,23 @@ func TestSuggestRisk(t *testing.T) {
}
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"", "abc", 3},
{"abc", "", 3},
{"abc", "abc", 0},
{"wrtie", "write", 2},
{"kitten", "sitting", 3},
}
for _, c := range cases {
got := levenshtein(c.a, c.b)
if got != c.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}

View File

@@ -1,10 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// 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.
// 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.
//
// 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"
)
// 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 {
// 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 {
Name string `yaml:"name"`
Description string `yaml:"description,omitempty"`
Allow []string `yaml:"allow,omitempty"`
@@ -35,70 +35,15 @@ type ruleSchema struct {
AllowUnannotated bool `yaml:"allow_unannotated,omitempty"`
}
// fileSchema is the top-level document shape. Two mutually-exclusive
// layouts are accepted:
//
// - 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"`
}
// 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 &platform.Rule{
Name: s.Name,
Description: s.Description,
Allow: s.Allow,
Deny: s.Deny,
MaxRisk: platform.Risk(s.MaxRisk),
Identities: idents,
AllowUnannotated: s.AllowUnannotated,
}
}
// Parse decodes yaml bytes into one or more *platform.Rule. Unknown fields
// are rejected so an old binary cannot silently ignore new schema additions
// 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).
//
// 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
// 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 fileSchema
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 {
@@ -108,7 +53,7 @@ func Parse(data []byte) ([]*platform.Rule, error) {
// 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
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")
@@ -116,20 +61,17 @@ func Parse(data []byte) ([]*platform.Rule, error) {
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
idents := make([]platform.Identity, len(s.Identities))
for i, id := range s.Identities {
idents[i] = platform.Identity(id)
}
// Backward-compatible single top-level rule (flat fields).
return []*platform.Rule{s.ruleSchema.toRule()}, nil
return &platform.Rule{
Name: s.Name,
Description: s.Description,
Allow: s.Allow,
Deny: s.Deny,
MaxRisk: platform.Risk(s.MaxRisk),
Identities: idents,
AllowUnannotated: s.AllowUnannotated,
}, nil
}

View File

@@ -24,7 +24,7 @@ max_risk: read
identities:
- user
`)
rules, err := pyaml.Parse(data)
rule, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
@@ -36,59 +36,8 @@ identities:
MaxRisk: "read",
Identities: []platform.Identity{"user"},
}
// 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")
if !reflect.DeepEqual(rule, want) {
t.Fatalf("rule = %+v, want %+v", rule, want)
}
}
@@ -103,15 +52,15 @@ name: agent-readonly
max_risk: read
allow_unannotated: true
`)
rules, err := pyaml.Parse(data)
rule, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if !rules[0].AllowUnannotated {
if !rule.AllowUnannotated {
t.Fatalf("AllowUnannotated = false, want true (yaml field must propagate)")
}
if rules[0].MaxRisk != "read" || rules[0].Name != "agent-readonly" {
t.Errorf("other fields lost: %+v", rules[0])
if rule.MaxRisk != "read" || rule.Name != "agent-readonly" {
t.Errorf("other fields lost: %+v", rule)
}
}
@@ -122,11 +71,11 @@ func TestParse_allowUnannotatedDefaultsFalse(t *testing.T) {
name: x
max_risk: read
`)
rules, err := pyaml.Parse(data)
rule, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if rules[0].AllowUnannotated {
if rule.AllowUnannotated {
t.Fatalf("AllowUnannotated must default to false when key is absent")
}
}
@@ -147,12 +96,12 @@ mystery_field: oh no
// structural yaml; an invalid max_risk passes through (validation happens
// downstream).
func TestParse_doesNotValidateSemantics(t *testing.T) {
rules, err := pyaml.Parse([]byte("max_risk: nuclear\n"))
rule, err := pyaml.Parse([]byte("max_risk: nuclear\n"))
if err != nil {
t.Fatalf("structural parse should succeed, got %v", err)
}
if rules[0].MaxRisk != "nuclear" {
t.Fatalf("MaxRisk = %q, want passed through as-is", rules[0].MaxRisk)
if rule.MaxRisk != "nuclear" {
t.Fatalf("MaxRisk = %q, want passed through as-is", rule.MaxRisk)
}
}

View File

@@ -5,6 +5,7 @@ package cmdutil
import (
"context"
"fmt"
"io"
"net/http"
"strings"
@@ -12,13 +13,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.
@@ -128,18 +129,11 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
}
list := strings.Join(supported, ", ")
if f.IdentityAutoDetected {
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 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])
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--as %s is not supported, this command only supports: %s", as, list).
WithParam("--as")
return fmt.Errorf("--as %s is not supported, this command only supports: %s", as, list)
}
// ResolveStrictMode returns the effective strict mode by reading
@@ -167,9 +161,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 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 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 nil
}
@@ -208,9 +202,9 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient
}, nil
}
// 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.
// RequireBuiltinCredentialProvider returns a structured error (exit 2, code
// "external_provider") when an extension provider is actively managing credentials.
// Intended for use as PersistentPreRunE on the auth and config parent commands.
//
// Returns nil when:
// - f.Credential is nil (test environments without credential setup)
@@ -226,7 +220,10 @@ func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command
if provName == "" {
return nil
}
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.")
return output.ErrWithHint(
output.ExitValidation,
"external_provider",
fmt.Sprintf("%q is not supported: credentials are provided externally and do not support interactive management", command),
"If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.",
)
}

View File

@@ -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/transport"
"github.com/larksuite/cli/internal/util"
_ "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) {
transport.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
util.WarnIfProxied(f.IOStreams.ErrOut)
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)
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)
client := &http.Client{
Transport: rt,
Transport: transport,
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()),
}
transport.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
util.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 = transport.Shared()
var sdkTransport http.RoundTripper = util.SharedTransport()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}

View File

@@ -11,7 +11,6 @@ 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"
@@ -180,15 +179,14 @@ func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) {
f.IdentityAutoDetected = true
err := f.CheckIdentity(core.AsUser, []string{"bot"})
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(ve.Message, "resolved identity") {
t.Errorf("expected 'resolved identity' in message, got: %v", ve.Message)
if !strings.Contains(err.Error(), "resolved identity") {
t.Errorf("expected 'resolved identity' 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)
if !strings.Contains(err.Error(), "hint: use --as bot") {
t.Errorf("expected hint in error, got: %v", err)
}
}
@@ -424,17 +422,20 @@ func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if ve.Message == "" {
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
t.Errorf("error type field = %v, want %q", exitErr.Detail, "external_provider")
}
if exitErr.Detail.Message == "" {
t.Error("expected non-empty message")
}
if ve.Hint == "" {
if exitErr.Detail.Hint == "" {
t.Error("expected non-empty hint")
}
}

View File

@@ -1,27 +0,0 @@
// 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
}

View File

@@ -6,18 +6,15 @@ 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"
)
@@ -27,7 +24,6 @@ const (
HeaderBuild = "X-Cli-Build"
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace"
SourceValue = "lark-cli"
@@ -40,8 +36,6 @@ const (
BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli"
agentTraceMaxLen = 1024
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -49,25 +43,6 @@ 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)
@@ -75,11 +50,6 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
h.Set("x-tt-env", "ppe_moa_canvas")
h.Set("x-use-ppe", "1")
if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v)
}
return h
}

View File

@@ -6,12 +6,10 @@ 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"
)
@@ -262,134 +260,3 @@ 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)
}
}

View File

@@ -9,7 +9,7 @@ import (
"time"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/util"
)
// 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 transport.Fallback()
return util.FallbackTransport()
}
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 transport.Fallback().RoundTrip(req)
return util.FallbackTransport().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 transport.Fallback().RoundTrip(req)
return util.FallbackTransport().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 transport.Fallback()
return util.FallbackTransport()
}
// RoundTrip implements http.RoundTripper.

View File

@@ -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
// transport.Fallback rather than panicking. This covers the fallback
// util.FallbackTransport 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

View File

@@ -11,7 +11,6 @@ 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"
@@ -42,7 +41,7 @@ type AppConfig struct {
AppId string `json:"appId"`
AppSecret SecretInput `json:"appSecret"`
Brand LarkBrand `json:"brand"`
Lang i18n.Lang `json:"lang,omitempty"`
Lang string `json:"lang,omitempty"`
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
StrictMode *StrictMode `json:"strictMode,omitempty"`
Users []AppUser `json:"users"`
@@ -160,7 +159,6 @@ 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
}
@@ -266,7 +264,6 @@ 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

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