mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
1 Commits
feat/sidec
...
docs/drive
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2424f6dfc2 |
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -9,7 +9,7 @@
|
||||
## Test Plan
|
||||
<!-- Describe how this change was verified. -->
|
||||
- [ ] Unit tests pass
|
||||
- [ ] Manual local verification confirms the `lark-cli <domain> <command>` flow works as expected
|
||||
- [ ] Manual local verification confirms the `lark xxx` command works as expected
|
||||
|
||||
## Related Issues
|
||||
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
# Build output
|
||||
/lark-cli*
|
||||
/lark-cli
|
||||
.cache/
|
||||
dist/
|
||||
bin/
|
||||
|
||||
@@ -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
|
||||
|
||||
109
CHANGELOG.md
109
CHANGELOG.md
@@ -2,110 +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
|
||||
|
||||
- **minutes**: Add minutes edit shortcuts (#1036)
|
||||
- **minutes**: Get minutes keywords (#1079)
|
||||
- **slides**: Support importing pptx as slides (#1068)
|
||||
- **config**: Add `keychain-downgrade` subcommand (macOS) (#1085)
|
||||
- **errors**: Add structured CLI error contract (#984)
|
||||
- **apps**: Replace `+html-publish` cwd hard-reject with credential-file scan (#1072)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Support doubao drive inspect URL variants (#1106)
|
||||
- **skills**: Sync skills incrementally during update (#1042)
|
||||
- **apps**: Read app object from `data.app` for `+create` and `+update` (#1087)
|
||||
- **common**: Escape special chars in multipart form filenames (#1037)
|
||||
- **auth**: Remove fenced code block guidance from auth URL output hints (#1088)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Fix agent routing for doubao.com URLs (#1082)
|
||||
- **task**: Require `--complete=false` for pending standup summaries (#1101)
|
||||
- **base**: Document UI-only field settings (#1078)
|
||||
- **contributing**: Clarify contributor guidance (#1096)
|
||||
|
||||
## [v1.0.40] - 2026-05-25
|
||||
|
||||
### Features
|
||||
@@ -964,11 +860,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
|
||||
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38
|
||||
|
||||
@@ -279,8 +279,6 @@ Community contributions are welcome! If you find a bug or have feature suggestio
|
||||
|
||||
For major changes, we recommend discussing with us first via an Issue.
|
||||
|
||||
Before opening a PR, see [AGENTS.md](./AGENTS.md) for the local build, test, and PR checklist used by contributors and AI agents.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **MIT License**.
|
||||
|
||||
@@ -280,8 +280,6 @@ lark-cli schema im.messages.delete
|
||||
|
||||
对于较大的改动,建议先通过 Issue 与我们讨论。
|
||||
|
||||
提交 PR 前,请先阅读 [AGENTS.md](./AGENTS.md),其中列出了贡献者和 AI Agent 使用的本地构建、测试和 PR 检查清单。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 **MIT 许可证** 开源。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "飞书"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build darwin
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewCmdConfigKeychainDowngrade creates the macOS-only subcommand that pins
|
||||
// the master key to the local file fallback (master.key.file) so subsequent
|
||||
// operations bypass the OS Keychain. Useful inside sandboxes like Codex
|
||||
// where the system Keychain is unreachable.
|
||||
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "keychain-downgrade",
|
||||
Short: "Downgrade keychain storage to a local file (macOS only)",
|
||||
Long: `Materialize the master key from the macOS system Keychain into a local file
|
||||
under ~/Library/Application Support/lark-cli/master.key.file, then pin all
|
||||
subsequent reads to that file.
|
||||
|
||||
Intended workflow: run this once from an interactive Terminal session on
|
||||
macOS (where the system Keychain is reachable). After it finishes,
|
||||
sandboxed / automation / CI runs of lark-cli on the same machine will read
|
||||
the master key from the local file and no longer need the OS Keychain.
|
||||
|
||||
This is the supported fix for environments like the Codex sandbox where the
|
||||
system Keychain is blocked. Running keychain-downgrade from inside such a
|
||||
sandbox will itself fail with "keychain access blocked" — that is expected;
|
||||
run it from an interactive macOS session instead.
|
||||
|
||||
The OS Keychain entry is preserved as a cold backup; nothing is deleted there.
|
||||
The command is idempotent: re-running it on an already-downgraded install
|
||||
reports "already downgraded" and exits 0.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return configKeychainDowngradeRun(f)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func configKeychainDowngradeRun(f *cmdutil.Factory) error {
|
||||
service := keychain.LarkCliService
|
||||
keyPath := keychain.MasterKeyFilePath(service)
|
||||
|
||||
result, err := keychain.DowngradeMasterKeyToFile(service)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError,
|
||||
"keychain downgrade failed: %v", err).
|
||||
WithHint("This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design.").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
switch result {
|
||||
case keychain.DowngradeAlreadyDone:
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("keychain already downgraded; subsequent operations read from %s", keyPath))
|
||||
case keychain.DowngradeUsedKeychainKey:
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("downgraded: copied master key from system Keychain to %s. Subsequent operations will read from file, bypassing the OS Keychain (useful inside sandboxes like Codex).", keyPath))
|
||||
case keychain.DowngradeCreatedNewKey:
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("system Keychain was empty; generated a new master key and wrote it to %s. The OS Keychain was not modified.", keyPath))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !darwin
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewCmdConfigKeychainDowngrade is registered on all platforms so that
|
||||
// `lark-cli config --help` reads the same everywhere. On non-macOS it
|
||||
// refuses with a clear message.
|
||||
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
|
||||
_ = f
|
||||
cmd := &cobra.Command{
|
||||
Use: "keychain-downgrade",
|
||||
Short: "Downgrade keychain storage to a local file (macOS only)",
|
||||
Long: `Downgrade keychain storage to a local file. This subcommand is only supported on macOS; on this platform the keychain layer already uses local files.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "keychain-downgrade is only supported on macOS")
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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{},
|
||||
})
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
196
cmd/root.go
196
cmd/root.go
@@ -4,22 +4,23 @@
|
||||
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"
|
||||
@@ -200,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 {
|
||||
@@ -271,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) {
|
||||
@@ -399,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
@@ -384,8 +384,11 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that missing state
|
||||
// produces no skills key in the composed notice.
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
|
||||
// produces no skills key in the composed notice. Users who installed
|
||||
// skills via `npx skills add` (no stamp) must not see the misleading
|
||||
// "not installed" notice — only `lark-cli update` users opt into the
|
||||
// drift tracker.
|
||||
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
@@ -416,13 +419,13 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_InSync verifies that matching state produces no
|
||||
// TestSetupNotices_InSync verifies that a matching stamp produces no
|
||||
// skills key in the composed notice.
|
||||
func TestSetupNotices_InSync(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -449,13 +452,13 @@ func TestSetupNotices_InSync(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_Drift verifies mismatching state produces the
|
||||
// TestSetupNotices_Drift verifies a mismatching stamp produces the
|
||||
// drift message with both current and target populated.
|
||||
func TestSetupNotices_Drift(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -504,7 +507,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
361
cmd/root_test.go
361
cmd/root_test.go
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -25,8 +24,7 @@ type SchemaOptions struct {
|
||||
Ctx context.Context
|
||||
|
||||
// Positional args
|
||||
Path string // first positional, when only one is given
|
||||
ExtraArgs []string // 2nd+ positional args (space-separated form)
|
||||
Path string
|
||||
|
||||
// Flags
|
||||
Format string
|
||||
@@ -361,16 +359,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
opts := &SchemaOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema [path | service resource method]",
|
||||
Use: "schema [path]",
|
||||
Short: "View API method parameters, types, and scopes",
|
||||
Args: cobra.MaximumNArgs(8),
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
opts.Path = args[0]
|
||||
}
|
||||
if len(args) > 1 {
|
||||
opts.ExtraArgs = args[1:]
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -385,108 +380,60 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath provides tab-completion for the schema path argument.
|
||||
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
|
||||
// newer space-separated form (e.g. `schema im messages reply`).
|
||||
// It handles dotted resource names (e.g. app.table.fields) by iterating all
|
||||
// resources and classifying each as a prefix-match or fully-matched.
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
|
||||
// Case 1: legacy "single dotted arg" path — no previous args yet
|
||||
if len(args) == 0 {
|
||||
parts := strings.Split(toComplete, ".")
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// Case 2: space-form, args already has segments
|
||||
// Walk down service -> resource(s) -> method based on existing args
|
||||
serviceName := args[0]
|
||||
parts := strings.Split(toComplete, ".")
|
||||
|
||||
// Level 1: complete service names
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// args[1:] are resource path segments (possibly partial); current
|
||||
// toComplete is the next segment under cursor.
|
||||
consumed := args[1:]
|
||||
resource, _, remaining := findResourceByPath(resources, consumed)
|
||||
if resource == nil {
|
||||
// Suggest top-level resource names that match toComplete
|
||||
var completions []string
|
||||
for resName := range resources {
|
||||
if strings.HasPrefix(resName, toComplete) {
|
||||
completions = append(completions, resName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
// Already typed past the resource — suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
// Resource matched exactly, suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,231 +469,94 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
out := opts.Factory.IOStreams.Out
|
||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||
|
||||
// args may have arrived as a single string (legacy single-arg path) or
|
||||
// split into multiple — normalize to a single args slice.
|
||||
var rawArgs []string
|
||||
if opts.Path != "" {
|
||||
rawArgs = []string{opts.Path}
|
||||
}
|
||||
if len(opts.ExtraArgs) > 0 {
|
||||
if opts.Path != "" {
|
||||
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
|
||||
} else {
|
||||
rawArgs = append([]string(nil), opts.ExtraArgs...)
|
||||
}
|
||||
}
|
||||
parts := schema.ParsePath(rawArgs)
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
return runPrettyMode(out, parts, mode)
|
||||
}
|
||||
return runJSONMode(out, parts, mode)
|
||||
}
|
||||
|
||||
// runJSONMode dispatches list/single envelope output based on parts.
|
||||
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
|
||||
// output is deterministic across machines.
|
||||
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
filter := strictModeFilter(mode)
|
||||
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
envs := schema.AssembleAll(filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
case 1:
|
||||
spec := registry.EmbeddedSpec(parts[0])
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(parts[0])
|
||||
}
|
||||
envs := schema.AssembleService(parts[0], spec, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
default:
|
||||
return runJSONForPath(out, parts, filter)
|
||||
}
|
||||
}
|
||||
|
||||
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
|
||||
// to single-method match. Uses embedded data only.
|
||||
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
|
||||
serviceName := parts[0]
|
||||
spec := registry.EmbeddedSpec(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(serviceName)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var names []string
|
||||
for k := range resources {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
// Resource-scoped envelope array
|
||||
envs := assembleResource(serviceName, resName, resource, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var names []string
|
||||
for k := range methods {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
// Method exists but caller appended extra segments — reject so they
|
||||
// don't silently get this method's schema when they typo'd the path.
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
// Method exists in spec but filtered out by strict mode
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
|
||||
"Use --as user / --as bot to switch")
|
||||
}
|
||||
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
|
||||
output.PrintJson(out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
resourcePath := []string{resName}
|
||||
var envs []schema.Envelope
|
||||
for methodName, raw := range methods {
|
||||
method, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
continue
|
||||
}
|
||||
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
|
||||
}
|
||||
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
|
||||
return envs
|
||||
}
|
||||
|
||||
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
|
||||
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
|
||||
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
if len(parts) == 0 {
|
||||
if opts.Path == "" {
|
||||
printServices(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(opts.Path, ".")
|
||||
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownService(serviceName)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", serviceName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
printResourceList(out, spec, mode)
|
||||
if opts.Format == "pretty" {
|
||||
printResourceList(out, spec, mode)
|
||||
} else {
|
||||
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var names []string
|
||||
var resNames []string
|
||||
for k := range resources {
|
||||
names = append(names, k)
|
||||
resNames = append(resNames, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
fmt.Sprintf("Available: %s", strings.Join(resNames, ", ")))
|
||||
}
|
||||
|
||||
if len(remaining) == 0 {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||
if opts.Format == "pretty" {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
} else {
|
||||
// For JSON output, filter methods in a copy to avoid mutating the registry.
|
||||
if mode.IsActive() {
|
||||
filtered := make(map[string]interface{})
|
||||
for k, v := range resource {
|
||||
filtered[k] = v
|
||||
}
|
||||
if methods, ok := resource["methods"].(map[string]interface{}); ok {
|
||||
filtered["methods"] = filterMethodsByStrictMode(methods, mode)
|
||||
}
|
||||
output.PrintJson(out, filtered)
|
||||
} else {
|
||||
output.PrintJson(out, resource)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
return nil
|
||||
}
|
||||
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var names []string
|
||||
var mNames []string
|
||||
for k := range methods {
|
||||
names = append(names, k)
|
||||
mNames = append(mNames, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
fmt.Sprintf("Available: %s", strings.Join(mNames, ", ")))
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
printMethodDetail(out, spec, resName, methodName, method)
|
||||
} else {
|
||||
output.PrintJson(out, method)
|
||||
}
|
||||
printMethodDetail(out, spec, resName, methodName, method)
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
|
||||
// nil if strict mode is not active.
|
||||
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
|
||||
if !mode.IsActive() {
|
||||
return nil
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
return func(method map[string]interface{}) bool {
|
||||
tokens, _ := method["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
return true // permissive when meta_data lacks accessTokens
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if s, _ := t.(string); s == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func errUnknownService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||
}
|
||||
|
||||
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
|
||||
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
|
||||
// overlay-only services would mislead callers when those services subsequently
|
||||
// fail to resolve in envelope output.
|
||||
func errUnknownEmbeddedService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
|
||||
}
|
||||
|
||||
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
|
||||
// filtered by strict mode. Returns the original spec when strict mode is off.
|
||||
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
|
||||
@@ -5,7 +5,6 @@ package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -34,165 +33,17 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
|
||||
func TestSchemaCmd_NoArgs(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
cmd.SetArgs([]string{})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Available services") {
|
||||
t.Error("expected service list in pretty mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{}) // default --format json
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
if !strings.HasPrefix(out, "[") {
|
||||
head := out
|
||||
if len(head) > 80 {
|
||||
head = head[:80]
|
||||
}
|
||||
t.Errorf("expected JSON array root, first 80 chars:\n%s", head)
|
||||
}
|
||||
var envs []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &envs); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
if len(envs) < 193 {
|
||||
t.Errorf("envelopes count = %d, want >= 193", len(envs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env["name"] != "im images create" {
|
||||
t.Errorf("name = %v, want \"im images create\"", env["name"])
|
||||
}
|
||||
for _, key := range []string{"description", "inputSchema", "outputSchema", "_meta"} {
|
||||
if _, ok := env[key]; !ok {
|
||||
t.Errorf("missing top-level key: %s", key)
|
||||
}
|
||||
}
|
||||
meta, _ := env["_meta"].(map[string]interface{})
|
||||
if meta["envelope_version"] != "1.0" {
|
||||
t.Errorf("envelope_version = %v, want \"1.0\"", meta["envelope_version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_SpaceSeparatedPath_EqualsDotted(t *testing.T) {
|
||||
f1, out1, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd1 := NewCmdSchema(f1, nil)
|
||||
cmd1.SetArgs([]string{"im", "images", "create"})
|
||||
if err := cmd1.Execute(); err != nil {
|
||||
t.Fatalf("space form failed: %v", err)
|
||||
}
|
||||
|
||||
f2, out2, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd2 := NewCmdSchema(f2, nil)
|
||||
cmd2.SetArgs([]string{"im.images.create"})
|
||||
if err := cmd2.Execute(); err != nil {
|
||||
t.Fatalf("dotted form failed: %v", err)
|
||||
}
|
||||
|
||||
if out1.String() != out2.String() {
|
||||
t.Errorf("space and dotted forms produced different output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_ServiceListIsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var envs []map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envs); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(envs) == 0 {
|
||||
t.Fatal("expected non-empty array for service im")
|
||||
}
|
||||
for _, e := range envs {
|
||||
name, _ := e["name"].(string)
|
||||
if !strings.HasPrefix(name, "im ") {
|
||||
t.Errorf("envelope name %q does not start with \"im \"", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_HighRiskYesInjection(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.messages.delete"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
is, _ := env["inputSchema"].(map[string]interface{})
|
||||
props, _ := is["properties"].(map[string]interface{})
|
||||
if _, ok := props["yes"]; !ok {
|
||||
t.Errorf("inputSchema.properties.yes missing for high-risk-write command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.reactions.list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
is, _ := env["inputSchema"].(map[string]interface{})
|
||||
props, _ := is["properties"].(map[string]interface{})
|
||||
if _, ok := props["yes"]; ok {
|
||||
t.Errorf("yes property should not appear for risk=read command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Existing pretty rendering surfaces these markers — they must still appear
|
||||
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("pretty output missing marker %q", want)
|
||||
}
|
||||
t.Error("expected service list output")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,18 +31,15 @@ var (
|
||||
currentVersion = func() string { return build.Version }
|
||||
currentOS = runtime.GOOS
|
||||
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
|
||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { return skillscheck.SyncSkills(opts) }
|
||||
)
|
||||
|
||||
func isWindows() bool { return currentOS == osWindows }
|
||||
|
||||
// normalizeVersion canonicalizes a version string for state comparison.
|
||||
// normalizeVersion canonicalizes a version string for stamp comparison.
|
||||
// Strips a leading "v" so versions written from Makefile (git describe →
|
||||
// "v1.0.0") and npm (no prefix → "1.0.0") compare equal.
|
||||
func normalizeVersion(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "v")
|
||||
return strings.TrimPrefix(s, "V")
|
||||
return strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||
}
|
||||
|
||||
func releaseURL(version string) string {
|
||||
@@ -124,9 +121,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
if !opts.Check {
|
||||
updater.CleanupStaleFiles()
|
||||
}
|
||||
updater.CleanupStaleFiles()
|
||||
output.PendingNotice = nil
|
||||
|
||||
// 1. Fetch latest version
|
||||
@@ -142,9 +137,13 @@ func updateRun(opts *UpdateOptions) error {
|
||||
|
||||
// 3. Compare versions
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
var skillsResult *skillscheck.SyncResult
|
||||
// Run skills sync before returning — covers the case where the
|
||||
// binary is already current but skills were never synced.
|
||||
// Stamp dedup makes this a no-op if skills are already in sync.
|
||||
// Skip side-effects under --check (pure report path per spec §3.6).
|
||||
var skillsResult *selfupdate.NpmResult
|
||||
if !opts.Check {
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
|
||||
skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||
}
|
||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
@@ -186,7 +185,16 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
}
|
||||
applySkillsStatus(out, cur)
|
||||
// skills_status: pure report, no side effect, no stamp write.
|
||||
// ReadStamp errors are silently swallowed — if we can't read the
|
||||
// stamp we just omit the block rather than fail the --check.
|
||||
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||
out["skills_status"] = map[string]interface{}{
|
||||
"current": stamp,
|
||||
"target": cur,
|
||||
"in_sync": stamp == cur,
|
||||
}
|
||||
}
|
||||
output.PrintJson(io.Out, out)
|
||||
return nil
|
||||
}
|
||||
@@ -202,7 +210,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
|
||||
skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
@@ -280,7 +288,10 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
|
||||
// Skills update (best-effort) — uses runSkillsAndStamp so the
|
||||
// stamp gets persisted on success and dedup applies if a previous
|
||||
// run already stamped this version.
|
||||
skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force)
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
@@ -317,21 +328,27 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
|
||||
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
|
||||
// stamp on success. Skips the npx invocation when the stamp already
|
||||
// matches stampVersion (unless force is true). The stamp write failure
|
||||
// emits a warning to io.ErrOut but does NOT fail the update command —
|
||||
// best-effort. ReadStamp errors are swallowed (fail-closed: treated as
|
||||
// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp
|
||||
// dedup; otherwise returns the underlying *NpmResult with Err semantics
|
||||
// from RunSkillsUpdate.
|
||||
func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult {
|
||||
if !force {
|
||||
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
result := syncSkills(skillscheck.SyncOptions{
|
||||
Version: stateVersion,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
})
|
||||
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
|
||||
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
|
||||
r := updater.RunSkillsUpdate()
|
||||
if r.Err == nil {
|
||||
if err := skillscheck.WriteStamp(stampVersion); err != nil {
|
||||
fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err)
|
||||
}
|
||||
}
|
||||
return result
|
||||
return r
|
||||
}
|
||||
|
||||
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
||||
@@ -339,7 +356,7 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
|
||||
// fields derived from skillsResult. When check is true, this is the pure
|
||||
// report path (spec §3.6): no side-effects, JSON envelope uses
|
||||
// skills_status (spec §4.2) instead of skills_action.
|
||||
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error {
|
||||
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error {
|
||||
if opts.JSON {
|
||||
out := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
@@ -347,7 +364,16 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
|
||||
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
|
||||
}
|
||||
if check {
|
||||
applySkillsStatus(out, cur)
|
||||
// Pure report — read stamp directly, emit skills_status block.
|
||||
// ReadStamp errors are silently swallowed — if we can't read
|
||||
// the stamp we just omit the block rather than fail the --check.
|
||||
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||
out["skills_status"] = map[string]interface{}{
|
||||
"current": stamp,
|
||||
"target": cur,
|
||||
"in_sync": stamp == cur,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
applySkillsResult(out, skillsResult)
|
||||
}
|
||||
@@ -361,70 +387,36 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
|
||||
return nil
|
||||
}
|
||||
|
||||
func applySkillsStatus(env map[string]interface{}, target string) {
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable || state.Version == "" {
|
||||
return
|
||||
}
|
||||
status := map[string]interface{}{
|
||||
"current": state.Version,
|
||||
"target": target,
|
||||
"in_sync": normalizeVersion(state.Version) == normalizeVersion(target),
|
||||
}
|
||||
if len(state.OfficialSkills) > 0 {
|
||||
status["official"] = len(state.OfficialSkills)
|
||||
}
|
||||
if len(state.UpdatedSkills) > 0 {
|
||||
status["updated"] = len(state.UpdatedSkills)
|
||||
}
|
||||
if len(state.SkippedDeletedSkills) > 0 {
|
||||
status["skipped_deleted"] = state.SkippedDeletedSkills
|
||||
}
|
||||
env["skills_status"] = status
|
||||
}
|
||||
|
||||
func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
|
||||
// applySkillsResult mutates the JSON envelope to include skills_action
|
||||
// (and skills_warning when failed). nil result = "in_sync" (dedup hit).
|
||||
func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) {
|
||||
switch {
|
||||
case r == nil:
|
||||
env["skills_action"] = "in_sync"
|
||||
case r.Err != nil:
|
||||
env["skills_action"] = "failed"
|
||||
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||
env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
|
||||
}
|
||||
default:
|
||||
env["skills_action"] = "synced"
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
}
|
||||
}
|
||||
|
||||
func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
|
||||
summary := map[string]interface{}{
|
||||
"official": len(r.Official),
|
||||
"updated": len(r.Updated),
|
||||
"added": len(r.Added),
|
||||
"skipped_deleted": len(r.SkippedDeleted),
|
||||
}
|
||||
if len(r.Failed) > 0 {
|
||||
summary["failed"] = r.Failed
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
|
||||
// emitSkillsTextHints prints human-readable feedback about the skills
|
||||
// sync result for non-JSON output.
|
||||
func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) {
|
||||
switch {
|
||||
case r == nil:
|
||||
// dedup hit — silent (already up to date)
|
||||
case r.Err != nil:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err)
|
||||
if len(r.Failed) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
|
||||
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
|
||||
case r.Force:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
|
||||
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
|
||||
default:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
|
||||
if len(r.SkippedDeleted) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,13 @@ package cmdupdate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -29,6 +28,7 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
|
||||
}
|
||||
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
|
||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
@@ -41,53 +41,22 @@ func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
}
|
||||
|
||||
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
|
||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(string) *selfupdate.NpmResult) {
|
||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
|
||||
npmFn func(string) *selfupdate.NpmResult,
|
||||
skillsFn func() *selfupdate.NpmResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.NpmInstallOverride = npmFn
|
||||
u.SkillsUpdateOverride = skillsFn
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
return func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
switch strings.Join(args, " ") {
|
||||
case "-y skills add https://open.feishu.cn --list":
|
||||
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
|
||||
case "-y skills ls -g":
|
||||
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
|
||||
default:
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: "1.2.3", want: "1.2.3"},
|
||||
{input: "v1.2.3", want: "1.2.3"},
|
||||
{input: "V1.2.3", want: "1.2.3"},
|
||||
{input: " v1.2.3 ", want: "1.2.3"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
if got := normalizeVersion(tt.input); got != tt.want {
|
||||
t.Fatalf("normalizeVersion(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
|
||||
@@ -199,7 +168,9 @@ func TestUpdateManual_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateNpm_JSON(t *testing.T) {
|
||||
// Isolate config dir because skills sync writes skills-state.json.
|
||||
// Isolate config dir: this test mocks fetchLatest="2.0.0" and lets
|
||||
// runSkillsAndStamp → WriteStamp succeed, which without isolation would
|
||||
// clobber the real ~/.lark-cli/skills.stamp with "2.0.0".
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -215,6 +186,7 @@ func TestUpdateNpm_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -244,6 +216,7 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -257,7 +230,7 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -273,6 +246,7 @@ func TestUpdateForce_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -349,7 +323,7 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -365,6 +339,7 @@ func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -476,8 +451,8 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||
u.RestoreAvailableOverride = func() bool { return false }
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
t.Fatal("skills sync should not run when binary verification fails")
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
t.Fatal("skills update should not run when binary verification fails")
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
@@ -674,7 +649,7 @@ func TestPermissionHint(t *testing.T) {
|
||||
|
||||
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
// With the rename trick, Windows npm installs can now auto-update.
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -693,6 +668,7 @@ func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -774,6 +750,7 @@ func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -808,7 +785,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
// Skills update fails
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
@@ -834,8 +812,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
if !strings.Contains(out, "skills_warning") {
|
||||
t.Errorf("expected skills_warning in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "skills_summary") {
|
||||
t.Errorf("expected skills_summary in output, got: %s", out)
|
||||
if !strings.Contains(out, "skills_detail") {
|
||||
t.Errorf("expected skills_detail in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,7 +838,7 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
@@ -883,96 +861,100 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
if !strings.Contains(out, "Skills update failed") {
|
||||
t.Errorf("expected skills failure warning, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "lark-cli update --force") {
|
||||
t.Errorf("expected force retry hint, got: %s", out)
|
||||
if !strings.Contains(out, "npx -y skills add") {
|
||||
t.Errorf("expected manual skills command hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers.
|
||||
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable
|
||||
// for direct calls to internals like runSkillsAndStamp that write to
|
||||
// io.ErrOut.
|
||||
func newTestIO() *cmdutil.IOStreams {
|
||||
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
func TestRunSkillsAndStamp_DedupHit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
called = true
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
if got != nil {
|
||||
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
|
||||
t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got)
|
||||
}
|
||||
if called {
|
||||
t.Error("SkillsCommandOverride called, want skipped due to dedup")
|
||||
t.Error("SkillsUpdateOverride called, want skipped due to dedup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
func TestRunSkillsAndStamp_DedupForceBypass(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
called = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true)
|
||||
if got == nil {
|
||||
t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil")
|
||||
}
|
||||
if !called {
|
||||
t.Error("SkillsCommandOverride not called with force=true")
|
||||
t.Error("SkillsUpdateOverride not called with force=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
func TestRunSkillsAndStamp_SuccessWritesStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
func TestRunSkillsAndStamp_FailureKeepsOldStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("npx failed")
|
||||
return r
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got)
|
||||
}
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.20\" (failure must not overwrite)", state.Version)
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.20" {
|
||||
t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -991,7 +973,8 @@ func TestTruncate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -1004,9 +987,9 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1017,19 +1000,17 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("skills sync not called in already-up-to-date branch")
|
||||
t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called")
|
||||
}
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -1048,9 +1029,9 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
ResolvedPath: "/usr/local/bin/lark-cli",
|
||||
}
|
||||
},
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1061,19 +1042,17 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("skills sync not called in manual branch")
|
||||
t.Error("RunSkillsUpdate not called in manual branch, want called")
|
||||
}
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\" (manual path records current binary)", state.Version)
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -1096,9 +1075,9 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
VerifyOverride: func(expectedVersion string) error { return nil },
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1109,25 +1088,18 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("skills sync not called in npm branch")
|
||||
t.Error("RunSkillsUpdate not called in npm branch")
|
||||
}
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.22" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.22\" (npm path records latest binary)", state.Version)
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.22" {
|
||||
t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{
|
||||
Version: "1.0.20",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail"},
|
||||
UpdatedSkills: []string{"lark-calendar"},
|
||||
SkippedDeletedSkills: []string{"lark-mail"},
|
||||
}); err != nil {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1145,9 +1117,9 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
||||
},
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1158,7 +1130,7 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
t.Fatalf("updateRun(--check) err = %v, want nil", err)
|
||||
}
|
||||
if skillsCalled {
|
||||
t.Error("skills sync called under --check, want skipped")
|
||||
t.Error("RunSkillsUpdate called under --check, want skipped (pure report)")
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
@@ -1172,14 +1144,12 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false {
|
||||
t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status)
|
||||
}
|
||||
if status["official"] != float64(2) || status["updated"] != float64(1) {
|
||||
t.Errorf("skills_status counts = %+v, want official:2 updated:1", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1194,9 +1164,9 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1207,15 +1177,12 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err)
|
||||
}
|
||||
if skillsCalled {
|
||||
t.Error("skills sync called under --check (already-latest), want skipped")
|
||||
t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)")
|
||||
}
|
||||
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version mutated to %q under --check, want \"1.0.20\"", state.Version)
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.20" {
|
||||
t.Errorf("stamp mutated to %q under --check, want \"1.0.20\" (pure report must not write stamp)", stamp)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
@@ -1237,248 +1204,39 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
|
||||
origSync := syncSkills
|
||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
|
||||
return &skillscheck.SyncResult{Err: fmt.Errorf("skills synced but state not written: denied")}
|
||||
// TestRunSkillsAndStamp_StampWriteFailureWarns verifies the stderr warning
|
||||
// emission when RunSkillsUpdate succeeds but WriteStamp fails.
|
||||
func TestRunSkillsAndStamp_StampWriteFailureWarns(t *testing.T) {
|
||||
// Force WriteStamp to fail by pointing config dir at a path that exists
|
||||
// as a regular file (so MkdirAll fails).
|
||||
tmp := t.TempDir()
|
||||
badPath := filepath.Join(tmp, "blocker")
|
||||
if err := os.WriteFile(badPath, []byte("not-a-dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { syncSkills = origSync })
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath)
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
return &selfupdate.NpmResult{} // success
|
||||
},
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: skills synced but state not written") {
|
||||
got := runSkillsAndStamp(updater, f.IOStreams, "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: skills synced but stamp not written") {
|
||||
t.Errorf("stderr does not contain warning: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitSkillsTextHints_Success verifies the "Skills updated" success
|
||||
// message is printed to ErrOut on a successful (Err == nil) result.
|
||||
func TestEmitSkillsTextHints_Success(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
emitSkillsTextHints(f.IOStreams, &skillscheck.SyncResult{Official: []string{"lark-calendar"}, Updated: []string{"lark-calendar"}})
|
||||
emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success
|
||||
if !strings.Contains(stderr.String(), "Skills updated") {
|
||||
t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
|
||||
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
|
||||
// state file. It calls the real npx skills CLI, so the test is skipped when
|
||||
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
|
||||
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skipf("npx not found in PATH: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
||||
}
|
||||
|
||||
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
|
||||
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
|
||||
// intentionally removed them while they were still official skills.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
before := skillscheck.SkillsState{
|
||||
Version: "1.0.19",
|
||||
OfficialSkills: []string{"lark-approval", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"},
|
||||
UpdatedSkills: []string{"lark-approval", "lark-apps", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"},
|
||||
AddedOfficialSkills: []string{},
|
||||
SkippedDeletedSkills: []string{},
|
||||
UpdatedAt: "2026-05-20T00:00:00Z",
|
||||
}
|
||||
if err := skillscheck.WriteState(before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() before update = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.19" {
|
||||
t.Fatalf("state.Version before update = %q, want 1.0.19", state.Version)
|
||||
}
|
||||
|
||||
// Phase 3: Mock version functions so the update command believes it has
|
||||
// upgraded from 1.0.19 to 1.0.20, then execute "lark-cli update --json".
|
||||
// This triggers SyncSkills which calls the real npx skills add command.
|
||||
origFetch := fetchLatest
|
||||
origVersion := currentVersion
|
||||
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion })
|
||||
fetchLatest = func() (string, error) { return "1.0.20", nil }
|
||||
currentVersion = func() string { return "1.0.20" }
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("lark-cli update --json err = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Phase 4: Verify the state file was rewritten with the new version,
|
||||
// non-empty official/updated skill lists, and a refreshed timestamp.
|
||||
state, readable, err = skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version after update = %q, want 1.0.20", state.Version)
|
||||
}
|
||||
if len(state.OfficialSkills) == 0 {
|
||||
t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state)
|
||||
}
|
||||
if len(state.UpdatedSkills) == 0 {
|
||||
t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state)
|
||||
}
|
||||
if state.UpdatedAt == "" || state.UpdatedAt == before.UpdatedAt {
|
||||
t.Errorf("state.UpdatedAt = %q, want refreshed non-empty timestamp", state.UpdatedAt)
|
||||
}
|
||||
// Verify that previously-skipped skills are handled correctly:
|
||||
// - If locally installed → should appear in UpdatedSkills (updated to latest)
|
||||
// - If locally absent → should NOT be force-restored in UpdatedSkills,
|
||||
// and should remain in SkippedDeletedSkills
|
||||
for _, skill := range []string{"lark-doc", "lark-mail"} {
|
||||
if containsString(localSkills, skill) {
|
||||
if !containsString(state.UpdatedSkills, skill) {
|
||||
t.Errorf("state.UpdatedSkills = %v, want installed skill %q updated", state.UpdatedSkills, skill)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if containsString(state.UpdatedSkills, skill) {
|
||||
t.Errorf("state.UpdatedSkills = %v, want deleted skill %q not restored without --force", state.UpdatedSkills, skill)
|
||||
}
|
||||
if !containsString(state.SkippedDeletedSkills, skill) {
|
||||
t.Errorf("state.SkippedDeletedSkills = %v, want deleted skill %q preserved when still official", state.SkippedDeletedSkills, skill)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: Verify the JSON output structure is parseable and contains
|
||||
// the expected action fields for AI agent consumption.
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
if env["action"] != "already_up_to_date" {
|
||||
t.Errorf("action = %v, want already_up_to_date", env["action"])
|
||||
}
|
||||
if env["skills_action"] != "synced" {
|
||||
t.Errorf("skills_action = %v, want synced", env["skills_action"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCommand_SkillsSyncColdStart verifies that when skills-state.json does
|
||||
// not exist (cold start), the update command installs all official skills and
|
||||
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
|
||||
// because there is no previous state to preserve user deletions from.
|
||||
// This is a live integration test that calls the real npx skills CLI; it is
|
||||
// skipped when npx or the skills registry is unavailable.
|
||||
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
|
||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skipf("npx not found in PATH: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
||||
}
|
||||
|
||||
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if _, readable, _ := skillscheck.ReadState(); readable {
|
||||
t.Fatal("skills-state.json should not exist before update")
|
||||
}
|
||||
|
||||
// Phase 3: Mock version functions so the update command believes it is at
|
||||
// v1.0.20, then execute "lark-cli update --json". This triggers SyncSkills
|
||||
// which calls the real npx skills add command.
|
||||
origFetch := fetchLatest
|
||||
origVersion := currentVersion
|
||||
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion })
|
||||
fetchLatest = func() (string, error) { return "1.0.20", nil }
|
||||
currentVersion = func() string { return "1.0.20" }
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("lark-cli update --json err = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Phase 4: Verify the state file was created with all official skills in
|
||||
// UpdatedSkills and nothing in SkippedDeletedSkills (cold start = no prior
|
||||
// deletions to honor). Locally installed skills should appear in UpdatedSkills.
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version = %q, want 1.0.20", state.Version)
|
||||
}
|
||||
if len(state.OfficialSkills) == 0 {
|
||||
t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state)
|
||||
}
|
||||
if len(state.UpdatedSkills) == 0 {
|
||||
t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state)
|
||||
}
|
||||
if state.UpdatedAt == "" {
|
||||
t.Error("state.UpdatedAt is empty, want non-empty timestamp")
|
||||
}
|
||||
// All locally installed official skills must appear in UpdatedSkills.
|
||||
officialSet := map[string]bool{}
|
||||
for _, s := range state.OfficialSkills {
|
||||
officialSet[s] = true
|
||||
}
|
||||
for _, skill := range localSkills {
|
||||
if !officialSet[skill] {
|
||||
continue
|
||||
}
|
||||
if !containsString(state.UpdatedSkills, skill) {
|
||||
t.Errorf("state.UpdatedSkills = %v, want locally installed official skill %q updated", state.UpdatedSkills, skill)
|
||||
}
|
||||
}
|
||||
// No skill should be in SkippedDeletedSkills on cold start — there is no
|
||||
// previous state recording a user deletion to preserve.
|
||||
if len(state.SkippedDeletedSkills) != 0 {
|
||||
t.Errorf("state.SkippedDeletedSkills = %v, want empty on cold start", state.SkippedDeletedSkills)
|
||||
}
|
||||
|
||||
// Phase 5: Verify the JSON output structure is parseable and contains
|
||||
// the expected action fields for AI agent consumption.
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
if env["action"] != "already_up_to_date" {
|
||||
t.Errorf("action = %v, want already_up_to_date", env["action"])
|
||||
}
|
||||
if env["skills_action"] != "synced" {
|
||||
t.Errorf("skills_action = %v, want synced", env["skills_action"])
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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 2–4 — restricted to framework code, no business sweep:
|
||||
|
||||
- **Migrate `internal/**` typed construction to the builder API.** ~16 call sites in `internal/errclass/classify.go` (BuildAPIError fanout), `internal/auth/transport.go` (SecurityPolicy), `internal/auth/uat_client.go`, `internal/errcompat/promote*.go`, `internal/client/client.go`, `internal/client/api_errors.go`.
|
||||
- **Land the framework-side semantic changes** previously scoped to spec §9 PR 2: `SecurityPolicyError` exit `1→6`, `WrapDoAPIError` typed (`*NetworkError` with subtype timeout/tls/dns/server_error/transport, `*InternalError` for JSON-decode), `WrapJSONResponseParseError` typed, `errcompat.PromoteConfigError` real Type routing, `PromoteAuthError` helper + dispatcher wiring, 10 credential Lark codes registered in codeMeta, 99991543 config classification, `resolveAccessToken` typed `*AuthenticationError`, `BuildAPIError` filling `*PermissionError.MissingScopes` / `Identity` / `ConsoleURL`, deletion of `scopeAwareChecker`.
|
||||
- **Add `forbidigo` rule** banning `output.Err*` constructors in `shortcuts/**` and `cmd/**` (mirrors the contract that new business code must use the builder).
|
||||
- **CHANGELOG** lists the resulting ~10 shell-exit-code shifts in one release entry (vs the spec §1 spread of 11 — the remaining one site lives in `task` business code).
|
||||
|
||||
### Business-domain migration (self-service, no central timeline)
|
||||
|
||||
Each business package migrates its own `output.Err*` call sites to the builder when convenient — typically batched within one domain. The guide at [`docs/errors-guide.md`](../docs/errors-guide.md) walks owners through the 8 typical error modes (validation / authorization / authentication / config / network / api / internal / policy) with real `file:line` examples from main. The three-layer extension model (add Subtype / add field / add Category) handles cases the existing taxonomy does not cover.
|
||||
|
||||
Helper assertions accept both shapes during migration (see `shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) so domain migrations stay green incrementally.
|
||||
|
||||
### Legacy removal
|
||||
|
||||
Deferred until business migration completion approaches the asymptote. `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and `ErrorEnvelope` are `// Deprecated:` today and stay callable. No fixed removal date.
|
||||
|
||||
### Before / after at a call site
|
||||
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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
21
errs/subtypes_service_task.go
Normal file
21
errs/subtypes_service_task.go
Normal 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"
|
||||
)
|
||||
653
errs/types.go
653
errs/types.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
minutesDetailRetryDelay = 500 * time.Millisecond
|
||||
minutesDetailMaxRetries = 2
|
||||
)
|
||||
|
||||
// MinutesMinuteSourceOutput is the flattened minute source payload.
|
||||
type MinutesMinuteSourceOutput struct {
|
||||
SourceType string `json:"source_type,omitempty" desc:"Minute source type"`
|
||||
SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"`
|
||||
}
|
||||
|
||||
// MinutesMinuteGeneratedOutput is the flattened shape for minutes.minute.generated_v1.
|
||||
type MinutesMinuteGeneratedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always minutes.minute.generated_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
MinuteToken string `json:"minute_token,omitempty" desc:"Minute token"`
|
||||
Title string `json:"title,omitempty" desc:"Minute title"`
|
||||
MinuteSource *MinutesMinuteSourceOutput `json:"minute_source,omitempty" desc:"Minute source metadata"`
|
||||
}
|
||||
|
||||
func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
MinuteSource struct {
|
||||
SourceType string `json:"source_type"`
|
||||
SourceEntityID string `json:"source_entity_id"`
|
||||
} `json:"minute_source"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
out := &MinutesMinuteGeneratedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MinuteToken: envelope.Event.MinuteToken,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
if src := envelope.Event.MinuteSource; src.SourceType != "" || src.SourceEntityID != "" {
|
||||
out.MinuteSource = &MinutesMinuteSourceOutput{
|
||||
SourceType: src.SourceType,
|
||||
SourceEntityID: src.SourceEntityID,
|
||||
}
|
||||
}
|
||||
|
||||
if rt != nil && out.MinuteToken != "" {
|
||||
fillMinutesMinuteGeneratedDetails(ctx, rt, out)
|
||||
}
|
||||
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func fillMinutesMinuteGeneratedDetails(ctx context.Context, rt event.APIClient, out *MinutesMinuteGeneratedOutput) {
|
||||
if rt == nil || out == nil || out.MinuteToken == "" {
|
||||
return
|
||||
}
|
||||
|
||||
path := fmt.Sprintf(pathMinuteDetailFmt, validate.EncodePathSegment(out.MinuteToken))
|
||||
|
||||
type minuteDetailResp struct {
|
||||
Data struct {
|
||||
Minute struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"minute"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
for attempt := 0; attempt <= minutesDetailMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(minutesDetailRetryDelay)
|
||||
}
|
||||
|
||||
raw, err := rt.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var resp minuteDetailResp
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.Data.Minute.Title == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
out.Title = resp.Data.Minute.Title
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
if s.callFn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.callFn(ctx, method, path, body)
|
||||
}
|
||||
|
||||
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
|
||||
t.Helper()
|
||||
want := map[string]string{"event_type": wantEventType}
|
||||
if !reflect.DeepEqual(gotBody, want) {
|
||||
t.Fatalf("request body = %#v, want %#v", gotBody, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
for _, k := range Keys() {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestMinutesKeys_ProcessedMinuteGeneratedRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup(eventTypeMinuteGenerated)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for processed key")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Error("PreConsume must not be nil for processed key")
|
||||
}
|
||||
if len(def.Scopes) != 1 || def.Scopes[0] != "minutes:minutes.basic:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
var gotMethod, gotPath string
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
gotMethod = method
|
||||
gotPath = path
|
||||
if body != nil {
|
||||
t.Fatalf("GET detail body = %#v, want nil", body)
|
||||
}
|
||||
return json.RawMessage(`{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"minute": {
|
||||
"token": "<doc_token_001>",
|
||||
"title": "产品周会的视频会议",
|
||||
"note_id": "7616590025794260496"
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_001",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989000"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_001>",
|
||||
"minute_source": {
|
||||
"source_type": "meeting",
|
||||
"source_entity_id": "6911188411934433028"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if gotMethod != "GET" {
|
||||
t.Errorf("detail method = %q, want GET", gotMethod)
|
||||
}
|
||||
if gotPath != fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment("<doc_token_001>")) {
|
||||
t.Errorf("detail path = %q", gotPath)
|
||||
}
|
||||
if out.Type != eventTypeMinuteGenerated {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_minute_001" || out.Timestamp != "1608725989000" {
|
||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.MinuteToken != "<doc_token_001>" {
|
||||
t.Errorf("MinuteToken = %q", out.MinuteToken)
|
||||
}
|
||||
if out.Title != "产品周会的视频会议" {
|
||||
t.Errorf("Title = %q", out.Title)
|
||||
}
|
||||
if out.MinuteSource == nil {
|
||||
t.Fatal("MinuteSource should not be nil")
|
||||
}
|
||||
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "6911188411934433028" {
|
||||
t.Errorf("MinuteSource = %+v", out.MinuteSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_DetailFailureFallsBackToBaseFields(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
called := 0
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
called++
|
||||
return nil, context.DeadlineExceeded
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_002",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989001"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_004>",
|
||||
"minute_source": {
|
||||
"source_type": "meeting",
|
||||
"source_entity_id": "7641156270787481117"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
wantCalls := 1 + minutesDetailMaxRetries
|
||||
if called != wantCalls {
|
||||
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
|
||||
}
|
||||
if out.MinuteToken != "<doc_token_004>" {
|
||||
t.Errorf("MinuteToken = %q", out.MinuteToken)
|
||||
}
|
||||
if out.Title != "" {
|
||||
t.Errorf("Title = %q, want empty", out.Title)
|
||||
}
|
||||
if out.MinuteSource == nil {
|
||||
t.Fatal("MinuteSource should remain from event payload")
|
||||
}
|
||||
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "7641156270787481117" {
|
||||
t.Errorf("MinuteSource = %+v", out.MinuteSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_EmptyTitleRetriesAndSucceeds(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
called := 0
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
|
||||
called++
|
||||
if called <= 1 {
|
||||
return json.RawMessage(`{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"minute": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
}
|
||||
return json.RawMessage(`{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"minute": {
|
||||
"title": "delayed title"
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_retry",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989000"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_003>"
|
||||
}
|
||||
}`)
|
||||
|
||||
if called != 2 {
|
||||
t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called)
|
||||
}
|
||||
if out.Title != "delayed title" {
|
||||
t.Errorf("Title = %q, want delayed title", out.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_EmptyTitleExhaustsRetries(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
called := 0
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
|
||||
called++
|
||||
return json.RawMessage(`{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"minute": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_exhaust",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989000"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_002>"
|
||||
}
|
||||
}`)
|
||||
|
||||
wantCalls := 1 + minutesDetailMaxRetries
|
||||
if called != wantCalls {
|
||||
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
|
||||
}
|
||||
if out.Title != "" {
|
||||
t.Errorf("Title = %q, want empty after exhausted retries", out.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesMinuteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup(eventTypeMinuteGenerated)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
|
||||
}
|
||||
|
||||
type call struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}
|
||||
var calls []call
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
calls = append(calls, call{method: method, path: path, body: body})
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatal("cleanup must not be nil")
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
||||
}
|
||||
if calls[0].method != "POST" || calls[0].path != pathMinuteSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, eventTypeMinuteGenerated)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathMinuteUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, eventTypeMinuteGenerated)
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMinuteGenerated,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processMinutesMinuteGenerated(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) MinutesMinuteGeneratedOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMinuteGenerated,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processMinutesMinuteGenerated(context.Background(), rt, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out MinutesMinuteGeneratedOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid MinutesMinuteGeneratedOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package minutes registers Minutes-domain EventKeys.
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMinuteGenerated = "minutes.minute.generated_v1"
|
||||
|
||||
pathMinuteSubscribe = "/open-apis/minutes/v1/minutes/subscription"
|
||||
pathMinuteUnsubscribe = "/open-apis/minutes/v1/minutes/unsubscription"
|
||||
|
||||
pathMinuteDetailFmt = "/open-apis/minutes/v1/minutes/%s"
|
||||
)
|
||||
|
||||
// Keys returns all Minutes-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeMinuteGenerated,
|
||||
DisplayName: "Minute generated",
|
||||
Description: "Triggered when a minute has been generated",
|
||||
EventType: eventTypeMinuteGenerated,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})},
|
||||
},
|
||||
Process: processMinutesMinuteGenerated,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe),
|
||||
Scopes: []string{"minutes:minutes.basic:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeMinuteGenerated},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -6,17 +6,13 @@ package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// Mail is intentionally omitted in this phase.
|
||||
// Mail is intentionally omitted: only IM is wired up this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
vc.Keys(),
|
||||
}
|
||||
for _, keys := range all {
|
||||
for _, k := range keys {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCParticipantMeetingEndedOutput is the flattened shape for vc.meeting.participant_meeting_ended_v1.
|
||||
type VCParticipantMeetingEndedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_ended_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
||||
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
||||
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
||||
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
||||
EndTime string `json:"end_time,omitempty" desc:"Meeting end time in RFC3339, converted to the local timezone"`
|
||||
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
||||
}
|
||||
|
||||
func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Meeting struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
MeetingNo string `json:"meeting_no"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CalendarEventID string `json:"calendar_event_id"`
|
||||
} `json:"meeting"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
meeting := envelope.Event.Meeting
|
||||
out := &VCParticipantMeetingEndedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MeetingID: meeting.ID,
|
||||
Topic: meeting.Topic,
|
||||
MeetingNo: meeting.MeetingNo,
|
||||
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
||||
EndTime: unixSecondsToLocalRFC3339(meeting.EndTime),
|
||||
CalendarEventID: meeting.CalendarEventID,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func unixSecondsToLocalRFC3339(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
secs, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(secs, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
for _, k := range Keys() {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestVCKeys_ProcessedMeetingEndedRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup(eventTypeMeetingEnded)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventTypeMeetingEnded)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for processed key")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Error("PreConsume must not be nil for processed key")
|
||||
}
|
||||
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_end_001",
|
||||
"event_type": "vc.meeting.participant_meeting_ended_v1",
|
||||
"create_time": "1608725989000",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "6911188411934433028",
|
||||
"topic": "my meeting",
|
||||
"meeting_no": "235812466",
|
||||
"start_time": "1608883322",
|
||||
"end_time": "1608883899",
|
||||
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingEnded(t, payload)
|
||||
|
||||
if out.Type != eventTypeMeetingEnded {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_vc_end_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.Timestamp != "1608725989000" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
if out.MeetingID != "6911188411934433028" {
|
||||
t.Errorf("MeetingID = %q", out.MeetingID)
|
||||
}
|
||||
if out.Topic != "my meeting" || out.MeetingNo != "235812466" {
|
||||
t.Errorf("Topic/MeetingNo = %q/%q", out.Topic, out.MeetingNo)
|
||||
}
|
||||
if out.CalendarEventID != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
|
||||
t.Errorf("CalendarEventID = %q", out.CalendarEventID)
|
||||
}
|
||||
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out.StartTime != want {
|
||||
t.Errorf("StartTime = %q, want %q", out.StartTime, want)
|
||||
}
|
||||
if want := time.Unix(1608883899, 0).Local().Format(time.RFC3339); out.EndTime != want {
|
||||
t.Errorf("EndTime = %q, want %q", out.EndTime, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded_InvalidMeetingTimes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_end_002",
|
||||
"event_type": "vc.meeting.participant_meeting_ended_v1",
|
||||
"create_time": "1608725989001"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "meeting_invalid_time",
|
||||
"start_time": "bad",
|
||||
"end_time": ""
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingEnded(t, payload)
|
||||
if out.StartTime != "" || out.EndTime != "" {
|
||||
t.Errorf("StartTime/EndTime = %q/%q, want empty strings", out.StartTime, out.EndTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMeetingEnded,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVCParticipantMeetingEnded_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup("vc.meeting.participant_meeting_ended_v1")
|
||||
if !ok {
|
||||
t.Fatal("vc.meeting.participant_meeting_ended_v1 should be registered via Keys()")
|
||||
}
|
||||
|
||||
type call struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}
|
||||
var calls []call
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
calls = append(calls, call{method: method, path: path, body: body})
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatal("cleanup must not be nil")
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
||||
}
|
||||
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, eventTypeMeetingEnded)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, eventTypeMeetingEnded)
|
||||
}
|
||||
|
||||
func runMeetingEnded(t *testing.T, payload string) VCParticipantMeetingEndedOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMeetingEnded,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out VCParticipantMeetingEndedOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid VCParticipantMeetingEndedOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package vc registers VC-domain EventKeys.
|
||||
package vc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
|
||||
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
||||
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
|
||||
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
|
||||
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
|
||||
|
||||
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
|
||||
)
|
||||
|
||||
// Keys returns all VC-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeMeetingEnded,
|
||||
DisplayName: "Participant meeting ended",
|
||||
Description: "Triggered when a meeting the current user participates in has ended",
|
||||
EventType: eventTypeMeetingEnded,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingEndedOutput{})},
|
||||
},
|
||||
Process: processVCParticipantMeetingEnded,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe),
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeMeetingEnded},
|
||||
},
|
||||
{
|
||||
Key: eventTypeNoteGenerated,
|
||||
DisplayName: "Note generated",
|
||||
Description: "Triggered when a note has been generated",
|
||||
EventType: eventTypeNoteGenerated,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCNoteGeneratedOutput{})},
|
||||
},
|
||||
Process: processVCNoteGenerated,
|
||||
PreConsume: subscriptionPreConsume(eventTypeNoteGenerated, pathNoteSubscribe, pathNoteUnsubscribe),
|
||||
Scopes: []string{"vc:note:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeNoteGenerated},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
if s.callFn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.callFn(ctx, method, path, body)
|
||||
}
|
||||
|
||||
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
|
||||
t.Helper()
|
||||
want := map[string]string{"event_type": wantEventType}
|
||||
if !reflect.DeepEqual(gotBody, want) {
|
||||
t.Fatalf("request body = %#v, want %#v", gotBody, want)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a transport interceptor for the auth sidecar
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an http:// or https://
|
||||
// URL), all outgoing requests are rewritten to the sidecar address. The
|
||||
// interceptor strips placeholder credentials, injects proxy headers, and
|
||||
// signs each request with HMAC-SHA256. No custom DialContext is needed —
|
||||
// Go's standard http.Transport connects to the sidecar via HTTP, or via
|
||||
// HTTPS (TLS) when the sidecar address is an https:// URL.
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
|
||||
// outgoing requests are rewritten to the sidecar address. The interceptor
|
||||
// strips placeholder credentials, injects proxy headers, and signs each
|
||||
// request with HMAC-SHA256. No custom DialContext is needed — Go's
|
||||
// standard http.Transport connects to the sidecar via plain HTTP.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
@@ -47,17 +46,15 @@ func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor
|
||||
}
|
||||
key := os.Getenv(envvars.CliProxyKey)
|
||||
return &Interceptor{
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
sidecarScheme: sidecar.ProxyScheme(proxyAddr),
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
}
|
||||
}
|
||||
|
||||
// Interceptor rewrites requests for the sidecar proxy.
|
||||
type Interceptor struct {
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host[:port] for URL rewriting
|
||||
sidecarScheme string // "http" (same-host) or "https" (remote TLS sidecar)
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host:port for URL rewriting
|
||||
}
|
||||
|
||||
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
||||
@@ -133,13 +130,8 @@ func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response,
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||
|
||||
// 5. Rewrite URL to route through sidecar. Scheme follows the configured
|
||||
// proxy address: https for a remote (TLS) sidecar, http for a same-host one.
|
||||
scheme := i.sidecarScheme
|
||||
if scheme == "" {
|
||||
scheme = "http"
|
||||
}
|
||||
req.URL.Scheme = scheme
|
||||
// 5. Rewrite URL to route through sidecar
|
||||
req.URL.Scheme = "http"
|
||||
req.URL.Host = i.sidecarHost
|
||||
|
||||
return nil // no post-hook needed
|
||||
|
||||
@@ -7,13 +7,11 @@ package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
@@ -99,54 +97,6 @@ func TestInterceptor_PreRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptor_PreRoundTrip_HTTPS verifies that a remote (TLS) sidecar
|
||||
// rewrites the request to https://<remote-host>, while still preserving the
|
||||
// original target and signing the request.
|
||||
func TestInterceptor_PreRoundTrip_HTTPS(t *testing.T) {
|
||||
key := []byte("test-key-for-hmac-signing-32byte!")
|
||||
interceptor := &Interceptor{key: key, sidecarHost: "sidecar.mycorp.com", sidecarScheme: "https"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/im/v1/chats", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if req.URL.Scheme != "https" {
|
||||
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "https")
|
||||
}
|
||||
if req.URL.Host != "sidecar.mycorp.com" {
|
||||
t.Errorf("host = %q, want %q", req.URL.Host, "sidecar.mycorp.com")
|
||||
}
|
||||
// Original target still preserved for the sidecar to forward upstream.
|
||||
if target := req.Header.Get(sidecar.HeaderProxyTarget); target != "https://open.feishu.cn" {
|
||||
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
|
||||
}
|
||||
// Request is still signed.
|
||||
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
|
||||
t.Error("signature header should be set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveInterceptor_HTTPSScheme pins the end-to-end env→scheme path: a
|
||||
// (mixed-case) https proxy address must produce an interceptor that rewrites to
|
||||
// https, never silently downgrading a remote sidecar to plaintext http.
|
||||
func TestResolveInterceptor_HTTPSScheme(t *testing.T) {
|
||||
t.Setenv(envvars.CliAuthProxy, "HTTPS://sidecar.mycorp.com") // uppercase on purpose
|
||||
t.Setenv(envvars.CliProxyKey, "key")
|
||||
|
||||
ic := (&Provider{}).ResolveInterceptor(context.Background())
|
||||
si, ok := ic.(*Interceptor)
|
||||
if !ok || si == nil {
|
||||
t.Fatalf("expected *Interceptor, got %T", ic)
|
||||
}
|
||||
if si.sidecarScheme != "https" {
|
||||
t.Errorf("sidecarScheme = %q, want %q (uppercase HTTPS must not downgrade)", si.sidecarScheme, "https")
|
||||
}
|
||||
if si.sidecarHost != "sidecar.mycorp.com" {
|
||||
t.Errorf("sidecarHost = %q, want %q", si.sidecarHost, "sidecar.mycorp.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_BotIdentity(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user