mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
42 Commits
v1.0.37
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
823a55a1ef | ||
|
|
e98471ce26 | ||
|
|
9e2be14301 | ||
|
|
367cfc9d06 | ||
|
|
e182b01f68 | ||
|
|
1135fc2767 | ||
|
|
68d78d5067 | ||
|
|
b783561965 | ||
|
|
f00261da9f | ||
|
|
137176e8b0 | ||
|
|
0bf590d01a | ||
|
|
cf40945bbc | ||
|
|
b9e5b50251 | ||
|
|
049ddf771b | ||
|
|
f12d279fc2 | ||
|
|
83adbac2b2 | ||
|
|
ee9d090e64 | ||
|
|
fe72e41fb2 | ||
|
|
877fbe6d47 | ||
|
|
e93e2a98e1 | ||
|
|
0dda56914d | ||
|
|
8bc4ec3fff | ||
|
|
06a3921f40 | ||
|
|
b25ff1ced5 | ||
|
|
aea9f37f58 | ||
|
|
ac06eaa0f4 | ||
|
|
282c27784d | ||
|
|
f2a4c95665 | ||
|
|
cb5055eb46 | ||
|
|
9d4233bfe3 | ||
|
|
708cbc2b31 | ||
|
|
6d1f9980fa | ||
|
|
6e3e120ec8 | ||
|
|
ce5b4f24e1 | ||
|
|
4b2223194b | ||
|
|
4582dfd281 | ||
|
|
5c01a7f7f0 | ||
|
|
d5d2fee848 | ||
|
|
ffcf7781b4 | ||
|
|
fbe4cc689a | ||
|
|
ac85c3e34d | ||
|
|
daba3c9afd |
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 xxx` command works as expected
|
||||
- [ ] Manual local verification confirms the `lark-cli <domain> <command>` flow works as expected
|
||||
|
||||
## Related Issues
|
||||
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
# Build output
|
||||
/lark-cli
|
||||
/lark-cli*
|
||||
.cache/
|
||||
dist/
|
||||
bin/
|
||||
|
||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -2,6 +2,75 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
|
||||
- **wiki**: Add exponential backoff retry for `+node-create` lock contention (#1012)
|
||||
- **auth**: Add `auth qrcode` subcommand and update auth docs/hints (#968)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **wiki**: Rename `+node-get --token` to `--node-token`, keep alias (#1074)
|
||||
- **output**: Classify wiki lock-contention error (131009) with retry hint (#1014)
|
||||
- **contact**: Add actionable hint when fanout search all-fail with no API code (#1054)
|
||||
- **permission**: Annotate auto-grant permission failures with `required_scope` and `console_url` (#1045)
|
||||
- **validation**: Use `ErrValidation` instead of `fmt.Errorf` in `Validate` paths (#1001)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Add 云盘/云存储 alias alongside 云空间 for agent clarity (#1073)
|
||||
- **task**: Refresh `lark-task` shortcut docs (#1057)
|
||||
|
||||
## [v1.0.39] - 2026-05-22
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+export` shortcut to export slides (#988)
|
||||
- **sidecar**: Support multi-client identity isolation in `server-demo` via per-client HMAC keys, preventing UAT cross-contamination when multiple CLI sandboxes share one sidecar (#934)
|
||||
- **im**: Support Markdown image rendering in post content (#893)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scope**: Add 22 new scope entries to scope priorities (#1050)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Update location `full_address` guidance (#754)
|
||||
- **apps**: Refine `lark-apps` skill description and surface, document `index.html` / `--path` hard constraints (#1040)
|
||||
|
||||
## [v1.0.38] - 2026-05-22
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Gate the Miaoda apps domain off on the Lark brand — the `apps` shortcut subtree returns a structured brand-restriction error, `auth login --domain apps` is rejected, `--domain all` skips it, and `spark:*` scopes are no longer requested (#1025)
|
||||
|
||||
## [v1.0.37] - 2026-05-21
|
||||
|
||||
### Features
|
||||
@@ -817,6 +886,10 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
[v1.0.37]: https://github.com/larksuite/cli/releases/tag/v1.0.37
|
||||
[v1.0.36]: https://github.com/larksuite/cli/releases/tag/v1.0.36
|
||||
[v1.0.35]: https://github.com/larksuite/cli/releases/tag/v1.0.35
|
||||
|
||||
@@ -279,6 +279,8 @@ Community contributions are welcome! If you find a bug or have feature suggestio
|
||||
|
||||
For major changes, we recommend discussing with us first via an Issue.
|
||||
|
||||
Before opening a PR, see [AGENTS.md](./AGENTS.md) for the local build, test, and PR checklist used by contributors and AI agents.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **MIT License**.
|
||||
|
||||
@@ -280,6 +280,8 @@ lark-cli schema im.messages.delete
|
||||
|
||||
对于较大的改动,建议先通过 Issue 与我们讨论。
|
||||
|
||||
提交 PR 前,请先阅读 [AGENTS.md](./AGENTS.md),其中列出了贡献者和 AI Agent 使用的本地构建、测试和 PR 检查清单。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 **MIT 许可证** 开源。
|
||||
|
||||
@@ -238,7 +238,11 @@ func apiRun(opts *APIOptions) error {
|
||||
|
||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||
if err != nil {
|
||||
return output.MarkRaw(client.WrapDoAPIError(err))
|
||||
// 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{
|
||||
OutputPath: opts.Output,
|
||||
@@ -248,9 +252,15 @@ func apiRun(opts *APIOptions) error {
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
// Stage 1: CheckResponse emits the legacy *output.ExitError envelope.
|
||||
// Per-domain migration in stage 2+ will route through
|
||||
// errclass.BuildAPIError to populate identity-aware fields
|
||||
// (PermissionError.ConsoleURL needs Brand+AppID from the client).
|
||||
CheckError: ac.CheckResponse,
|
||||
})
|
||||
// MarkRaw tells root error handler to skip enrichPermissionError,
|
||||
// preserving the original API error detail (log_id, troubleshooter, etc.).
|
||||
// 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)
|
||||
}
|
||||
@@ -262,9 +272,12 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
|
||||
if pagOpts.Identity == "" {
|
||||
pagOpts.Identity = request.As
|
||||
}
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil {
|
||||
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
@@ -277,9 +290,9 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
pf.FormatPage(items)
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
@@ -291,9 +304,9 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
default:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -399,154 +397,6 @@ func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_APIError_IsRaw(t *testing.T) {
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Return a permission error from the API
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/perm", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for permission denied API response")
|
||||
}
|
||||
|
||||
// Error should be marked Raw
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if !exitErr.Raw {
|
||||
t.Error("expected API error from api command to be marked Raw")
|
||||
}
|
||||
|
||||
// Note: stderr envelope output is tested at the root level (TestHandleRootError_*)
|
||||
// since WriteErrorEnvelope is called by handleRootError, not by cobra's Execute.
|
||||
_ = stderr
|
||||
}
|
||||
|
||||
func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/origmsg",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:message:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/origmsg", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
// The message should NOT have been enriched (no "App scope not enabled" replacement)
|
||||
if strings.Contains(exitErr.Error(), "App scope not enabled") {
|
||||
t.Error("expected original message, not enriched message")
|
||||
}
|
||||
// Detail should still contain the raw API error detail
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil Detail")
|
||||
}
|
||||
if exitErr.Detail.Detail == nil {
|
||||
t.Error("expected raw Detail.Detail to be preserved (not cleared by enrichment)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/invalidjson",
|
||||
RawBody: []byte{},
|
||||
ContentType: "application/json",
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected detail on exit error")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
|
||||
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
|
||||
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--output") {
|
||||
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/rawpage",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled",
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/rawpage", "--as", "bot", "--page-all"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if !exitErr.Raw {
|
||||
t.Error("expected paginated API error to be marked Raw")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -43,6 +43,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthList(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthCheck(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"only delivers final turn messages",
|
||||
"--no-wait --json",
|
||||
"send the verification URL to the user as your final message",
|
||||
"send the verification URL (or QR code) to the user as your final message",
|
||||
"run --device-code in a later step",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
|
||||
@@ -47,9 +47,10 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
|
||||
Long: `Device Flow authorization login.
|
||||
|
||||
For AI agents: this command blocks until the user completes authorization in the
|
||||
browser. If your harness only delivers final turn messages, use --no-wait --json,
|
||||
send the verification URL to the user as your final message, end the turn, then
|
||||
run --device-code in a later step after the user confirms authorization.`,
|
||||
browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json,
|
||||
send the verification URL (or QR code) to the user as your final message, end the turn, then
|
||||
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
|
||||
to generate QR codes (supports ASCII and PNG formats).`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return output.ErrWithHint(output.ExitValidation, "command_denied",
|
||||
@@ -68,7 +69,13 @@ run --device-code in a later step after the user confirms authorization.`,
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
available := sortedKnownDomains()
|
||||
var helpBrand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
helpBrand = cfg.Brand
|
||||
}
|
||||
}
|
||||
available := sortedKnownDomains(helpBrand)
|
||||
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
|
||||
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
|
||||
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
|
||||
@@ -139,14 +146,14 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
// Expand --domain all to all available domains (from_meta projects + shortcut services)
|
||||
for _, d := range selectedDomains {
|
||||
if strings.EqualFold(d, "all") {
|
||||
selectedDomains = sortedKnownDomains()
|
||||
selectedDomains = sortedKnownDomains(config.Brand)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domain names and suggest corrections for unknown ones
|
||||
if len(selectedDomains) > 0 {
|
||||
knownDomains := allKnownDomains()
|
||||
knownDomains := allKnownDomains(config.Brand)
|
||||
for _, d := range selectedDomains {
|
||||
if !knownDomains[d] {
|
||||
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
|
||||
@@ -170,7 +177,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
if !hasAnyOption {
|
||||
if !opts.JSON && f.IOStreams.IsTerminal {
|
||||
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
|
||||
result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -208,10 +215,10 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
if len(selectedDomains) > 0 || opts.Recommend {
|
||||
var candidateScopes []string
|
||||
if len(selectedDomains) > 0 {
|
||||
candidateScopes = collectScopesForDomains(selectedDomains, "user")
|
||||
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
|
||||
} else {
|
||||
// --recommend without --domain: all domains
|
||||
candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user")
|
||||
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
|
||||
}
|
||||
|
||||
// Filter to auto-approve scopes if --recommend or interactive "common"
|
||||
@@ -269,7 +276,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the URL the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
|
||||
"hint": 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)
|
||||
@@ -452,6 +459,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncLoginUserToProfile persists the logged-in user info into the named profile.
|
||||
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
@@ -477,6 +485,7 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// findProfileByName returns the AppConfig matching profileName, or nil.
|
||||
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].ProfileName() == profileName {
|
||||
@@ -490,7 +499,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
|
||||
// shortcut scopes for the given domain names.
|
||||
// Domains with auth_domain children are automatically expanded to include
|
||||
// their children's scopes.
|
||||
func collectScopesForDomains(domains []string, identity string) []string {
|
||||
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
|
||||
scopeSet := make(map[string]bool)
|
||||
|
||||
// 1. API scopes from from_meta projects
|
||||
@@ -509,6 +518,9 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
continue
|
||||
}
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.DeclaredScopesForIdentity(identity) {
|
||||
scopeSet[s] = true
|
||||
@@ -528,7 +540,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
// allKnownDomains returns all valid auth domain names (from_meta projects +
|
||||
// shortcut services), excluding domains that have auth_domain set (they are
|
||||
// folded into their parent domain).
|
||||
func allKnownDomains() map[string]bool {
|
||||
func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
domains := make(map[string]bool)
|
||||
for _, p := range registry.ListFromMetaProjects() {
|
||||
if !registry.HasAuthDomain(p) {
|
||||
@@ -536,6 +548,9 @@ func allKnownDomains() map[string]bool {
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
continue
|
||||
}
|
||||
if !registry.HasAuthDomain(sc.Service) {
|
||||
domains[sc.Service] = true
|
||||
}
|
||||
@@ -544,8 +559,8 @@ func allKnownDomains() map[string]bool {
|
||||
}
|
||||
|
||||
// sortedKnownDomains returns all valid domain names sorted alphabetically.
|
||||
func sortedKnownDomains() []string {
|
||||
m := allKnownDomains()
|
||||
func sortedKnownDomains(brand core.LarkBrand) []string {
|
||||
m := allKnownDomains(brand)
|
||||
domains := make([]string, 0, len(m))
|
||||
for d := range m {
|
||||
domains = append(domains, d)
|
||||
|
||||
32
cmd/auth/login_brand_filter_test.go
Normal file
32
cmd/auth/login_brand_filter_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
|
||||
feishuDomains := allKnownDomains(core.BrandFeishu)
|
||||
if !feishuDomains["apps"] {
|
||||
t.Errorf("expected apps domain to be known on Feishu brand")
|
||||
}
|
||||
|
||||
larkDomains := allKnownDomains(core.BrandLark)
|
||||
if larkDomains["apps"] {
|
||||
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
|
||||
}
|
||||
|
||||
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
|
||||
if len(feishuScopes) == 0 {
|
||||
t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes))
|
||||
}
|
||||
|
||||
larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark)
|
||||
if len(larkScopes) != 0 {
|
||||
t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
@@ -105,7 +106,7 @@ func buildDomainMeta(name, lang string) domainMeta {
|
||||
}
|
||||
|
||||
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
|
||||
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*interactiveResult, error) {
|
||||
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
|
||||
allDomains := getDomainMetadata(lang)
|
||||
|
||||
// Build multi-select options
|
||||
@@ -165,7 +166,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i
|
||||
}
|
||||
|
||||
// Compute scope summary
|
||||
scopes := collectScopesForDomains(selectedDomains, "user")
|
||||
scopes := collectScopesForDomains(selectedDomains, "user", brand)
|
||||
if permLevel == "common" {
|
||||
scopes = registry.FilterAutoApproveScopes(scopes)
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 只会把最终回复发给用户,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 和 verification_url,把 verification_url 作为本轮最终消息原样发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 `lark-cli auth login --device-code <code>` 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL,把它视为不可修改的 opaque string;不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url,把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code <code>\" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output);仅当用户明确要求时才使用 ASCII(--ascii)。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL,再将二维码图片置于 URL 下方完整展示。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string,不要做任何修改(包括 URL 编码/解码、添加空格或标点)。",
|
||||
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
@@ -95,7 +95,7 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness only delivers final turn messages, use `lark-cli auth login --no-wait --json` to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run `lark-cli auth login --device-code <code>` in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
|
||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code <code>\" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**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.",
|
||||
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
@@ -114,6 +114,7 @@ var loginMsgEn = &loginMsg{
|
||||
HintFooter: " lark-cli auth login --help",
|
||||
}
|
||||
|
||||
// getLoginMsg returns the login message bundle for the given language.
|
||||
func getLoginMsg(lang string) *loginMsg {
|
||||
if lang == "en" {
|
||||
return loginMsgEn
|
||||
|
||||
@@ -176,6 +176,11 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
"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{
|
||||
|
||||
@@ -171,7 +171,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllKnownDomains(t *testing.T) {
|
||||
domains := allKnownDomains()
|
||||
domains := allKnownDomains("")
|
||||
if len(domains) == 0 {
|
||||
t.Fatal("expected non-empty known domains")
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func TestAllKnownDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSortedKnownDomains(t *testing.T) {
|
||||
sorted := sortedKnownDomains()
|
||||
sorted := sortedKnownDomains("")
|
||||
if len(sorted) == 0 {
|
||||
t.Fatal("expected non-empty sorted domains")
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func TestSortedKnownDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should match allKnownDomains
|
||||
known := allKnownDomains()
|
||||
known := allKnownDomains("")
|
||||
if len(sorted) != len(known) {
|
||||
t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known))
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func TestCollectScopesForDomains(t *testing.T) {
|
||||
t.Skip("no from_meta data available")
|
||||
}
|
||||
|
||||
scopes := collectScopesForDomains([]string{"calendar"}, "user")
|
||||
scopes := collectScopesForDomains([]string{"calendar"}, "user", "")
|
||||
if len(scopes) == 0 {
|
||||
t.Fatal("expected non-empty scopes for calendar domain")
|
||||
}
|
||||
@@ -247,7 +247,7 @@ func TestCollectScopesForDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user")
|
||||
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user", "")
|
||||
if len(scopes) != 0 {
|
||||
t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes))
|
||||
}
|
||||
@@ -945,12 +945,19 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
||||
}
|
||||
hint, _ := data["hint"].(string)
|
||||
for _, want := range []string{
|
||||
"exactly as returned by the CLI",
|
||||
"MUST generate QR code AND display it",
|
||||
"lark-cli auth qrcode",
|
||||
"Prefer PNG QR code (--output)",
|
||||
"use ASCII (--ascii) only when the user explicitly requests it",
|
||||
"This is a required step, do NOT skip it",
|
||||
"CRITICAL",
|
||||
"You MUST include the QR image in your response",
|
||||
"Generating the file alone is NOT enough",
|
||||
"image tags, inline images, or file attachments",
|
||||
"Display order",
|
||||
"place the QR code image below the URL",
|
||||
"opaque string",
|
||||
"Do not URL-encode or decode it",
|
||||
"do not add %20, spaces, or punctuation",
|
||||
"do not wrap it as Markdown link text",
|
||||
"fenced code block containing only the raw URL",
|
||||
"cannot be modified",
|
||||
"final message of the turn",
|
||||
"return control to the user",
|
||||
"do not block on --device-code in the same turn",
|
||||
@@ -1054,12 +1061,17 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *
|
||||
"结束本轮",
|
||||
"用户回复已完成授权",
|
||||
"不要在同一轮里展示 URL 后立刻阻塞执行 --device-code",
|
||||
"逐字原样转发 CLI 返回的 URL",
|
||||
"必须生成二维码并展示",
|
||||
"lark-cli auth qrcode",
|
||||
"优先生成 PNG 二维码(--output)",
|
||||
"仅当用户明确要求时才使用 ASCII(--ascii)",
|
||||
"生成后必须在回复中展示图片",
|
||||
"仅生成文件不算完成",
|
||||
"image 标签或内联图片",
|
||||
"二维码图片置于 URL 下方完整展示",
|
||||
"URL 输出规则",
|
||||
"opaque string",
|
||||
"不要做 URL 编码或解码",
|
||||
"不要补 `%20`、空格或标点",
|
||||
"不要改写成 Markdown 链接",
|
||||
"只包含该 URL 的代码块单独输出",
|
||||
"不要做任何修改",
|
||||
} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
|
||||
@@ -1077,7 +1089,7 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
domains := allKnownDomains()
|
||||
domains := allKnownDomains("")
|
||||
if domains["whiteboard"] {
|
||||
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
|
||||
}
|
||||
@@ -1087,7 +1099,7 @@ func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"docs"}, "user")
|
||||
scopes := collectScopesForDomains([]string{"docs"}, "user", "")
|
||||
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
|
||||
found := false
|
||||
for _, s := range scopes {
|
||||
|
||||
142
cmd/auth/qrcode.go
Normal file
142
cmd/auth/qrcode.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// QRCodeOptions holds inputs for auth qrcode command.
|
||||
type QRCodeOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
URL string
|
||||
Size int
|
||||
ASCII bool
|
||||
Output string
|
||||
}
|
||||
|
||||
// NewCmdAuthQRCode creates the auth qrcode subcommand.
|
||||
func NewCmdAuthQRCode(f *cmdutil.Factory, runF func(*QRCodeOptions) error) *cobra.Command {
|
||||
opts := &QRCodeOptions{Factory: f, Size: 256}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "qrcode <url>",
|
||||
Short: "Generate QR code for verification URL",
|
||||
Long: `Generate a QR code image or ASCII representation for a verification URL.
|
||||
|
||||
This command is designed for AI agents to generate QR codes for OAuth authorization URLs.
|
||||
|
||||
For PNG output, the --output flag is required to specify the output file path (must be a relative path within the current directory).
|
||||
For ASCII output, the result is printed to stdout with fixed size.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.URL = args[0]
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runQRCode(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
|
||||
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runQRCode executes the auth qrcode command.
|
||||
func runQRCode(opts *QRCodeOptions) error {
|
||||
if opts.URL == "" {
|
||||
return output.Errorf(output.ExitValidation, "missing_url", "url is required")
|
||||
}
|
||||
|
||||
if opts.ASCII {
|
||||
var out io.Writer = os.Stdout
|
||||
if opts.Factory != nil {
|
||||
out = opts.Factory.IOStreams.Out
|
||||
}
|
||||
return generateASCIIQRCode(opts.URL, out)
|
||||
}
|
||||
|
||||
if opts.Output == "" {
|
||||
return output.Errorf(output.ExitValidation, "missing_output", "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.")
|
||||
}
|
||||
|
||||
if opts.Size < 32 {
|
||||
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at least 32, got %d", opts.Size))
|
||||
}
|
||||
|
||||
if opts.Size > 1024 {
|
||||
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 output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"ok": true,
|
||||
"file_path": safePath,
|
||||
"hint": "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.",
|
||||
}
|
||||
|
||||
var out io.Writer = os.Stdout
|
||||
if opts.Factory != nil {
|
||||
out = opts.Factory.IOStreams.Out
|
||||
}
|
||||
encoder := json.NewEncoder(out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(result); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write output: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateImageQRCode encodes the URL as a PNG QR code and writes it to outputPath.
|
||||
func generateImageQRCode(url string, size int, outputPath string) error {
|
||||
png, err := qrcode.Encode(url, qrcode.Medium, size)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to encode QR code: %v", err))
|
||||
}
|
||||
|
||||
err = vfs.WriteFile(outputPath, png, 0644)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "write_error", fmt.Sprintf("failed to write QR code to %s: %v", outputPath, err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateASCIIQRCode encodes the URL as an ASCII QR code and prints it to stdout.
|
||||
func generateASCIIQRCode(url string, w io.Writer) error {
|
||||
q, err := qrcode.New(url, qrcode.Medium)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to create QR code: %v", err))
|
||||
}
|
||||
|
||||
fmt.Fprint(w, q.ToSmallString(false))
|
||||
|
||||
return nil
|
||||
}
|
||||
368
cmd/auth/qrcode_test.go
Normal file
368
cmd/auth/qrcode_test.go
Normal file
@@ -0,0 +1,368 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png", "--size", "128"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.URL != "https://example.com" {
|
||||
t.Errorf("URL = %q, want %q", gotOpts.URL, "https://example.com")
|
||||
}
|
||||
if gotOpts.Size != 128 {
|
||||
t.Errorf("Size = %d, want %d", gotOpts.Size, 128)
|
||||
}
|
||||
if gotOpts.Output != "qr.png" {
|
||||
t.Errorf("Output = %q, want %q", gotOpts.Output, "qr.png")
|
||||
}
|
||||
if gotOpts.ASCII {
|
||||
t.Error("ASCII should be false by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"https://example.com", "--ascii"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !gotOpts.ASCII {
|
||||
t.Error("ASCII should be true when --ascii is passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_DefaultSize(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *QRCodeOptions
|
||||
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"https://example.com", "--ascii"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.Size != 256 {
|
||||
t.Errorf("default Size = %d, want 256", gotOpts.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_ExactOneArg(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error when no URL argument provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_RunE_PNGEndToEnd(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
tmpDir := t.TempDir()
|
||||
oldWd, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(oldWd) })
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("qr.png")
|
||||
if err != nil {
|
||||
t.Fatalf("output file not created: %v", err)
|
||||
}
|
||||
if string(data[:4]) != "\x89PNG" {
|
||||
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v, got: %s", err, stdout.String())
|
||||
}
|
||||
if result["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", result["ok"])
|
||||
}
|
||||
hint, _ := result["hint"].(string)
|
||||
if hint == "" {
|
||||
t.Error("hint is empty")
|
||||
}
|
||||
if !strings.Contains(hint, "MUST include") {
|
||||
t.Errorf("hint missing 'MUST include', got: %s", hint)
|
||||
}
|
||||
if !strings.Contains(hint, "NOT enough") {
|
||||
t.Errorf("hint missing 'NOT enough', got: %s", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_RunE_MissingOutput(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"https://example.com"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error when --output is missing in PNG mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdAuthQRCode(f, nil)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"qrcode <url>",
|
||||
"QR code",
|
||||
"--output",
|
||||
"--ascii",
|
||||
"relative path",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("help missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_MissingURL(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{URL: ""})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "missing_url" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_MissingOutput(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "missing_output" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_InvalidSize(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 16,
|
||||
Output: "qr.png",
|
||||
})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "invalid_size" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_SizeTooLarge(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 2048,
|
||||
Output: "qr.png",
|
||||
})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "invalid_size" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 256,
|
||||
Output: "/etc/passwd",
|
||||
})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_PNGWritesFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
tmpDir := t.TempDir()
|
||||
oldWd, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(oldWd) })
|
||||
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
Size: 256,
|
||||
Output: "qr.png",
|
||||
Factory: f,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat("qr.png")
|
||||
if err != nil {
|
||||
t.Fatalf("output file not created: %v", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Error("output file is empty")
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if jsonErr := json.Unmarshal(stdout.Bytes(), &result); jsonErr != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v, got: %s", jsonErr, stdout.String())
|
||||
}
|
||||
if result["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", result["ok"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_ASCIIOutputsToStdout(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
err := runQRCode(&QRCodeOptions{
|
||||
URL: "https://example.com",
|
||||
ASCII: true,
|
||||
Factory: f,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() == 0 {
|
||||
t.Error("ASCII QR code produced no output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImageQRCode_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "test-qr.png")
|
||||
|
||||
if err := generateImageQRCode("https://example.com", 256, outputPath); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output file: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("output file is empty")
|
||||
}
|
||||
if len(data) < 8 {
|
||||
t.Error("output too small to be a valid PNG")
|
||||
}
|
||||
if string(data[:4]) != "\x89PNG" {
|
||||
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImageQRCode_WriteError(t *testing.T) {
|
||||
err := generateImageQRCode("https://example.com", 256, "/nonexistent/deep/nested/dir/qr.png")
|
||||
if err == nil {
|
||||
t.Fatal("expected error writing to nonexistent directory")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
|
||||
}
|
||||
if exitErr.Detail.Type != "write_error" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "write_error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateASCIIQRCode_Success(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
err := generateASCIIQRCode("https://example.com", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Error("ASCII QR code produced no output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
err := generateASCIIQRCode("", &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty string")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "encode_error" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "encode_error")
|
||||
}
|
||||
}
|
||||
@@ -13,30 +13,45 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// assertExitError checks the full structured error in one assertion.
|
||||
// assertExitError checks the full structured error in one assertion. It
|
||||
// accepts both *output.ExitError (used by output.ErrWithHint) and the
|
||||
// typed validation error — they normalize to the same wantDetail fields.
|
||||
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
|
||||
if errors.As(err, &exitErr) {
|
||||
if exitErr.Code != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil error detail")
|
||||
}
|
||||
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
|
||||
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
|
||||
}
|
||||
return
|
||||
}
|
||||
if exitErr.Code != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil error detail")
|
||||
}
|
||||
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
|
||||
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
if got := output.ExitCodeOf(err); got != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", got, wantCode)
|
||||
}
|
||||
gotDetail := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
if !reflect.DeepEqual(gotDetail, wantDetail) {
|
||||
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
|
||||
}
|
||||
return
|
||||
}
|
||||
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
|
||||
@@ -595,8 +610,10 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
// Config errors share ExitAuth (3); the workspace is detected but no
|
||||
// binding exists yet, which is a config error.
|
||||
if cfgErr.Code != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
|
||||
@@ -33,6 +33,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -95,8 +95,9 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
// Config errors share ExitAuth (3), not ExitValidation.
|
||||
if cfgErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
||||
}
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
||||
|
||||
@@ -352,6 +352,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
// Existing app with unchanged secret — update app ID and brand only
|
||||
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
|
||||
// Deprecated: legacy *output.ExitError passthrough; removed after typed migration.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
|
||||
73
cmd/config/keychain_downgrade.go
Normal file
73
cmd/config/keychain_downgrade.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build darwin
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"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 output.ErrWithHint(
|
||||
output.ExitAPI,
|
||||
"config",
|
||||
fmt.Sprintf("keychain downgrade failed: %v", err),
|
||||
"This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design.",
|
||||
)
|
||||
}
|
||||
|
||||
switch result {
|
||||
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
|
||||
}
|
||||
28
cmd/config/keychain_downgrade_other.go
Normal file
28
cmd/config/keychain_downgrade_other.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !darwin
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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 output.ErrValidation("keychain-downgrade is only supported on macOS")
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -4,9 +4,13 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -14,12 +18,49 @@ import (
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// enrichMissingScopeError preserves the original need_user_authorization
|
||||
// message and appends a scope hint when the current command declares the
|
||||
// required scopes locally.
|
||||
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
|
||||
// "current command requires scope(s): X, Y" hint when the underlying error is
|
||||
// a need_user_authorization signal AND the current command declares scopes
|
||||
// locally (via shortcut registration or service-method metadata).
|
||||
//
|
||||
// Stage-1: this typed path is dormant — no production code returns a typed
|
||||
// *errs.AuthenticationError. Kept so per-domain stage-2 migrations can plug
|
||||
// in without re-architecting. The active stage-1 path is
|
||||
// enrichMissingScopeError below, which operates on legacy *output.ExitError.
|
||||
func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
||||
if err == nil || f == nil {
|
||||
return
|
||||
}
|
||||
if !internalauth.IsNeedUserAuthorizationError(err) {
|
||||
return
|
||||
}
|
||||
var authErr *errs.AuthenticationError
|
||||
if !errors.As(err, &authErr) {
|
||||
return
|
||||
}
|
||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
||||
if len(scopes) == 0 {
|
||||
return
|
||||
}
|
||||
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
|
||||
if authErr.Hint == "" {
|
||||
authErr.Hint = scopeHint
|
||||
return
|
||||
}
|
||||
authErr.Hint += "\n" + scopeHint
|
||||
}
|
||||
|
||||
// enrichMissingScopeError appends a "current command requires scope(s): X"
|
||||
// hint to a legacy *output.ExitError when the underlying error carries the
|
||||
// need_user_authorization marker AND the current command declares scopes
|
||||
// locally. Matches pre-PR behaviour byte-for-byte; lives on the legacy
|
||||
// envelope path until per-domain stage-2 typed migration.
|
||||
//
|
||||
// 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
|
||||
@@ -27,12 +68,10 @@ func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
|
||||
return
|
||||
}
|
||||
|
||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
||||
if len(scopes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
|
||||
if exitErr.Detail.Hint == "" {
|
||||
exitErr.Detail.Hint = scopeHint
|
||||
|
||||
@@ -42,7 +42,7 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
|
||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
}
|
||||
return json.RawMessage(resp.RawBody), nil
|
||||
|
||||
@@ -36,6 +36,13 @@ import (
|
||||
// makeErr is called for every guarded dispatch; it must return a fresh
|
||||
// *output.ExitError each time (the envelope writer mutates a few fields
|
||||
// as it serialises).
|
||||
// Deprecated: installFatalGuard accepts a *output.ExitError-producing lambda,
|
||||
// which is part of the legacy error surface that predates the typed error
|
||||
// contract introduced by errs/. New code MUST NOT add new callers — the
|
||||
// platform-extension fatal-guard plumbing will switch to typed errs.* errors
|
||||
// when the platform-extension framework migrates. This wrapper is retained
|
||||
// only for the existing in-tree call sites; it will be removed once they
|
||||
// have moved to the typed surface.
|
||||
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
|
||||
// Two cobra subcommands are injected lazily at Execute() time and
|
||||
// would otherwise slip past walkGuard. We pre-register both so
|
||||
@@ -75,6 +82,12 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError)
|
||||
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
|
||||
// failure as a structured plugin_install envelope before any command
|
||||
// runs.
|
||||
// Deprecated: installPluginInstallErrorGuard produces a legacy
|
||||
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
|
||||
// such producers — plugin install failures should surface as a typed
|
||||
// *errs.XxxError once the platform-extension framework migrates. This
|
||||
// helper is retained only while existing call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
var pi *internalplatform.PluginInstallError
|
||||
@@ -116,6 +129,12 @@ func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
||||
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
|
||||
//
|
||||
// Either way the CLI must NOT silently continue with a broken policy.
|
||||
// Deprecated: installPluginConflictGuard produces a legacy *output.ExitError
|
||||
// via its internal makeErr lambda. New code MUST NOT add such producers —
|
||||
// plugin conflict failures should surface as a typed *errs.XxxError once the
|
||||
// platform-extension framework migrates. This helper is retained only while
|
||||
// existing call sites are migrated; it will be removed once they have moved
|
||||
// to the typed surface.
|
||||
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
envelopeType := "plugin_install"
|
||||
@@ -143,6 +162,12 @@ func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
||||
// failure as a plugin_lifecycle envelope. The reason_code splits
|
||||
// returned-error vs panic so consumers (audit / on-call) can tell the
|
||||
// two failure modes apart.
|
||||
// Deprecated: installPluginLifecycleErrorGuard produces a legacy
|
||||
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
|
||||
// such producers — plugin lifecycle failures should surface as a typed
|
||||
// *errs.XxxError once the platform-extension framework migrates. This
|
||||
// helper is retained only while existing call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
reasonCode := "lifecycle_failed"
|
||||
@@ -194,6 +219,13 @@ func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
//
|
||||
// This way the very first non-nil step in cobra's chain is always our
|
||||
// guard, regardless of which leaf the user invoked.
|
||||
// Deprecated: walkGuard accepts a *output.ExitError-producing lambda, part
|
||||
// of the legacy error surface that predates the typed error contract
|
||||
// introduced by errs/. New code MUST NOT add new callers — the platform-
|
||||
// extension guard plumbing will switch to typed errs.* errors when the
|
||||
// platform-extension framework migrates. This wrapper is retained only for
|
||||
// the existing in-tree call sites; it will be removed once they have moved
|
||||
// to the typed surface.
|
||||
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
|
||||
if cmd == nil {
|
||||
return
|
||||
|
||||
@@ -105,6 +105,10 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
||||
},
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
||||
// Legacy *output.ExitError producer: this literal predates the
|
||||
// typed error contract introduced by errs/. New denial sites MUST
|
||||
// NOT construct *output.ExitError directly — they should return a
|
||||
// typed *errs.XxxError once the cmdpolicy framework migrates.
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
|
||||
170
cmd/root.go
170
cmd/root.go
@@ -10,14 +10,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"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"
|
||||
@@ -200,22 +199,52 @@ func configureFlagCompletions(args []string) {
|
||||
|
||||
// handleRootError dispatches a command error to the appropriate handler
|
||||
// and returns the process exit code.
|
||||
//
|
||||
// Dispatch order:
|
||||
// 1. *errs.SecurityPolicyError: keeps the legacy custom envelope
|
||||
// (type=auth_error, string code, retryable, challenge_url) and exit 1.
|
||||
// Carve-out from the typed taxonomy — wire migration deferred to a later PR.
|
||||
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError):
|
||||
// render via the typed envelope writer, which lifts extension fields
|
||||
// (missing_scopes, console_url, ...) to the top level. Routed by
|
||||
// errs.CategoryOf via ExitCodeOf.
|
||||
// 3. *core.ConfigError + Legacy *output.ExitError: asExitError adapts them
|
||||
// to a legacy envelope; written via WriteErrorEnvelope. Stage-1 keeps
|
||||
// this path so existing wire shapes are preserved byte-for-byte until
|
||||
// per-domain typed migration in stage 2+.
|
||||
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
errOut := f.IOStreams.ErrOut
|
||||
|
||||
// SecurityPolicyError uses a custom envelope format (string codes, challenge_url, retryable)
|
||||
// that differs from the standard ErrDetail, so it's handled separately.
|
||||
var spErr *internalauth.SecurityPolicyError
|
||||
// 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
|
||||
}
|
||||
|
||||
// All other structured errors normalize to ExitError.
|
||||
// *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)
|
||||
|
||||
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
|
||||
return output.ExitCodeOf(err)
|
||||
}
|
||||
|
||||
if exitErr := asExitError(err); exitErr != nil {
|
||||
if !exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command) preserve the original API
|
||||
// error detail; skip enrichment which would clear it.
|
||||
// Raw errors (e.g. from `api` command via output.MarkRaw)
|
||||
// preserve the original API error detail; skip enrichment
|
||||
// which would clear it.
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
enrichPermissionError(f, exitErr)
|
||||
}
|
||||
@@ -223,35 +252,21 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return exitErr.Code
|
||||
}
|
||||
|
||||
// Cobra errors (required flags, unknown commands, etc.)
|
||||
fmt.Fprintln(errOut, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// asExitError converts known structured error types to *output.ExitError.
|
||||
// Returns nil for unrecognized errors (e.g. cobra flag errors).
|
||||
func asExitError(err error) *output.ExitError {
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeSecurityPolicyError writes the security-policy-specific JSON envelope to w.
|
||||
// This format intentionally differs from the standard ErrDetail envelope:
|
||||
// it uses string codes ("challenge_required"/"access_denied") and extra fields
|
||||
// (retryable, challenge_url) for machine-readable policy error handling.
|
||||
func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyError) {
|
||||
// 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.Code {
|
||||
case internalauth.LarkErrBlockByPolicyTryAuth:
|
||||
switch spErr.Subtype {
|
||||
case errs.SubtypeChallengeRequired:
|
||||
codeStr = "challenge_required"
|
||||
case internalauth.LarkErrBlockByPolicy:
|
||||
case errs.SubtypeAccessDenied:
|
||||
codeStr = "access_denied"
|
||||
default:
|
||||
codeStr = strconv.Itoa(spErr.Code)
|
||||
@@ -266,8 +281,8 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
|
||||
if spErr.ChallengeURL != "" {
|
||||
errData["challenge_url"] = spErr.ChallengeURL
|
||||
}
|
||||
if spErr.CLIHint != "" {
|
||||
errData["hint"] = spErr.CLIHint
|
||||
if spErr.Hint != "" {
|
||||
errData["hint"] = spErr.Hint
|
||||
}
|
||||
|
||||
env := map[string]interface{}{"ok": false, "error": errData}
|
||||
@@ -276,15 +291,29 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
|
||||
encoder := json.NewEncoder(buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
err := encoder.Encode(env)
|
||||
|
||||
if err != nil {
|
||||
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; removed after typed migration.
|
||||
func asExitError(err error) *output.ExitError {
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
||||
// group commands (no Run/RunE) with an unknown_subcommand error.
|
||||
//
|
||||
@@ -307,6 +336,13 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// add producers of this shape — unknown-subcommand signals should move to
|
||||
// a typed *errs.ValidationError (or a dedicated typed error) carrying the
|
||||
// agent-protocol metadata as typed extension fields. This helper is retained
|
||||
// only while existing dispatch sites are migrated; it will be removed once
|
||||
// they have moved to the typed surface.
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return cmd.Help()
|
||||
@@ -381,16 +417,22 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
})
|
||||
}
|
||||
|
||||
// enrichPermissionError adds console_url and improves the hint for permission errors.
|
||||
// It differentiates between:
|
||||
// - LarkErrAppScopeNotEnabled (99991672): app has not enabled the API scope → hint to admin console
|
||||
// - LarkErrUserScopeInsufficient (99991679): user has not authorized the scope → hint to auth login --scope
|
||||
// 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: 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 || exitErr.Detail.Type != "permission" {
|
||||
return
|
||||
}
|
||||
// Extract required scopes from API error detail
|
||||
scopes := extractRequiredScopes(exitErr.Detail.Detail)
|
||||
// Extract required scopes from API error detail (shared helper)
|
||||
scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
|
||||
if len(scopes) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -401,31 +443,18 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
}
|
||||
|
||||
// Select the recommended (least-privilege) scope
|
||||
scopeIfaces := make([]interface{}, len(scopes))
|
||||
for i, s := range scopes {
|
||||
scopeIfaces[i] = s
|
||||
}
|
||||
recommended := registry.SelectRecommendedScope(scopeIfaces, "tenant")
|
||||
if recommended == "" {
|
||||
recommended = scopes[0]
|
||||
}
|
||||
recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant")
|
||||
|
||||
// Build admin console URL with the recommended scope
|
||||
host := "open.feishu.cn"
|
||||
if cfg.Brand == "lark" {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
consoleURL := fmt.Sprintf("https://%s/page/scope-apply?clientID=%s&scopes=%s", host, url.QueryEscape(cfg.AppID), url.QueryEscape(recommended))
|
||||
consoleURL := registry.BuildConsoleScopeURL(cfg.Brand, cfg.AppID, recommended)
|
||||
|
||||
// Clear raw API detail — useful info is now in message/hint/console_url
|
||||
// Clear raw API detail — useful info is now in message/hint/console_url.
|
||||
exitErr.Detail.Detail = nil
|
||||
|
||||
isBot := f.ResolvedIdentity.IsBot()
|
||||
|
||||
larkCode := exitErr.Detail.Code
|
||||
switch larkCode {
|
||||
case output.LarkErrUserScopeInsufficient, output.LarkErrUserNotAuthorized:
|
||||
// User has not authorized the scope → re-authorize
|
||||
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)"
|
||||
@@ -435,13 +464,11 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
exitErr.Detail.ConsoleURL = consoleURL
|
||||
|
||||
case output.LarkErrAppScopeNotEnabled:
|
||||
// App has not enabled the API scope → admin console
|
||||
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:
|
||||
// Other permission errors (matched by keyword)
|
||||
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)"
|
||||
@@ -452,26 +479,3 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
exitErr.Detail.ConsoleURL = consoleURL
|
||||
}
|
||||
}
|
||||
|
||||
// extractRequiredScopes extracts scope names from the API error's permission_violations field.
|
||||
func extractRequiredScopes(detail interface{}) []string {
|
||||
m, ok := detail.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
violations, ok := m["permission_violations"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var scopes []string
|
||||
for _, v := range violations {
|
||||
vm, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if subject, ok := vm["subject"].(string); ok {
|
||||
scopes = append(scopes, subject)
|
||||
}
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
@@ -161,160 +161,8 @@ func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
|
||||
stderr.Reset()
|
||||
}
|
||||
|
||||
// --- api command ---
|
||||
|
||||
func TestIntegration_Api_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
|
||||
"--params", `{"receive_id_type":"chat_id"}`,
|
||||
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
|
||||
})
|
||||
|
||||
// api uses MarkRaw: detail preserved, no enrichment
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_Api_PermissionError_NotEnriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "GET", "/open-apis/test/perm",
|
||||
})
|
||||
|
||||
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "Permission denied [99991672]",
|
||||
Hint: "check app permissions or re-authorize: lark-cli auth login",
|
||||
Detail: map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- service command ---
|
||||
|
||||
func TestIntegration_Service_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_fake",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99992356,
|
||||
"msg": "id not exist",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw, non-permission error — detail preserved
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 99992356,
|
||||
Message: "API error: [99992356] id not exist",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_Service_PermissionError_Enriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:chat:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildIntegrationRootCmd(t, f)
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
|
||||
Hint: "enable the scope in developer console (see console_url)",
|
||||
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
@@ -524,7 +372,7 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
||||
})
|
||||
|
||||
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
|
||||
// shortcut: typed error via DoAPIJSON path
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
@@ -536,11 +384,8 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
|
||||
// produces no skills key in the composed notice. Users who installed
|
||||
// skills via `npx skills add` (no stamp) must not see the misleading
|
||||
// "not installed" notice — only `lark-cli update` users opt into the
|
||||
// drift tracker.
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that missing state
|
||||
// produces no skills key in the composed notice.
|
||||
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
@@ -571,13 +416,13 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_InSync verifies that a matching stamp produces no
|
||||
// TestSetupNotices_InSync verifies that matching state produces no
|
||||
// skills key in the composed notice.
|
||||
func TestSetupNotices_InSync(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -604,13 +449,13 @@ func TestSetupNotices_InSync(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_Drift verifies a mismatching stamp produces the
|
||||
// TestSetupNotices_Drift verifies mismatching state produces the
|
||||
// drift message with both current and target populated.
|
||||
func TestSetupNotices_Drift(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -659,7 +504,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
499
cmd/root_test.go
499
cmd/root_test.go
@@ -4,19 +4,23 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
cmdconfig "github.com/larksuite/cli/cmd/config"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/errs"
|
||||
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"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
|
||||
@@ -68,273 +72,6 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRootError_RawError_SkipsEnrichmentButWritesEnvelope(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Create a permission error (would normally be enriched) and mark it Raw
|
||||
err := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "API error: [99991672] scope not enabled", map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
})
|
||||
err.Raw = true
|
||||
|
||||
code := handleRootError(f, err)
|
||||
if code != output.ExitAPI {
|
||||
t.Errorf("expected exit code %d, got %d", output.ExitAPI, code)
|
||||
}
|
||||
// stderr should contain the error envelope
|
||||
if stderr.Len() == 0 {
|
||||
t.Error("expected non-empty stderr for Raw error — WriteErrorEnvelope should always run")
|
||||
}
|
||||
// The message should NOT have been enriched by enrichPermissionError
|
||||
// (ErrAPI sets "Permission denied [code]" but enrichment would replace it with "App scope not enabled: ...")
|
||||
if strings.Contains(err.Error(), "App scope not enabled") {
|
||||
t.Errorf("expected message not enriched, got: %s", err.Error())
|
||||
}
|
||||
// Detail.Detail should be preserved (enrichPermissionError clears it to nil)
|
||||
if err.Detail != nil && err.Detail.Detail == nil {
|
||||
t.Error("expected Detail.Detail to be preserved, but it was cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRootError_NonRawError_EnrichesAndWritesEnvelope(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
// Create a permission error without Raw — should be enriched
|
||||
err := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "API error: [99991672] scope not enabled", map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
})
|
||||
|
||||
code := handleRootError(f, err)
|
||||
if code != output.ExitAPI {
|
||||
t.Errorf("expected exit code %d, got %d", output.ExitAPI, code)
|
||||
}
|
||||
// stderr should contain the error envelope
|
||||
if stderr.Len() == 0 {
|
||||
t.Error("expected non-empty stderr for non-Raw error")
|
||||
}
|
||||
// The message should have been enriched
|
||||
if !strings.Contains(err.Error(), "App scope not enabled") {
|
||||
t.Errorf("expected enriched message, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appID string
|
||||
scope string
|
||||
wantInURL string // substring that must appear in console_url
|
||||
denyInURL string // substring that must NOT appear raw in console_url
|
||||
}{
|
||||
{
|
||||
name: "ampersand in scope",
|
||||
appID: "cli_good",
|
||||
scope: "scope&evil=injected",
|
||||
wantInURL: "scopes=scope%26evil%3Dinjected",
|
||||
denyInURL: "scopes=scope&evil=injected",
|
||||
},
|
||||
{
|
||||
name: "hash in scope",
|
||||
appID: "cli_good",
|
||||
scope: "scope#fragment",
|
||||
wantInURL: "scopes=scope%23fragment",
|
||||
denyInURL: "scopes=scope#fragment",
|
||||
},
|
||||
{
|
||||
name: "space in scope",
|
||||
appID: "cli_good",
|
||||
scope: "scope with spaces",
|
||||
wantInURL: "scopes=scope+with+spaces",
|
||||
},
|
||||
{
|
||||
name: "special chars in appID",
|
||||
appID: "app&id=bad",
|
||||
scope: "calendar:calendar:readonly",
|
||||
wantInURL: "clientID=app%26id%3Dbad",
|
||||
denyInURL: "clientID=app&id=bad",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: tt.appID, AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
exitErr := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "scope not enabled", map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": tt.scope},
|
||||
},
|
||||
})
|
||||
|
||||
handleRootError(f, exitErr)
|
||||
|
||||
consoleURL := exitErr.Detail.ConsoleURL
|
||||
if consoleURL == "" {
|
||||
t.Fatal("expected console_url to be set")
|
||||
}
|
||||
if !strings.Contains(consoleURL, tt.wantInURL) {
|
||||
t.Errorf("console_url missing expected escaped value\n want substring: %s\n got url: %s", tt.wantInURL, consoleURL)
|
||||
}
|
||||
if tt.denyInURL != "" && strings.Contains(consoleURL, tt.denyInURL) {
|
||||
t.Errorf("console_url contains unescaped dangerous value\n deny substring: %s\n got url: %s", tt.denyInURL, consoleURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
var target registry.CommandEntry
|
||||
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
||||
if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" {
|
||||
target = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if target.Command == "" {
|
||||
t.Fatal("failed to locate a calendar create command in local registry metadata")
|
||||
}
|
||||
parts := strings.Split(target.Command, " ")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("expected resource/method command, got %q", target.Command)
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "calendar"}
|
||||
resourceCmd := &cobra.Command{Use: parts[0]}
|
||||
methodCmd := &cobra.Command{Use: parts[1]}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(resourceCmd)
|
||||
resourceCmd.AddCommand(methodCmd)
|
||||
f.CurrentCommand = methodCmd
|
||||
|
||||
exitErr := output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected exit code %d, got %d", output.ExitAPI, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
||||
t.Fatalf("expected api_error detail, got %+v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
|
||||
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): calendar:calendar.event:create") {
|
||||
t.Fatalf("expected scope guidance in hint, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
|
||||
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Detail != nil {
|
||||
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("expected exit code %d, got %d", output.ExitNetwork, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||
t.Fatalf("expected network detail, got %+v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
|
||||
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): docx:document:create") {
|
||||
t.Fatalf("expected shortcut scope hint, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
|
||||
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Detail != nil {
|
||||
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ShortcutIncludesConditionalScopes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "drive"}
|
||||
shortcutCmd := &cobra.Command{Use: "+status"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected error detail")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
|
||||
t.Fatalf("expected conditional scope hint for drive +status, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
exitErr.Detail.Hint = "existing hint"
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
|
||||
if exitErr.Detail.Hint != want {
|
||||
t.Fatalf("expected appended hint %q, got %q", want, exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
||||
@@ -396,3 +133,227 @@ func TestIsCompletionCommand(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteConfigError_* lives with the implementation in
|
||||
// internal/errcompat/promote_test.go.
|
||||
|
||||
// TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope pins the carve-out
|
||||
// for *errs.SecurityPolicyError: it does NOT go through the typed envelope
|
||||
// writer. Downstream OAuth/policy consumers parse a wire format that
|
||||
// predates the typed taxonomy and depend on:
|
||||
// - error.type == "auth_error" (not the Category literal "policy")
|
||||
// - error.code is a string ("challenge_required" / "access_denied"), not a number
|
||||
// - error.retryable is present at the top of the error object
|
||||
// - exit code 1 (not ExitContentSafety 6)
|
||||
//
|
||||
// Migration of this category to the typed envelope is deferred to a later PR.
|
||||
func TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cases := []struct {
|
||||
name string
|
||||
subtype errs.Subtype
|
||||
code int
|
||||
wantCode string
|
||||
}{
|
||||
{"challenge_required", errs.SubtypeChallengeRequired, 21000, "challenge_required"},
|
||||
{"access_denied", errs.SubtypeAccessDenied, 21001, "access_denied"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
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 != 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 != "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
|
||||
// contains the need_user_authorization marker — the same shape that
|
||||
// resolveAccessToken now produces when the credential chain returns
|
||||
// *internalauth.NeedAuthorizationError.
|
||||
func newAuthErrorWithNeedAuthMarker() *errs.AuthenticationError {
|
||||
cause := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
|
||||
return &errs.AuthenticationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthentication,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Message: fmt.Sprintf("API call failed: %s", cause),
|
||||
},
|
||||
Cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// service method.
|
||||
func TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
var target registry.CommandEntry
|
||||
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
|
||||
if len(entry.Scopes) == 1 && entry.Scopes[0] == "calendar:calendar.event:create" {
|
||||
target = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if target.Command == "" {
|
||||
t.Fatal("failed to locate a calendar create command in local registry metadata")
|
||||
}
|
||||
parts := strings.Split(target.Command, " ")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("expected resource/method command, got %q", target.Command)
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "calendar"}
|
||||
resourceCmd := &cobra.Command{Use: parts[0]}
|
||||
methodCmd := &cobra.Command{Use: parts[1]}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(resourceCmd)
|
||||
resourceCmd.AddCommand(methodCmd)
|
||||
f.CurrentCommand = methodCmd
|
||||
|
||||
authErr := newAuthErrorWithNeedAuthMarker()
|
||||
applyNeedAuthorizationHint(f, authErr)
|
||||
|
||||
if authErr.Category != errs.CategoryAuthentication {
|
||||
t.Errorf("Category = %q, want authentication", authErr.Category)
|
||||
}
|
||||
if !strings.Contains(authErr.Message, "need_user_authorization") {
|
||||
t.Errorf("Message should preserve need_user_authorization marker; got %q", authErr.Message)
|
||||
}
|
||||
if !strings.Contains(authErr.Hint, "current command requires scope(s): calendar:calendar.event:create") {
|
||||
t.Errorf("expected declared-scope hint, got %q", authErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT pins the
|
||||
// same hint behavior for mounted shortcut commands.
|
||||
func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
authErr := newAuthErrorWithNeedAuthMarker()
|
||||
applyNeedAuthorizationHint(f, authErr)
|
||||
|
||||
if !strings.Contains(authErr.Hint, "current command requires scope(s): docx:document:create") {
|
||||
t.Errorf("expected shortcut scope hint, got %q", authErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes pins that
|
||||
// conditional scopes declared on a shortcut surface in the hint.
|
||||
func TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "drive"}
|
||||
shortcutCmd := &cobra.Command{Use: "+status"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
authErr := newAuthErrorWithNeedAuthMarker()
|
||||
applyNeedAuthorizationHint(f, authErr)
|
||||
|
||||
if !strings.Contains(authErr.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
|
||||
t.Errorf("expected conditional scope hint for drive +status, got %q", authErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyNeedAuthorizationHint_AppendsExistingHint pins that the
|
||||
// declared-scopes guidance is appended (separated by newline) when the typed
|
||||
// AuthenticationError already carries a Hint from elsewhere.
|
||||
func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "docs"}
|
||||
shortcutCmd := &cobra.Command{Use: "+create"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
authErr := newAuthErrorWithNeedAuthMarker()
|
||||
authErr.Hint = "existing hint"
|
||||
applyNeedAuthorizationHint(f, authErr)
|
||||
|
||||
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
|
||||
if authErr.Hint != want {
|
||||
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -24,7 +25,8 @@ type SchemaOptions struct {
|
||||
Ctx context.Context
|
||||
|
||||
// Positional args
|
||||
Path string
|
||||
Path string // first positional, when only one is given
|
||||
ExtraArgs []string // 2nd+ positional args (space-separated form)
|
||||
|
||||
// Flags
|
||||
Format string
|
||||
@@ -359,13 +361,16 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
opts := &SchemaOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema [path]",
|
||||
Use: "schema [path | service resource method]",
|
||||
Short: "View API method parameters, types, and scopes",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Args: cobra.MaximumNArgs(8),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
opts.Path = args[0]
|
||||
}
|
||||
if len(args) > 1 {
|
||||
opts.ExtraArgs = args[1:]
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -380,60 +385,108 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath provides tab-completion for the schema path argument.
|
||||
// It handles dotted resource names (e.g. app.table.fields) by iterating all
|
||||
// resources and classifying each as a prefix-match or fully-matched.
|
||||
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
|
||||
// newer space-separated form (e.g. `schema im messages reply`).
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
|
||||
parts := strings.Split(toComplete, ".")
|
||||
|
||||
// Level 1: complete service names
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
// Case 1: legacy "single dotted arg" path — no previous args yet
|
||||
if len(args) == 0 {
|
||||
parts := strings.Split(toComplete, ".")
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
}
|
||||
|
||||
serviceName := parts[0]
|
||||
// Case 2: space-form, args already has segments
|
||||
// Walk down service -> resource(s) -> method based on existing args
|
||||
serviceName := args[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
// args[1:] are resource path segments (possibly partial); current
|
||||
// toComplete is the next segment under cursor.
|
||||
consumed := args[1:]
|
||||
resource, _, remaining := findResourceByPath(resources, consumed)
|
||||
if resource == nil {
|
||||
// Suggest top-level resource names that match toComplete
|
||||
var completions []string
|
||||
for resName := range resources {
|
||||
if strings.HasPrefix(resName, toComplete) {
|
||||
completions = append(completions, resName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
// Already typed past the resource — suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
// Resource matched exactly, suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,94 +522,231 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
out := opts.Factory.IOStreams.Out
|
||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||
|
||||
if opts.Path == "" {
|
||||
printServices(out)
|
||||
return nil
|
||||
// args may have arrived as a single string (legacy single-arg path) or
|
||||
// split into multiple — normalize to a single args slice.
|
||||
var rawArgs []string
|
||||
if opts.Path != "" {
|
||||
rawArgs = []string{opts.Path}
|
||||
}
|
||||
|
||||
parts := strings.Split(opts.Path, ".")
|
||||
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", serviceName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
if opts.Format == "pretty" {
|
||||
printResourceList(out, spec, mode)
|
||||
if len(opts.ExtraArgs) > 0 {
|
||||
if opts.Path != "" {
|
||||
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
|
||||
} else {
|
||||
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
|
||||
rawArgs = append([]string(nil), opts.ExtraArgs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
parts := schema.ParsePath(rawArgs)
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
return runPrettyMode(out, parts, mode)
|
||||
}
|
||||
return runJSONMode(out, parts, mode)
|
||||
}
|
||||
|
||||
// runJSONMode dispatches list/single envelope output based on parts.
|
||||
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
|
||||
// output is deterministic across machines.
|
||||
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
filter := strictModeFilter(mode)
|
||||
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
envs := schema.AssembleAll(filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
case 1:
|
||||
spec := registry.EmbeddedSpec(parts[0])
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(parts[0])
|
||||
}
|
||||
envs := schema.AssembleService(parts[0], spec, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
default:
|
||||
return runJSONForPath(out, parts, filter)
|
||||
}
|
||||
}
|
||||
|
||||
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
|
||||
// to single-method match. Uses embedded data only.
|
||||
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
|
||||
serviceName := parts[0]
|
||||
spec := registry.EmbeddedSpec(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(serviceName)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var resNames []string
|
||||
var names []string
|
||||
for k := range resources {
|
||||
resNames = append(resNames, k)
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(resNames, ", ")))
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
|
||||
if len(remaining) == 0 {
|
||||
if opts.Format == "pretty" {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<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)
|
||||
}
|
||||
}
|
||||
// Resource-scoped envelope array
|
||||
envs := assembleResource(serviceName, resName, resource, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var names []string
|
||||
for k := range methods {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
// Method exists but caller appended extra segments — reject so they
|
||||
// don't silently get this method's schema when they typo'd the path.
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
// Method exists in spec but filtered out by strict mode
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
|
||||
"Use --as user / --as bot to switch")
|
||||
}
|
||||
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
|
||||
output.PrintJson(out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
resourcePath := []string{resName}
|
||||
var envs []schema.Envelope
|
||||
for methodName, raw := range methods {
|
||||
method, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
continue
|
||||
}
|
||||
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
|
||||
}
|
||||
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
|
||||
return envs
|
||||
}
|
||||
|
||||
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
|
||||
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
|
||||
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
if len(parts) == 0 {
|
||||
printServices(out)
|
||||
return nil
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownService(serviceName)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
printResourceList(out, spec, mode)
|
||||
return nil
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var names []string
|
||||
for k := range resources {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<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 mNames []string
|
||||
var names []string
|
||||
for k := range methods {
|
||||
mNames = append(mNames, k)
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(mNames, ", ")))
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
printMethodDetail(out, spec, resName, methodName, method)
|
||||
} else {
|
||||
output.PrintJson(out, method)
|
||||
if len(remaining) > 1 {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
printMethodDetail(out, spec, resName, methodName, method)
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
|
||||
// nil if strict mode is not active.
|
||||
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
|
||||
if !mode.IsActive() {
|
||||
return nil
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
return func(method map[string]interface{}) bool {
|
||||
tokens, _ := method["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
return true // permissive when meta_data lacks accessTokens
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if s, _ := t.(string); s == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func errUnknownService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||
}
|
||||
|
||||
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
|
||||
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
|
||||
// overlay-only services would mislead callers when those services subsequently
|
||||
// fail to resolve in envelope output.
|
||||
func errUnknownEmbeddedService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
|
||||
}
|
||||
|
||||
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
|
||||
// filtered by strict mode. Returns the original spec when strict mode is off.
|
||||
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
|
||||
@@ -5,6 +5,7 @@ package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -33,17 +34,165 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoArgs(t *testing.T) {
|
||||
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
cmd.SetArgs([]string{"--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Available services") {
|
||||
t.Error("expected service list output")
|
||||
t.Error("expected service list in pretty mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{}) // default --format json
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
if !strings.HasPrefix(out, "[") {
|
||||
head := out
|
||||
if len(head) > 80 {
|
||||
head = head[:80]
|
||||
}
|
||||
t.Errorf("expected JSON array root, first 80 chars:\n%s", head)
|
||||
}
|
||||
var envs []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &envs); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
if len(envs) < 193 {
|
||||
t.Errorf("envelopes count = %d, want >= 193", len(envs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env["name"] != "im images create" {
|
||||
t.Errorf("name = %v, want \"im images create\"", env["name"])
|
||||
}
|
||||
for _, key := range []string{"description", "inputSchema", "outputSchema", "_meta"} {
|
||||
if _, ok := env[key]; !ok {
|
||||
t.Errorf("missing top-level key: %s", key)
|
||||
}
|
||||
}
|
||||
meta, _ := env["_meta"].(map[string]interface{})
|
||||
if meta["envelope_version"] != "1.0" {
|
||||
t.Errorf("envelope_version = %v, want \"1.0\"", meta["envelope_version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_SpaceSeparatedPath_EqualsDotted(t *testing.T) {
|
||||
f1, out1, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd1 := NewCmdSchema(f1, nil)
|
||||
cmd1.SetArgs([]string{"im", "images", "create"})
|
||||
if err := cmd1.Execute(); err != nil {
|
||||
t.Fatalf("space form failed: %v", err)
|
||||
}
|
||||
|
||||
f2, out2, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd2 := NewCmdSchema(f2, nil)
|
||||
cmd2.SetArgs([]string{"im.images.create"})
|
||||
if err := cmd2.Execute(); err != nil {
|
||||
t.Fatalf("dotted form failed: %v", err)
|
||||
}
|
||||
|
||||
if out1.String() != out2.String() {
|
||||
t.Errorf("space and dotted forms produced different output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_ServiceListIsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var envs []map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envs); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(envs) == 0 {
|
||||
t.Fatal("expected non-empty array for service im")
|
||||
}
|
||||
for _, e := range envs {
|
||||
name, _ := e["name"].(string)
|
||||
if !strings.HasPrefix(name, "im ") {
|
||||
t.Errorf("envelope name %q does not start with \"im \"", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_HighRiskYesInjection(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.messages.delete"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
is, _ := env["inputSchema"].(map[string]interface{})
|
||||
props, _ := is["properties"].(map[string]interface{})
|
||||
if _, ok := props["yes"]; !ok {
|
||||
t.Errorf("inputSchema.properties.yes missing for high-risk-write command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.reactions.list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
is, _ := env["inputSchema"].(map[string]interface{})
|
||||
props, _ := is["properties"].(map[string]interface{})
|
||||
if _, ok := props["yes"]; ok {
|
||||
t.Errorf("yes property should not appear for risk=read command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Existing pretty rendering surfaces these markers — they must still appear
|
||||
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("pretty output missing marker %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -271,6 +271,11 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
}
|
||||
|
||||
// Stage 1: enrich the 99991679 (LarkErrUserScopeInsufficient) response
|
||||
// with a per-method recommended `--scope` hint, matching the pre-PR
|
||||
// behaviour. Per-domain typed migration in stage 2+ will lift this
|
||||
// into PermissionError.MissingScopes / ConsoleURL on the typed
|
||||
// envelope; until then the legacy ExitError envelope is preserved.
|
||||
checkErr := scopeAwareChecker(scopes, opts.As.IsBot())
|
||||
|
||||
if opts.PageAll {
|
||||
@@ -280,7 +285,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
|
||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("API call failed: %s", err)
|
||||
return err
|
||||
}
|
||||
return client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
@@ -290,10 +295,56 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
CheckError: checkErr,
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -339,7 +390,7 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
recommended := registry.SelectRecommendedScope(scopes, "user")
|
||||
return output.ErrWithHint(output.ExitAPI, "permission",
|
||||
fmt.Sprintf("insufficient permissions (required scope: %s)", recommended),
|
||||
fmt.Sprintf(`run `+"`"+`lark-cli auth login --scope "%s"`+"`"+` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.`, recommended))
|
||||
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.
|
||||
@@ -474,36 +525,10 @@ func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *cor
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
// scopeAwareChecker returns an error checker that enriches scope-related errors with login hints.
|
||||
func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) error {
|
||||
return func(result interface{}) 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)
|
||||
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))
|
||||
}
|
||||
|
||||
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
|
||||
if pagOpts.Identity == "" {
|
||||
pagOpts.Identity = request.As
|
||||
}
|
||||
}
|
||||
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
|
||||
@@ -516,9 +541,9 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
pf.FormatPage(items)
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("API call failed: %s", err)
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result); apiErr != nil {
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
if !hasItems {
|
||||
@@ -529,9 +554,9 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
default:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("API call failed: %s", err)
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result); apiErr != nil {
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
output.FormatValue(out, result, format)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -412,39 +411,6 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_BotMode_APIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-err", AppSecret: "test-secret-err", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{"code": 40003, "msg": "invalid token"},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected API error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !isExitError(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got: %T %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("expected ExitAPI code, got %d", exitErr.Code)
|
||||
}
|
||||
// stdout must be empty on API error — error details belong in stderr envelope only.
|
||||
// This guards against re-introducing duplicate output (see commit 86215a10).
|
||||
if stdout.Len() > 0 {
|
||||
t.Errorf("expected no stdout on API error, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
|
||||
@@ -662,73 +628,6 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── scopeAwareChecker ──
|
||||
|
||||
func TestScopeAwareChecker_Success(t *testing.T) {
|
||||
checker := scopeAwareChecker(nil, false)
|
||||
err := checker(map[string]interface{}{"code": 0.0, "msg": "ok"})
|
||||
if err != nil {
|
||||
t.Errorf("expected nil error for code=0, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeAwareChecker_NonMapResult(t *testing.T) {
|
||||
checker := scopeAwareChecker(nil, false)
|
||||
err := checker("not a map")
|
||||
if err != nil {
|
||||
t.Errorf("expected nil for non-map result, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeAwareChecker_APIError(t *testing.T) {
|
||||
checker := scopeAwareChecker(nil, false)
|
||||
err := checker(map[string]interface{}{"code": 40003.0, "msg": "bad request"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "API error: [40003]") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeAwareChecker_ScopeError_UserMode(t *testing.T) {
|
||||
scopes := []interface{}{"calendar:read"}
|
||||
checker := scopeAwareChecker(scopes, false)
|
||||
err := checker(map[string]interface{}{
|
||||
"code": float64(output.LarkErrUserScopeInsufficient),
|
||||
"msg": "scope insufficient",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected permission error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !isExitError(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "permission" {
|
||||
t.Errorf("expected type=permission, got %s", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "auth login") {
|
||||
t.Errorf("expected auth login hint, got %s", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeAwareChecker_ScopeError_BotMode(t *testing.T) {
|
||||
scopes := []interface{}{"calendar:read"}
|
||||
checker := scopeAwareChecker(scopes, true)
|
||||
err := checker(map[string]interface{}{
|
||||
"code": float64(output.LarkErrUserScopeInsufficient),
|
||||
"msg": "scope insufficient",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected permission error")
|
||||
}
|
||||
// Bot mode should still include the scope hint
|
||||
if !strings.Contains(err.Error(), "insufficient permissions") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── file upload ──
|
||||
|
||||
func imImageMethod() map[string]interface{} {
|
||||
@@ -866,13 +765,3 @@ func TestDetectFileFields(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
func isExitError(err error, target **output.ExitError) bool {
|
||||
ee, ok := err.(*output.ExitError)
|
||||
if ok && target != nil {
|
||||
*target = ee
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -31,15 +31,18 @@ var (
|
||||
currentVersion = func() string { return build.Version }
|
||||
currentOS = runtime.GOOS
|
||||
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
|
||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { return skillscheck.SyncSkills(opts) }
|
||||
)
|
||||
|
||||
func isWindows() bool { return currentOS == osWindows }
|
||||
|
||||
// normalizeVersion canonicalizes a version string for stamp comparison.
|
||||
// normalizeVersion canonicalizes a version string for state comparison.
|
||||
// Strips a leading "v" so versions written from Makefile (git describe →
|
||||
// "v1.0.0") and npm (no prefix → "1.0.0") compare equal.
|
||||
func normalizeVersion(s string) string {
|
||||
return strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "v")
|
||||
return strings.TrimPrefix(s, "V")
|
||||
}
|
||||
|
||||
func releaseURL(version string) string {
|
||||
@@ -121,7 +124,9 @@ func updateRun(opts *UpdateOptions) error {
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
updater.CleanupStaleFiles()
|
||||
if !opts.Check {
|
||||
updater.CleanupStaleFiles()
|
||||
}
|
||||
output.PendingNotice = nil
|
||||
|
||||
// 1. Fetch latest version
|
||||
@@ -137,13 +142,9 @@ func updateRun(opts *UpdateOptions) error {
|
||||
|
||||
// 3. Compare versions
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
// Run skills sync before returning — covers the case where the
|
||||
// binary is already current but skills were never synced.
|
||||
// Stamp dedup makes this a no-op if skills are already in sync.
|
||||
// Skip side-effects under --check (pure report path per spec §3.6).
|
||||
var skillsResult *selfupdate.NpmResult
|
||||
var skillsResult *skillscheck.SyncResult
|
||||
if !opts.Check {
|
||||
skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
|
||||
}
|
||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
@@ -185,16 +186,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
}
|
||||
// skills_status: pure report, no side effect, no stamp write.
|
||||
// ReadStamp errors are silently swallowed — if we can't read the
|
||||
// stamp we just omit the block rather than fail the --check.
|
||||
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||
out["skills_status"] = map[string]interface{}{
|
||||
"current": stamp,
|
||||
"target": cur,
|
||||
"in_sync": stamp == cur,
|
||||
}
|
||||
}
|
||||
applySkillsStatus(out, cur)
|
||||
output.PrintJson(io.Out, out)
|
||||
return nil
|
||||
}
|
||||
@@ -210,7 +202,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
|
||||
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
@@ -288,10 +280,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
// Skills update (best-effort) — uses runSkillsAndStamp so the
|
||||
// stamp gets persisted on success and dedup applies if a previous
|
||||
// run already stamped this version.
|
||||
skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force)
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
@@ -328,27 +317,21 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
|
||||
// stamp on success. Skips the npx invocation when the stamp already
|
||||
// matches stampVersion (unless force is true). The stamp write failure
|
||||
// emits a warning to io.ErrOut but does NOT fail the update command —
|
||||
// best-effort. ReadStamp errors are swallowed (fail-closed: treated as
|
||||
// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp
|
||||
// dedup; otherwise returns the underlying *NpmResult with Err semantics
|
||||
// from RunSkillsUpdate.
|
||||
func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult {
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
|
||||
if !force {
|
||||
if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) {
|
||||
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
r := updater.RunSkillsUpdate()
|
||||
if r.Err == nil {
|
||||
if err := skillscheck.WriteStamp(stampVersion); err != nil {
|
||||
fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err)
|
||||
}
|
||||
result := syncSkills(skillscheck.SyncOptions{
|
||||
Version: stateVersion,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
})
|
||||
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
|
||||
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
|
||||
}
|
||||
return r
|
||||
return result
|
||||
}
|
||||
|
||||
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
||||
@@ -356,7 +339,7 @@ func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stamp
|
||||
// fields derived from skillsResult. When check is true, this is the pure
|
||||
// report path (spec §3.6): no side-effects, JSON envelope uses
|
||||
// skills_status (spec §4.2) instead of skills_action.
|
||||
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error {
|
||||
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error {
|
||||
if opts.JSON {
|
||||
out := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
@@ -364,16 +347,7 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
|
||||
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
|
||||
}
|
||||
if check {
|
||||
// Pure report — read stamp directly, emit skills_status block.
|
||||
// ReadStamp errors are silently swallowed — if we can't read
|
||||
// the stamp we just omit the block rather than fail the --check.
|
||||
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||
out["skills_status"] = map[string]interface{}{
|
||||
"current": stamp,
|
||||
"target": cur,
|
||||
"in_sync": stamp == cur,
|
||||
}
|
||||
}
|
||||
applySkillsStatus(out, cur)
|
||||
} else {
|
||||
applySkillsResult(out, skillsResult)
|
||||
}
|
||||
@@ -387,36 +361,70 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySkillsResult mutates the JSON envelope to include skills_action
|
||||
// (and skills_warning when failed). nil result = "in_sync" (dedup hit).
|
||||
func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) {
|
||||
func applySkillsStatus(env map[string]interface{}, target string) {
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable || state.Version == "" {
|
||||
return
|
||||
}
|
||||
status := map[string]interface{}{
|
||||
"current": state.Version,
|
||||
"target": target,
|
||||
"in_sync": normalizeVersion(state.Version) == normalizeVersion(target),
|
||||
}
|
||||
if len(state.OfficialSkills) > 0 {
|
||||
status["official"] = len(state.OfficialSkills)
|
||||
}
|
||||
if len(state.UpdatedSkills) > 0 {
|
||||
status["updated"] = len(state.UpdatedSkills)
|
||||
}
|
||||
if len(state.SkippedDeletedSkills) > 0 {
|
||||
status["skipped_deleted"] = state.SkippedDeletedSkills
|
||||
}
|
||||
env["skills_status"] = status
|
||||
}
|
||||
|
||||
func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
|
||||
switch {
|
||||
case r == nil:
|
||||
env["skills_action"] = "in_sync"
|
||||
case r.Err != nil:
|
||||
env["skills_action"] = "failed"
|
||||
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
|
||||
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||
env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
|
||||
}
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
default:
|
||||
env["skills_action"] = "synced"
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
}
|
||||
}
|
||||
|
||||
// emitSkillsTextHints prints human-readable feedback about the skills
|
||||
// sync result for non-JSON output.
|
||||
func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) {
|
||||
func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
|
||||
summary := map[string]interface{}{
|
||||
"official": len(r.Official),
|
||||
"updated": len(r.Updated),
|
||||
"added": len(r.Added),
|
||||
"skipped_deleted": len(r.SkippedDeleted),
|
||||
}
|
||||
if len(r.Failed) > 0 {
|
||||
summary["failed"] = r.Failed
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
|
||||
switch {
|
||||
case r == nil:
|
||||
// dedup hit — silent (already up to date)
|
||||
case r.Err != nil:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err)
|
||||
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail))
|
||||
if len(r.Failed) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
|
||||
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
|
||||
case r.Force:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
|
||||
default:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
|
||||
if len(r.SkippedDeleted) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ package cmdupdate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -28,7 +29,6 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
|
||||
}
|
||||
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
|
||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
@@ -41,22 +41,53 @@ func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
}
|
||||
|
||||
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
|
||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
|
||||
npmFn func(string) *selfupdate.NpmResult,
|
||||
skillsFn func() *selfupdate.NpmResult) {
|
||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(string) *selfupdate.NpmResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.NpmInstallOverride = npmFn
|
||||
u.SkillsUpdateOverride = skillsFn
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
return func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
switch strings.Join(args, " ") {
|
||||
case "-y skills add https://open.feishu.cn --list":
|
||||
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
|
||||
case "-y skills ls -g":
|
||||
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
|
||||
default:
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: "1.2.3", want: "1.2.3"},
|
||||
{input: "v1.2.3", want: "1.2.3"},
|
||||
{input: "V1.2.3", want: "1.2.3"},
|
||||
{input: " v1.2.3 ", want: "1.2.3"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
if got := normalizeVersion(tt.input); got != tt.want {
|
||||
t.Fatalf("normalizeVersion(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
|
||||
@@ -168,9 +199,7 @@ func TestUpdateManual_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateNpm_JSON(t *testing.T) {
|
||||
// Isolate config dir: this test mocks fetchLatest="2.0.0" and lets
|
||||
// runSkillsAndStamp → WriteStamp succeed, which without isolation would
|
||||
// clobber the real ~/.lark-cli/skills.stamp with "2.0.0".
|
||||
// Isolate config dir because skills sync writes skills-state.json.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -186,7 +215,6 @@ func TestUpdateNpm_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -216,7 +244,6 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -230,7 +257,7 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -246,7 +273,6 @@ func TestUpdateForce_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -323,7 +349,7 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -339,7 +365,6 @@ func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -451,8 +476,8 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||
u.RestoreAvailableOverride = func() bool { return false }
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
t.Fatal("skills update should not run when binary verification fails")
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
t.Fatal("skills sync should not run when binary verification fails")
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
@@ -649,7 +674,7 @@ func TestPermissionHint(t *testing.T) {
|
||||
|
||||
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
// With the rename trick, Windows npm installs can now auto-update.
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -668,7 +693,6 @@ func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -750,7 +774,6 @@ func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -785,8 +808,7 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
// Skills update fails
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
@@ -812,8 +834,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
if !strings.Contains(out, "skills_warning") {
|
||||
t.Errorf("expected skills_warning in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "skills_detail") {
|
||||
t.Errorf("expected skills_detail in output, got: %s", out)
|
||||
if !strings.Contains(out, "skills_summary") {
|
||||
t.Errorf("expected skills_summary in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,7 +860,7 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
@@ -861,100 +883,96 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
if !strings.Contains(out, "Skills update failed") {
|
||||
t.Errorf("expected skills failure warning, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "npx -y skills add") {
|
||||
t.Errorf("expected manual skills command hint, got: %s", out)
|
||||
if !strings.Contains(out, "lark-cli update --force") {
|
||||
t.Errorf("expected force retry hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable
|
||||
// for direct calls to internals like runSkillsAndStamp that write to
|
||||
// io.ErrOut.
|
||||
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers.
|
||||
func newTestIO() *cmdutil.IOStreams {
|
||||
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_DedupHit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got != nil {
|
||||
t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got)
|
||||
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
|
||||
}
|
||||
if called {
|
||||
t.Error("SkillsUpdateOverride called, want skipped due to dedup")
|
||||
t.Error("SkillsCommandOverride called, want skipped due to dedup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_DedupForceBypass(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true)
|
||||
if got == nil {
|
||||
t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil")
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
|
||||
}
|
||||
if !called {
|
||||
t.Error("SkillsUpdateOverride not called with force=true")
|
||||
t.Error("SkillsCommandOverride not called with force=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_SuccessWritesStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_FailureKeepsOldStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("npx failed")
|
||||
return r
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got)
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.20" {
|
||||
t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.20\" (failure must not overwrite)", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -973,8 +991,7 @@ func TestTruncate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -987,9 +1004,9 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1000,17 +1017,19 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called")
|
||||
t.Error("skills sync not called in already-up-to-date branch")
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -1029,9 +1048,9 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
ResolvedPath: "/usr/local/bin/lark-cli",
|
||||
}
|
||||
},
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1042,17 +1061,19 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("RunSkillsUpdate not called in manual branch, want called")
|
||||
t.Error("skills sync not called in manual branch")
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\" (manual path records current binary)", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -1075,9 +1096,9 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
VerifyOverride: func(expectedVersion string) error { return nil },
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1088,18 +1109,25 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("RunSkillsUpdate not called in npm branch")
|
||||
t.Error("skills sync not called in npm branch")
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.22" {
|
||||
t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.22" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.22\" (npm path records latest binary)", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{
|
||||
Version: "1.0.20",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail"},
|
||||
UpdatedSkills: []string{"lark-calendar"},
|
||||
SkippedDeletedSkills: []string{"lark-mail"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1117,9 +1145,9 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
||||
},
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1130,7 +1158,7 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
t.Fatalf("updateRun(--check) err = %v, want nil", err)
|
||||
}
|
||||
if skillsCalled {
|
||||
t.Error("RunSkillsUpdate called under --check, want skipped (pure report)")
|
||||
t.Error("skills sync called under --check, want skipped")
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
@@ -1144,12 +1172,14 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false {
|
||||
t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status)
|
||||
}
|
||||
if status["official"] != float64(2) || status["updated"] != float64(1) {
|
||||
t.Errorf("skills_status counts = %+v, want official:2 updated:1", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1164,9 +1194,9 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1177,12 +1207,15 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err)
|
||||
}
|
||||
if skillsCalled {
|
||||
t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)")
|
||||
t.Error("skills sync called under --check (already-latest), want skipped")
|
||||
}
|
||||
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.20" {
|
||||
t.Errorf("stamp mutated to %q under --check, want \"1.0.20\" (pure report must not write stamp)", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version mutated to %q under --check, want \"1.0.20\"", state.Version)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
@@ -1204,39 +1237,248 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSkillsAndStamp_StampWriteFailureWarns verifies the stderr warning
|
||||
// emission when RunSkillsUpdate succeeds but WriteStamp fails.
|
||||
func TestRunSkillsAndStamp_StampWriteFailureWarns(t *testing.T) {
|
||||
// Force WriteStamp to fail by pointing config dir at a path that exists
|
||||
// as a regular file (so MkdirAll fails).
|
||||
tmp := t.TempDir()
|
||||
badPath := filepath.Join(tmp, "blocker")
|
||||
if err := os.WriteFile(badPath, []byte("not-a-dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
|
||||
origSync := syncSkills
|
||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
|
||||
return &skillscheck.SyncResult{Err: fmt.Errorf("skills synced but state not written: denied")}
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath)
|
||||
t.Cleanup(func() { syncSkills = origSync })
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
return &selfupdate.NpmResult{} // success
|
||||
},
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
|
||||
}
|
||||
got := runSkillsAndStamp(updater, f.IOStreams, "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: skills synced but stamp not written") {
|
||||
if !strings.Contains(stderr.String(), "warning: skills synced but state not written") {
|
||||
t.Errorf("stderr does not contain warning: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitSkillsTextHints_Success verifies the "Skills updated" success
|
||||
// message is printed to ErrOut on a successful (Err == nil) result.
|
||||
func TestEmitSkillsTextHints_Success(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success
|
||||
emitSkillsTextHints(f.IOStreams, &skillscheck.SyncResult{Official: []string{"lark-calendar"}, Updated: []string{"lark-calendar"}})
|
||||
if !strings.Contains(stderr.String(), "Skills updated") {
|
||||
t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
|
||||
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
|
||||
// state file. It calls the real npx skills CLI, so the test is skipped when
|
||||
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
|
||||
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skipf("npx not found in PATH: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
||||
}
|
||||
|
||||
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
|
||||
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
|
||||
// intentionally removed them while they were still official skills.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
before := skillscheck.SkillsState{
|
||||
Version: "1.0.19",
|
||||
OfficialSkills: []string{"lark-approval", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"},
|
||||
UpdatedSkills: []string{"lark-approval", "lark-apps", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"},
|
||||
AddedOfficialSkills: []string{},
|
||||
SkippedDeletedSkills: []string{},
|
||||
UpdatedAt: "2026-05-20T00:00:00Z",
|
||||
}
|
||||
if err := skillscheck.WriteState(before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() before update = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.19" {
|
||||
t.Fatalf("state.Version before update = %q, want 1.0.19", state.Version)
|
||||
}
|
||||
|
||||
// Phase 3: Mock version functions so the update command believes it has
|
||||
// upgraded from 1.0.19 to 1.0.20, then execute "lark-cli update --json".
|
||||
// This triggers SyncSkills which calls the real npx skills add command.
|
||||
origFetch := fetchLatest
|
||||
origVersion := currentVersion
|
||||
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion })
|
||||
fetchLatest = func() (string, error) { return "1.0.20", nil }
|
||||
currentVersion = func() string { return "1.0.20" }
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("lark-cli update --json err = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Phase 4: Verify the state file was rewritten with the new version,
|
||||
// non-empty official/updated skill lists, and a refreshed timestamp.
|
||||
state, readable, err = skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version after update = %q, want 1.0.20", state.Version)
|
||||
}
|
||||
if len(state.OfficialSkills) == 0 {
|
||||
t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state)
|
||||
}
|
||||
if len(state.UpdatedSkills) == 0 {
|
||||
t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state)
|
||||
}
|
||||
if state.UpdatedAt == "" || state.UpdatedAt == before.UpdatedAt {
|
||||
t.Errorf("state.UpdatedAt = %q, want refreshed non-empty timestamp", state.UpdatedAt)
|
||||
}
|
||||
// Verify that previously-skipped skills are handled correctly:
|
||||
// - If locally installed → should appear in UpdatedSkills (updated to latest)
|
||||
// - If locally absent → should NOT be force-restored in UpdatedSkills,
|
||||
// and should remain in SkippedDeletedSkills
|
||||
for _, skill := range []string{"lark-doc", "lark-mail"} {
|
||||
if containsString(localSkills, skill) {
|
||||
if !containsString(state.UpdatedSkills, skill) {
|
||||
t.Errorf("state.UpdatedSkills = %v, want installed skill %q updated", state.UpdatedSkills, skill)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if containsString(state.UpdatedSkills, skill) {
|
||||
t.Errorf("state.UpdatedSkills = %v, want deleted skill %q not restored without --force", state.UpdatedSkills, skill)
|
||||
}
|
||||
if !containsString(state.SkippedDeletedSkills, skill) {
|
||||
t.Errorf("state.SkippedDeletedSkills = %v, want deleted skill %q preserved when still official", state.SkippedDeletedSkills, skill)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: Verify the JSON output structure is parseable and contains
|
||||
// the expected action fields for AI agent consumption.
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
if env["action"] != "already_up_to_date" {
|
||||
t.Errorf("action = %v, want already_up_to_date", env["action"])
|
||||
}
|
||||
if env["skills_action"] != "synced" {
|
||||
t.Errorf("skills_action = %v, want synced", env["skills_action"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCommand_SkillsSyncColdStart verifies that when skills-state.json does
|
||||
// not exist (cold start), the update command installs all official skills and
|
||||
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
|
||||
// because there is no previous state to preserve user deletions from.
|
||||
// This is a live integration test that calls the real npx skills CLI; it is
|
||||
// skipped when npx or the skills registry is unavailable.
|
||||
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
|
||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skipf("npx not found in PATH: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
||||
}
|
||||
|
||||
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if _, readable, _ := skillscheck.ReadState(); readable {
|
||||
t.Fatal("skills-state.json should not exist before update")
|
||||
}
|
||||
|
||||
// Phase 3: Mock version functions so the update command believes it is at
|
||||
// v1.0.20, then execute "lark-cli update --json". This triggers SyncSkills
|
||||
// which calls the real npx skills add command.
|
||||
origFetch := fetchLatest
|
||||
origVersion := currentVersion
|
||||
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion })
|
||||
fetchLatest = func() (string, error) { return "1.0.20", nil }
|
||||
currentVersion = func() string { return "1.0.20" }
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("lark-cli update --json err = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Phase 4: Verify the state file was created with all official skills in
|
||||
// UpdatedSkills and nothing in SkippedDeletedSkills (cold start = no prior
|
||||
// deletions to honor). Locally installed skills should appear in UpdatedSkills.
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version = %q, want 1.0.20", state.Version)
|
||||
}
|
||||
if len(state.OfficialSkills) == 0 {
|
||||
t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state)
|
||||
}
|
||||
if len(state.UpdatedSkills) == 0 {
|
||||
t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state)
|
||||
}
|
||||
if state.UpdatedAt == "" {
|
||||
t.Error("state.UpdatedAt is empty, want non-empty timestamp")
|
||||
}
|
||||
// All locally installed official skills must appear in UpdatedSkills.
|
||||
officialSet := map[string]bool{}
|
||||
for _, s := range state.OfficialSkills {
|
||||
officialSet[s] = true
|
||||
}
|
||||
for _, skill := range localSkills {
|
||||
if !officialSet[skill] {
|
||||
continue
|
||||
}
|
||||
if !containsString(state.UpdatedSkills, skill) {
|
||||
t.Errorf("state.UpdatedSkills = %v, want locally installed official skill %q updated", state.UpdatedSkills, skill)
|
||||
}
|
||||
}
|
||||
// No skill should be in SkippedDeletedSkills on cold start — there is no
|
||||
// previous state recording a user deletion to preserve.
|
||||
if len(state.SkippedDeletedSkills) != 0 {
|
||||
t.Errorf("state.SkippedDeletedSkills = %v, want empty on cold start", state.SkippedDeletedSkills)
|
||||
}
|
||||
|
||||
// Phase 5: Verify the JSON output structure is parseable and contains
|
||||
// the expected action fields for AI agent consumption.
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
if env["action"] != "already_up_to_date" {
|
||||
t.Errorf("action = %v, want already_up_to_date", env["action"])
|
||||
}
|
||||
if env["skills_action"] != "synced" {
|
||||
t.Errorf("skills_action = %v, want synced", env["skills_action"])
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
558
errs/ERROR_CONTRACT.md
Normal file
558
errs/ERROR_CONTRACT.md
Normal file
@@ -0,0 +1,558 @@
|
||||
# lark-cli Error Contract
|
||||
|
||||
`errs/` defines a typed, RFC 7807–aligned error taxonomy for the CLI. Three
|
||||
audiences depend on it: **AI agents and shell scripts** parsing the JSON
|
||||
envelope on stderr; **protocol adapters** mapping CLI errors into MCP /
|
||||
OAuth shapes; and **framework + business code** producing errors. This file
|
||||
is the single source of truth for all three.
|
||||
|
||||
This document describes the **typed authoring target**. The refactor lands
|
||||
in stages; some boundaries (e.g. `client.WrapDoAPIError`) still operate on
|
||||
legacy shapes today — see **Migration** for what is live in each stage.
|
||||
|
||||
Migrating an `*output.ExitError` call site? See **Migration**. Something off
|
||||
in production? See **Troubleshooting**.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. Every error belongs to exactly one **Category**. The set is closed
|
||||
(`errs/category.go`); adding a member requires deliberate review.
|
||||
2. Every **newly constructed** typed error has a **Subtype** — a stable
|
||||
lowercase-with-underscores identifier declared in `errs/subtypes*.go`.
|
||||
Undeclared subtypes fail CI. The constraint applies only to typed
|
||||
`*errs.*` literals; stage-1 legacy `*core.ConfigError` flows via the
|
||||
dispatcher's `asExitError` → legacy envelope path (not the typed
|
||||
taxonomy) and is unaffected. `errcompat.PromoteConfigError` is a
|
||||
stage-1 passthrough; its stage-2+ typed migration will subject the
|
||||
promoted typed error to this Subtype constraint at that time.
|
||||
3. **`Category` + `Subtype`** are wire-stable identifiers consumers may
|
||||
branch on. Renaming either is a breaking change.
|
||||
4. `Code` is the upstream numeric code when known (e.g. Lark API code).
|
||||
It is `omitempty` and never carries CLI-internal meaning.
|
||||
5. Every typed error embeds `errs.Problem`. `CheckProblemEmbed` rejects
|
||||
exported `*Error` structs that do not.
|
||||
6. Wrapping is idempotent: re-wrapping an already-typed error returns it
|
||||
unchanged across the `errors.As` / `errors.Unwrap` chain.
|
||||
7. For the typed-envelope path, exit codes derive from `Category` only
|
||||
via `output.ExitCodeForCategory`. Two stage-1 exceptions:
|
||||
`SecurityPolicyError` always exits `1` (fixed by its legacy envelope),
|
||||
and unmigrated `*output.ExitError` producers carry a hand-set `Code`;
|
||||
both are retired in the legacy-removal stage.
|
||||
|
||||
## Wire format
|
||||
|
||||
Typed errors render to **stderr** as one JSON object per process exit:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"identity": "user",
|
||||
"error": {
|
||||
"type": "authorization",
|
||||
"subtype": "missing_scope",
|
||||
"code": 99991679,
|
||||
"message": "missing scope `calendar:event:create` for app cli_xxx",
|
||||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
||||
"log_id": "20260520-0a1b2c3d",
|
||||
"missing_scopes": ["calendar:event:create"],
|
||||
"console_url": "https://open.feishu.cn/app/cli_xxx/auth?q=..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Stability | Notes |
|
||||
|-------|-----------|-------|
|
||||
| `ok` | wire-stable | always `false` for errors |
|
||||
| `identity` | wire-stable | `user` \| `bot` — caller identity; omitted when not resolved |
|
||||
| `error.type` | **wire-stable** | one of the 9 Categories |
|
||||
| `error.subtype` | **wire-stable** | declared Subtype constant |
|
||||
| `error.code` | wire-stable | upstream numeric code, omitted when zero |
|
||||
| `error.message` | informational | not safe to branch on |
|
||||
| `error.hint` | informational | actionable recovery guidance |
|
||||
| `error.log_id` | informational | upstream request id (server-side trace) |
|
||||
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
|
||||
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
|
||||
|
||||
Carve-out: `SecurityPolicyError` keeps the legacy
|
||||
`{type: "auth_error", code: "challenge_required"|"access_denied", ...}`
|
||||
envelope until its consumers migrate. Removal is staged in **Migration**.
|
||||
|
||||
## Categories
|
||||
|
||||
| Category | When | Exit | Typed struct |
|
||||
|----------|------|------|--------------|
|
||||
| `validation` | malformed user input | 2 | `ValidationError` |
|
||||
| `authentication` | no valid token / login required | 3 | `AuthenticationError` |
|
||||
| `authorization` | token lacks scope / app permission denied | 3 | `PermissionError` |
|
||||
| `config` | local config missing / unbound | 3 | `ConfigError` |
|
||||
| `network` | DNS, refused, timeout, transport | 4 | `NetworkError` |
|
||||
| `api` | server-side Lark error w/o specific bucket | 1 | `APIError` |
|
||||
| `policy` | content safety / security challenge | 6 | `SecurityPolicyError`, `ContentSafetyError` |
|
||||
| `internal` | SDK contract violation / decode failure | 5 | `InternalError` |
|
||||
| `confirmation` | high-risk action needs `--yes` | 10 | `ConfirmationRequiredError` |
|
||||
|
||||
Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
|
||||
|
||||
> **Note on the `authorization` / `PermissionError` asymmetry.** The wire
|
||||
> `type` field uses the RFC 7807 / taxonomy-formal name `"authorization"`,
|
||||
> but the Go type is named `PermissionError`. This is deliberate, following
|
||||
> the gRPC / Google APIs convention (`codes.Unauthenticated` +
|
||||
> `codes.PermissionDenied`): each name is chosen to be **maximally
|
||||
> distinct and readable on its own**, not to be perfectly symmetric.
|
||||
> `AuthenticationError` and `AuthorizationError` differ visually only at
|
||||
> the 5th character and are easy to confuse in code review;
|
||||
> `AuthenticationError` and `PermissionError` cannot be confused. The wire
|
||||
> field stays formal because it is the protocol-level taxonomy; the Go
|
||||
> type favors call-site readability.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
call site
|
||||
│ constructs typed error (e.g. *errs.ValidationError)
|
||||
▼
|
||||
command runE returns err
|
||||
│
|
||||
▼
|
||||
cmd/root.go handleRootError dispatches:
|
||||
├─ *errs.SecurityPolicyError → legacy "auth_error" JSON envelope; exit 1
|
||||
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
|
||||
├─ *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
|
||||
```
|
||||
|
||||
Only the typed and `*output.ExitError` branches emit a JSON envelope on
|
||||
stderr. Untyped errors (including Cobra's "required flag missing" / unknown
|
||||
subcommand messages) print plain text and exit `1` — consumers must
|
||||
tolerate that fallback.
|
||||
|
||||
## Consumers
|
||||
|
||||
### Go (in-process)
|
||||
|
||||
```go
|
||||
var pe *errs.PermissionError
|
||||
if errors.As(err, &pe) {
|
||||
fmt.Println("missing:", pe.MissingScopes)
|
||||
}
|
||||
```
|
||||
|
||||
Predicates cover the common categories (`errs/predicates.go`):
|
||||
|
||||
```go
|
||||
if errs.IsAuthentication(err) { ... }
|
||||
if errs.IsPermission(err) { ... }
|
||||
if errs.IsValidation(err) { ... }
|
||||
```
|
||||
|
||||
Type-agnostic field access:
|
||||
|
||||
```go
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
log.Printf("cat=%s subtype=%s retryable=%t", p.Category, p.Subtype, p.Retryable)
|
||||
}
|
||||
exitCode := output.ExitCodeOf(err) // ExitInternal for non-typed errors
|
||||
```
|
||||
|
||||
### Shell / AI
|
||||
|
||||
```bash
|
||||
out=$(lark-cli ... 2>&1)
|
||||
code=$?
|
||||
|
||||
# Untyped / Cobra errors print plain text — guard before jq.
|
||||
if ! jq -e . >/dev/null 2>&1 <<<"$out"; then
|
||||
printf '%s\n' "$out" >&2
|
||||
exit "$code"
|
||||
fi
|
||||
|
||||
case "$(jq -r '.error.type // empty' <<<"$out")" in
|
||||
authorization) jq -r '.error.missing_scopes[]' <<<"$out" ;;
|
||||
network) echo "transport failure, safe to retry" ;;
|
||||
internal) echo "bug — file an issue with log_id $(jq -r '.error.log_id // "n/a"' <<<"$out")" ;;
|
||||
esac
|
||||
```
|
||||
|
||||
Unknown fields are forward-compatible additions: ignore, don't fail.
|
||||
Branch only on `type`, `subtype`, `code`, `retryable`, and declared
|
||||
extension fields — `message` is human-readable prose that may be
|
||||
reworded without notice.
|
||||
|
||||
## Producers
|
||||
|
||||
### Quick reference
|
||||
|
||||
| Situation | Use |
|
||||
|-----------|-----|
|
||||
| Bad user input | `&errs.ValidationError{...}` or `output.ErrValidation(msg)` |
|
||||
| Login required | `&errs.AuthenticationError{...}` |
|
||||
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
|
||||
| Local config missing | `&errs.ConfigError{...}` |
|
||||
| Transport failure | `&errs.NetworkError{...}` |
|
||||
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
|
||||
| SDK / decode bug | `&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, ...}}` |
|
||||
| Policy block | `&errs.SecurityPolicyError{...}` or `&errs.ContentSafetyError{...}` |
|
||||
| Needs `--yes` | `&errs.ConfirmationRequiredError{...}` |
|
||||
|
||||
### Authoring discipline
|
||||
|
||||
Five rules every producer follows. Some are enforced by `lint/errscontract`
|
||||
AST guards (`go run -C lint . ..`); the rest by code review.
|
||||
|
||||
#### Propagate typed errors unchanged
|
||||
|
||||
A function that receives an error already carrying `errs.Problem`
|
||||
returns it as-is up the stack. Reclassification at non-boundary frames
|
||||
(e.g., wrapping a `*ValidationError` into `*InternalError`) defeats the
|
||||
single-source taxonomy and silently downgrades typed signals.
|
||||
|
||||
Conforming:
|
||||
|
||||
```go
|
||||
_, err := runtime.DoAPI(req, opts)
|
||||
if err != nil {
|
||||
return err // already typed by the framework boundary
|
||||
}
|
||||
```
|
||||
|
||||
Non-conforming:
|
||||
|
||||
```go
|
||||
return fmt.Errorf("calling /open-apis: %v", err) // %v strips the typed shape
|
||||
return &errs.InternalError{Cause: err} // re-decides category
|
||||
```
|
||||
|
||||
#### Never return a typed-nil pointer
|
||||
|
||||
A typed-nil pointer (`var pe *errs.PermissionError; return pe`) wraps as
|
||||
a non-nil interface — `errors.As` matches and `.Error()` may panic.
|
||||
Return interface `nil` literally.
|
||||
|
||||
Non-conforming:
|
||||
|
||||
```go
|
||||
var e *errs.ValidationError // nil pointer
|
||||
return e // non-nil interface holding nil pointer
|
||||
```
|
||||
|
||||
#### Let `Category` derive the exit code
|
||||
|
||||
Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory`
|
||||
maps `Category` to the shell code. A new exit-code requirement means a
|
||||
new `Category`, not a one-off override at the call site.
|
||||
|
||||
(Legacy `*output.ExitError` and `SecurityPolicyError` retain hand-set
|
||||
codes during stage 1.)
|
||||
|
||||
#### Split `Message`, `Hint`, and `Cause`
|
||||
|
||||
Each field carries a distinct role:
|
||||
|
||||
| Field | Carries | Style |
|
||||
|-------|---------|-------|
|
||||
| `Message` | What is wrong | Direct, lowercase first letter, no trailing period |
|
||||
| `Hint` | What to do next | Imperative ("run `lark-cli auth login`", "use `--as user`") |
|
||||
| `Cause` | The wrapped upstream `error`, not a stringified copy | Typed; serialized as `json:"-"` |
|
||||
|
||||
`Hint` must not be merged into `Message`. AI agents and humans read them
|
||||
on separate channels; merging defeats both.
|
||||
|
||||
`Cause` must be a real `error`. If the upstream returned an `error`,
|
||||
place it in `Cause` so `errors.Is` and `errors.Unwrap` walk the chain —
|
||||
do not inline its `.Error()` into `Message`.
|
||||
|
||||
Conforming:
|
||||
|
||||
```go
|
||||
return &errs.NetworkError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryNetwork,
|
||||
Subtype: errs.SubtypeNetworkTransport,
|
||||
Message: "request to /open-apis failed after 3 retries",
|
||||
Hint: "check connectivity and retry; set --log-level debug if it persists",
|
||||
},
|
||||
Cause: ioErr,
|
||||
}
|
||||
```
|
||||
|
||||
Non-conforming:
|
||||
|
||||
```go
|
||||
Message: fmt.Sprintf("request failed: %v — retry later", ioErr)
|
||||
// conflates what + what-to-do + cause into one string
|
||||
```
|
||||
|
||||
#### `ValidationError.Param` uses the `--flag` form
|
||||
|
||||
When a `*ValidationError` originates from a flag value, `Param` holds the
|
||||
flag name with leading dashes (`"--priority"`, not `"priority"`). AI
|
||||
agents grep this field literally to surface "the bad flag was `--X`".
|
||||
|
||||
For positional arguments, use the canonical name without dashes
|
||||
(`"target_user_id"`).
|
||||
|
||||
### Constructing typed errors
|
||||
|
||||
The minimal struct literal:
|
||||
|
||||
```go
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: fmt.Sprintf("--data must be a valid JSON object: %v", parseErr),
|
||||
},
|
||||
Param: "--data",
|
||||
}
|
||||
```
|
||||
|
||||
Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`)
|
||||
remain callable during migration; new code should prefer the struct
|
||||
literal so `Hint`, `Param`, `Cause`, and other extension fields stay
|
||||
available per [Split `Message`, `Hint`, and `Cause`](#split-message-hint-and-cause).
|
||||
|
||||
#### 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:
|
||||
|
||||
```go
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
duration := runtime.Int("duration-minutes")
|
||||
if duration < 1 || duration > 1440 {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: fmt.Sprintf("--duration-minutes must be between 1 and 1440, got %d", duration),
|
||||
Hint: "pass a value in [1, 1440]",
|
||||
},
|
||||
Param: "--duration-minutes",
|
||||
}
|
||||
}
|
||||
|
||||
_, err := runtime.DoAPI(req, opts)
|
||||
if err != nil {
|
||||
return err // already typed by the framework boundary; propagate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Two patterns visible: a producer site (the typed `*errs.ValidationError`
|
||||
above) and a propagation site (the `return err` after `runtime.DoAPI`,
|
||||
applying [Propagate typed errors unchanged](#propagate-typed-errors-unchanged)).
|
||||
|
||||
When the validation logic outgrows a single range check — multiple
|
||||
flags, format parsing, conditional rules — extract it into a helper that
|
||||
also returns the typed `*errs.ValidationError`. The helper, not
|
||||
`Execute`, sets `Param` (a helper bound to one shortcut is normal in
|
||||
this codebase; see `parseTimeRange` in
|
||||
`shortcuts/calendar/calendar_agenda.go:144`).
|
||||
|
||||
### Wrapping upstream errors
|
||||
|
||||
When a producer receives an error from a function it called, four cases
|
||||
cover the decision:
|
||||
|
||||
| Source | Decision | Example |
|
||||
|--------|----------|---------|
|
||||
| Helper returned a typed `*errs.*Error` | Return unchanged | `return err` |
|
||||
| Helper returned an untyped error tied to user input (`strconv.Atoi`, `json.Unmarshal`, …) | Construct a typed error; put the untyped error in `Cause` | `return &errs.ValidationError{Problem: ..., Cause: jsonErr}` |
|
||||
| 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))` |
|
||||
|
||||
Prefer the `Cause` field over `fmt.Errorf("ctx: %w", err)` when
|
||||
attaching an upstream error to a typed one. `Cause` is the chain
|
||||
`errs.UnwrapTypedError` walks and the chain consumer code expects;
|
||||
`fmt.Errorf("...: %w", err)` only affects `.Error()` output, which the
|
||||
wire envelope does not surface.
|
||||
|
||||
#### Boundary helpers (framework-internal)
|
||||
|
||||
These helpers are called from framework boundaries, not from domain
|
||||
code:
|
||||
|
||||
- `errs.WrapInternal(err)` — lifts an untyped error to `*InternalError`;
|
||||
already-typed errors pass through unchanged.
|
||||
- `client.WrapDoAPIError(err)` — classifies SDK transport / decode
|
||||
failures into `*errs.NetworkError` / `*errs.InternalError` at the SDK
|
||||
boundary.
|
||||
- `client.WrapJSONResponseParseError(body, err)` — lifts response-layer
|
||||
JSON parse failures to `*errs.InternalError`.
|
||||
|
||||
If you find yourself reaching for `WrapDoAPIError` from a `shortcuts/**`
|
||||
package, you are probably calling the SDK at the wrong layer — go
|
||||
through `runtime.DoAPI`.
|
||||
|
||||
### Extending the taxonomy
|
||||
|
||||
#### Add a Subtype
|
||||
|
||||
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`.
|
||||
4. Reference the constant from a producer.
|
||||
5. `go run -C lint . ..` — `CheckDeclaredSubtype` fails until the
|
||||
constant is wired through.
|
||||
|
||||
`ad_hoc_*` subtypes are a temporary unblocker that label a value for
|
||||
follow-up, not a permanent identifier. Resolve any `ad_hoc_*` to a
|
||||
declared constant within one week of introduction; `CheckAdHocSubtype`
|
||||
emits a warning to keep them visible.
|
||||
|
||||
#### Add a typed Error struct
|
||||
|
||||
Rare; the existing structs cover the 9 Categories with room. If you must:
|
||||
|
||||
1. Add the struct in `errs/types.go` embedding `errs.Problem`, with a
|
||||
nil-receiver-safe `Unwrap()` if it carries `Cause`.
|
||||
2. Add an `IsXxx` predicate in `errs/predicates.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
|
||||
typed struct as a documented extension field, not into the envelope's
|
||||
top level.
|
||||
|
||||
## CI guards
|
||||
|
||||
| Check | Enforces | Where |
|
||||
|-------|----------|-------|
|
||||
| forbidigo | business path (`shortcuts/**`, `cmd/service/**`) must not call legacy `output.*` error constructors — route through the typed classifier | `.golangci.yml` |
|
||||
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | `lint/errscontract/` AST |
|
||||
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | `lint/errscontract/` AST |
|
||||
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes labeled for promotion (warn) | `lint/errscontract/` AST |
|
||||
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant or `ad_hoc_*` | `lint/errscontract/` AST |
|
||||
| `CheckTypedErrorCompleteness` | every `*errs.<X>Error{Problem: errs.Problem{...}}` literal must set `Category`, `Subtype`, and `Message` | `lint/errscontract/` AST |
|
||||
|
||||
CI runs `lint/` on every PR. Locally: `go run -C lint . ..`. The
|
||||
lintcheck CLI lives in its own Go module so its `golang.org/x/tools`
|
||||
dependency stays out of the shipped `lark-cli` binary's module graph;
|
||||
see `lint/README.md` for how to add a new lint domain.
|
||||
|
||||
## Stability
|
||||
|
||||
| Tier | Surface | Change policy |
|
||||
|------|---------|---------------|
|
||||
| Wire-stable | `error.type`, `error.subtype`, `error.code`, `error.retryable`, declared extension fields, `Category` enum values | breaking change ⇒ semver major; deprecation window required |
|
||||
| Additive | new Category, new declared Subtype, new extension field on an existing struct | minor release; consumers ignore unknown fields by contract |
|
||||
| Experimental | `ad_hoc_*` Subtypes; fields documented as such in `errs/types.go` | may change or be promoted/removed within one release |
|
||||
|
||||
The deprecated `*output.ExitError` surface is outside these tiers — it
|
||||
will be removed once business migration completes.
|
||||
|
||||
## Migration
|
||||
|
||||
The error-contract refactor lands in stages. This PR is **stage 1**, and
|
||||
its scope is **strictly framework-only**: every production wire shape
|
||||
matches pre-PR byte-for-byte (additive fields only where the legacy slot
|
||||
had no subtype emission). Stage 1 ships infrastructure; behavioural
|
||||
migration of any specific path lives in later stages.
|
||||
|
||||
Stages:
|
||||
|
||||
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`.
|
||||
|
||||
During migration, helper assertions accept both shapes (see
|
||||
`shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`)
|
||||
so the build stays green domain-by-domain.
|
||||
|
||||
Before / after at a call site (illustrative — actually performed in
|
||||
stage 3):
|
||||
|
||||
```go
|
||||
// before (legacy)
|
||||
return output.ErrAPI(larkCode, "create event failed", resp.RawBody())
|
||||
|
||||
// after (typed) — cc carries Brand / AppID / Identity from the caller's context
|
||||
return errclass.BuildAPIError(parsedResp, cc)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Envelope shows `type=api subtype=unknown` for what should be a more
|
||||
specific category.** The Lark code is unknown to `LookupCodeMeta` and fell
|
||||
through to the generic bucket (`internal/errclass/classify.go`). Add the
|
||||
code to `internal/errclass/codemeta_<service>.go` with the right Category
|
||||
and Subtype, plus a dispatch test in `classify_test.go`.
|
||||
|
||||
**Envelope shows `type=internal subtype=sdk_error`.** Origin is
|
||||
`client.WrapDoAPIError` taking the non-transport branch
|
||||
(`internal/client/api_errors.go`). Check: did the SDK fail to decode the
|
||||
response (look for `subtype=invalid_response` in the wrapped chain)? Was the
|
||||
transport detection too narrow for this error (e.g. a `*url.Error` with an
|
||||
inner that does not satisfy `net.Error`)? Either widen the transport
|
||||
predicate or add an explicit typed wrap upstream.
|
||||
|
||||
**`CheckDeclaredSubtype` rejects my Subtype.** The constant must be
|
||||
declared in `errs/subtypes*.go` *and* referenced from the dispatch path.
|
||||
Bare string literals trip `CheckDeclaredSubtype` unless they match the
|
||||
`ad_hoc_*` prefix; `ad_hoc_*` then trips `CheckAdHocSubtype` as a
|
||||
follow-up warning.
|
||||
|
||||
**`errors.As(&typedErr)` panics with a nil-pointer receiver.** A typed-nil
|
||||
slipped through. All typed errors define nil-safe `Unwrap()`, but
|
||||
returning a typed-nil pointer up the stack still defeats `errors.As`.
|
||||
Return interface `nil` from constructors, never a typed-nil pointer.
|
||||
|
||||
**Exit code is 5 (internal) when I expected 3 (auth).** The error was not
|
||||
typed before reaching `handleRootError`. Wrap at the boundary
|
||||
(`client.WrapDoAPIError` or a typed constructor) — the bare `error.Error()`
|
||||
string cannot be classified retroactively.
|
||||
|
||||
## Security & privacy
|
||||
|
||||
- `log_id` is a server-side trace token. Safe to surface; it does not
|
||||
carry user content.
|
||||
- `missing_scopes` is app configuration, not user data.
|
||||
- `Message` and `Hint` must not contain tokens, JWTs, or personally
|
||||
identifying values. CI does not catch this — producer responsibility.
|
||||
- Wrapped `Cause` is **not** serialized to the wire (`json:"-"`). It is
|
||||
retained for in-process `errors.Is` / `errors.Unwrap` traversal and
|
||||
optional debug logging only.
|
||||
|
||||
## Pointers (task-driven)
|
||||
|
||||
- *Which struct to construct?* → **Producers / Quick reference**
|
||||
- *Add a new condition?* → **Add a Subtype**
|
||||
- *Consume from a shell script?* → **Consumers / Shell / AI**
|
||||
- *Understand or fix a CI failure?* → **CI guards**
|
||||
- *Migrate a legacy `ExitError` call site?* → **Migration** + the
|
||||
Deprecated note on the symbol being replaced.
|
||||
- *Read source.* → `errs/doc.go` → `errs/category.go` → `errs/types.go`
|
||||
→ `errs/predicates.go` → `internal/errclass/` →
|
||||
`cmd/root.go` `handleRootError`.
|
||||
19
errs/category.go
Normal file
19
errs/category.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// Category is the top-level taxonomy axis. Wire JSON: "type".
|
||||
type Category string
|
||||
|
||||
const (
|
||||
CategoryValidation Category = "validation"
|
||||
CategoryAuthentication Category = "authentication"
|
||||
CategoryAuthorization Category = "authorization"
|
||||
CategoryConfig Category = "config"
|
||||
CategoryNetwork Category = "network"
|
||||
CategoryAPI Category = "api"
|
||||
CategoryPolicy Category = "policy"
|
||||
CategoryInternal Category = "internal"
|
||||
CategoryConfirmation Category = "confirmation"
|
||||
)
|
||||
31
errs/category_test.go
Normal file
31
errs/category_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCategoryWireValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got Category
|
||||
want string
|
||||
}{
|
||||
{"validation", CategoryValidation, "validation"},
|
||||
{"authentication", CategoryAuthentication, "authentication"},
|
||||
{"authorization", CategoryAuthorization, "authorization"},
|
||||
{"config", CategoryConfig, "config"},
|
||||
{"network", CategoryNetwork, "network"},
|
||||
{"api", CategoryAPI, "api"},
|
||||
{"policy", CategoryPolicy, "policy"},
|
||||
{"internal", CategoryInternal, "internal"},
|
||||
{"confirmation", CategoryConfirmation, "confirmation"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.got) != tt.want {
|
||||
t.Errorf("category %s = %q, want %q", tt.name, string(tt.got), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
37
errs/doc.go
Normal file
37
errs/doc.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package errs is the public error-contract surface for lark-cli.
|
||||
//
|
||||
// It defines a closed taxonomy (9 Categories) and a small set of typed
|
||||
// errors that embed Problem — an RFC 7807-aligned shared shape. External
|
||||
// consumers (AI agents, shell scripts, integrating SDKs) read structured
|
||||
// fields instead of regex-parsing free-string error messages.
|
||||
//
|
||||
// # The Problem shape
|
||||
//
|
||||
// Every typed error embeds Problem so the JSON wire shape (`type`,
|
||||
// `subtype`, `code`, `message`, `hint`, `log_id`, `retryable`) is uniform
|
||||
// across categories. Typed extensions (PermissionError.MissingScopes,
|
||||
// SecurityPolicyError.ChallengeURL, etc.) appear at the top level of the
|
||||
// envelope alongside the shared fields, not nested under a `detail` key.
|
||||
//
|
||||
// # Working with typed errors
|
||||
//
|
||||
// Use ProblemOf to read shared fields polymorphically:
|
||||
//
|
||||
// if p, ok := errs.ProblemOf(err); ok {
|
||||
// log.Printf("category=%s subtype=%s retryable=%t", p.Category, p.Subtype, p.Retryable)
|
||||
// }
|
||||
//
|
||||
// Use the IsXxx predicates or stdlib errors.As to branch on concrete type:
|
||||
//
|
||||
// if errs.IsPermission(err) {
|
||||
// var pe *errs.PermissionError
|
||||
// _ = errors.As(err, &pe)
|
||||
// fmt.Println("missing scopes:", pe.MissingScopes)
|
||||
// }
|
||||
//
|
||||
// Use WrapInternal at boundaries to lift any non-typed error to
|
||||
// *InternalError; typed errors pass through unchanged.
|
||||
package errs
|
||||
11
errs/internal_carrier.go
Normal file
11
errs/internal_carrier.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// problemCarrier is the non-exported extraction interface.
|
||||
// Used by ProblemOf via errors.As, working around the Go embed semantic where
|
||||
// *Problem cannot match *PermissionError directly.
|
||||
type problemCarrier interface {
|
||||
ProblemDetail() *Problem
|
||||
}
|
||||
235
errs/marshal_test.go
Normal file
235
errs/marshal_test.go
Normal file
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Per-type marshal tests pin each typed error's wire shape against its
|
||||
// canonical fields. They guard against future refactors that change struct
|
||||
// layout from accidentally altering the externally visible JSON contract.
|
||||
//
|
||||
// Each test asserts (a) Problem fields surface at the top level via embed
|
||||
// promotion, (b) extension fields sit alongside as siblings (NOT under a
|
||||
// `detail` sub-object), and (c) omitempty is honored on optional fields.
|
||||
|
||||
func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
|
||||
pe := &PermissionError{
|
||||
Problem: Problem{
|
||||
Category: CategoryAuthorization, Subtype: SubtypeMissingScope, Code: 99991679,
|
||||
Message: "x", Hint: "y", LogID: "lg", Retryable: false,
|
||||
},
|
||||
MissingScopes: []string{"docx:document"},
|
||||
Identity: "user",
|
||||
ConsoleURL: "https://example",
|
||||
}
|
||||
b, err := json.Marshal(pe)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"authorization"`,
|
||||
`"subtype":"missing_scope"`,
|
||||
`"code":99991679`,
|
||||
`"message":"x"`,
|
||||
`"hint":"y"`,
|
||||
`"log_id":"lg"`,
|
||||
`"missing_scopes":["docx:document"]`,
|
||||
`"identity":"user"`,
|
||||
`"console_url":"https://example"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
if strings.Contains(s, `"retryable"`) {
|
||||
t.Errorf("retryable should be omitted when false; got %s", s)
|
||||
}
|
||||
if strings.Contains(s, `"detail"`) {
|
||||
t.Errorf("extension fields must not be wrapped under detail; got %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationError_MarshalJSON(t *testing.T) {
|
||||
ve := &ValidationError{
|
||||
Problem: Problem{Category: CategoryValidation, Subtype: SubtypeInvalidArgument, Message: "bad"},
|
||||
Param: "--scope",
|
||||
}
|
||||
b, _ := json.Marshal(ve)
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"validation"`,
|
||||
`"subtype":"invalid_argument"`,
|
||||
`"message":"bad"`,
|
||||
`"param":"--scope"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Param omitempty when ""
|
||||
ve2 := &ValidationError{Problem: Problem{Category: CategoryValidation, Message: "x"}}
|
||||
b2, _ := json.Marshal(ve2)
|
||||
if strings.Contains(string(b2), `"param"`) {
|
||||
t.Errorf("param should be omitted when empty; got %s", b2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthError_MarshalJSON(t *testing.T) {
|
||||
ae := &AuthenticationError{
|
||||
Problem: Problem{Category: CategoryAuthentication, Subtype: SubtypeTokenExpired, Message: "expired"},
|
||||
UserOpenID: "ou_x",
|
||||
}
|
||||
b, _ := json.Marshal(ae)
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"authentication"`,
|
||||
`"subtype":"token_expired"`,
|
||||
`"message":"expired"`,
|
||||
`"user_open_id":"ou_x"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigError_MarshalJSON(t *testing.T) {
|
||||
ce := &ConfigError{
|
||||
Problem: Problem{Category: CategoryConfig, Subtype: SubtypeInvalidClient, Message: "bad"},
|
||||
Field: "app_id",
|
||||
}
|
||||
b, _ := json.Marshal(ce)
|
||||
s := string(b)
|
||||
for _, want := range []string{`"type":"config"`, `"subtype":"invalid_client"`, `"field":"app_id"`} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkError_MarshalJSON(t *testing.T) {
|
||||
ne := &NetworkError{
|
||||
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTransport, Message: "transport"},
|
||||
CauseKind: "timeout",
|
||||
}
|
||||
b, _ := json.Marshal(ne)
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"network"`,
|
||||
`"subtype":"transport"`,
|
||||
`"cause":"timeout"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// CauseKind omitempty when ""
|
||||
ne2 := &NetworkError{Problem: Problem{Category: CategoryNetwork, Message: "x"}}
|
||||
b2, _ := json.Marshal(ne2)
|
||||
if strings.Contains(string(b2), `"cause"`) {
|
||||
t.Errorf("cause should be omitted when empty; got %s", b2)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
for _, want := range []string{
|
||||
`"type":"api"`,
|
||||
`"subtype":"rate_limit"`,
|
||||
`"code":99991400`,
|
||||
`"retryable":true`,
|
||||
`"detail":{`,
|
||||
`"raw":"value"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Detail omitempty when nil
|
||||
ae2 := &APIError{Problem: Problem{Category: CategoryAPI, Message: "x"}}
|
||||
b2, _ := json.Marshal(ae2)
|
||||
if strings.Contains(string(b2), `"detail"`) {
|
||||
t.Errorf("detail should be omitted when nil; got %s", b2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityPolicyError_MarshalJSON(t *testing.T) {
|
||||
spe := &SecurityPolicyError{
|
||||
Problem: Problem{Category: CategoryPolicy, Subtype: SubtypeChallengeRequired, Message: "blocked"},
|
||||
ChallengeURL: "https://chal.example",
|
||||
}
|
||||
b, _ := json.Marshal(spe)
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"policy"`,
|
||||
`"subtype":"challenge_required"`,
|
||||
`"challenge_url":"https://chal.example"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentSafetyError_MarshalJSON(t *testing.T) {
|
||||
cse := &ContentSafetyError{
|
||||
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("content_blocked"), Message: "blocked"},
|
||||
Rules: []string{"pii", "violence"},
|
||||
}
|
||||
b, _ := json.Marshal(cse)
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"policy"`,
|
||||
`"rules":["pii","violence"]`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalError_MarshalJSON(t *testing.T) {
|
||||
ie := &InternalError{
|
||||
Problem: Problem{Category: CategoryInternal, Subtype: SubtypeSDKError, Message: "boom"},
|
||||
}
|
||||
b, _ := json.Marshal(ie)
|
||||
s := string(b)
|
||||
for _, want := range []string{`"type":"internal"`, `"subtype":"sdk_error"`} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmationRequiredError_MarshalJSON(t *testing.T) {
|
||||
cre := &ConfirmationRequiredError{
|
||||
Problem: Problem{Category: CategoryConfirmation, Subtype: Subtype("confirmation_required"), Message: "confirm"},
|
||||
Risk: "write",
|
||||
Action: "mail +send",
|
||||
}
|
||||
b, _ := json.Marshal(cre)
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"confirmation"`,
|
||||
`"risk":"write"`,
|
||||
`"action":"mail +send"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
88
errs/predicates.go
Normal file
88
errs/predicates.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ProblemOf extracts the embedded Problem via the non-exported problemCarrier interface.
|
||||
// This is the supported way to read shared fields without depending on a specific typed error.
|
||||
//
|
||||
// A typed error whose embedded *Problem is nil is treated as "not a problem
|
||||
// carrier" — returning (nil, true) here would cause CategoryOf / IsRetryable
|
||||
// and other downstream readers to dereference nil.
|
||||
func ProblemOf(err error) (*Problem, bool) {
|
||||
var c problemCarrier
|
||||
if errors.As(err, &c) {
|
||||
if p := c.ProblemDetail(); p != nil {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// UnwrapTypedError walks the wrap chain and returns the first error that
|
||||
// embeds Problem (i.e. any typed error in this package). Returns the typed
|
||||
// error itself (as error) so callers — notably JSON marshaling — see the
|
||||
// concrete value's own struct tags rather than an opaque wrapper.
|
||||
func UnwrapTypedError(err error) (error, bool) {
|
||||
var c problemCarrier
|
||||
if errors.As(err, &c) {
|
||||
if e, ok := c.(error); ok {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// CategoryOf returns the error's Category for metrics/logging/dispatch routing.
|
||||
// Falls back to CategoryInternal for non-typed errors.
|
||||
func CategoryOf(err error) Category {
|
||||
if p, ok := ProblemOf(err); ok {
|
||||
return p.Category
|
||||
}
|
||||
return CategoryInternal
|
||||
}
|
||||
|
||||
// IsRetryable reads Problem.Retryable; non-typed errors are non-retryable by default.
|
||||
func IsRetryable(err error) bool {
|
||||
if p, ok := ProblemOf(err); ok {
|
||||
return p.Retryable
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsValidation reports whether err is a *ValidationError.
|
||||
func IsValidation(err error) bool { var x *ValidationError; return errors.As(err, &x) }
|
||||
|
||||
// IsPermission reports whether err is a *PermissionError.
|
||||
func IsPermission(err error) bool { var x *PermissionError; return errors.As(err, &x) }
|
||||
|
||||
// IsNetwork reports whether err is a *NetworkError.
|
||||
func IsNetwork(err error) bool { var x *NetworkError; return errors.As(err, &x) }
|
||||
|
||||
// IsAPI reports whether err is an *APIError.
|
||||
func IsAPI(err error) bool { var x *APIError; return errors.As(err, &x) }
|
||||
|
||||
// IsSecurityPolicy reports whether err is a *SecurityPolicyError.
|
||||
func IsSecurityPolicy(err error) bool { var x *SecurityPolicyError; return errors.As(err, &x) }
|
||||
|
||||
// IsContentSafety reports whether err is a *ContentSafetyError.
|
||||
func IsContentSafety(err error) bool { var x *ContentSafetyError; return errors.As(err, &x) }
|
||||
|
||||
// IsInternal reports whether err is an *InternalError.
|
||||
func IsInternal(err error) bool { var x *InternalError; return errors.As(err, &x) }
|
||||
|
||||
// IsConfirmationRequired reports whether err is a *ConfirmationRequiredError.
|
||||
func IsConfirmationRequired(err error) bool {
|
||||
var x *ConfirmationRequiredError
|
||||
return errors.As(err, &x)
|
||||
}
|
||||
|
||||
// IsAuthentication reports whether err is an *AuthenticationError.
|
||||
func IsAuthentication(err error) bool { var x *AuthenticationError; return errors.As(err, &x) }
|
||||
|
||||
// IsConfig reports whether err is a *ConfigError.
|
||||
func IsConfig(err error) bool { var x *ConfigError; return errors.As(err, &x) }
|
||||
203
errs/predicates_test.go
Normal file
203
errs/predicates_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestIsRetryable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "api error with retryable=true",
|
||||
err: &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI, Retryable: true}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "api error with retryable=false (zero)",
|
||||
err: &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain error",
|
||||
err: fmt.Errorf("plain"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := errs.IsRetryable(tt.err); got != tt.want {
|
||||
t.Errorf("IsRetryable(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAuthTypedOnly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "errs.AuthenticationError",
|
||||
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "errs.ConfigError",
|
||||
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain error",
|
||||
err: fmt.Errorf("plain"),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := errs.IsAuthentication(tt.err); got != tt.want {
|
||||
t.Errorf("IsAuthentication(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsConfigTypedOnly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "errs.ConfigError",
|
||||
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "errs.AuthenticationError",
|
||||
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain error",
|
||||
err: fmt.Errorf("plain"),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := errs.IsConfig(tt.err); got != tt.want {
|
||||
t.Errorf("IsConfig(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryOf(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want errs.Category
|
||||
}{
|
||||
{
|
||||
name: "typed validation error",
|
||||
err: &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation}},
|
||||
want: errs.CategoryValidation,
|
||||
},
|
||||
{
|
||||
name: "typed permission error",
|
||||
err: &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization}},
|
||||
want: errs.CategoryAuthorization,
|
||||
},
|
||||
{
|
||||
name: "typed config error",
|
||||
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
|
||||
want: errs.CategoryConfig,
|
||||
},
|
||||
{
|
||||
name: "typed auth error",
|
||||
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
|
||||
want: errs.CategoryAuthentication,
|
||||
},
|
||||
{
|
||||
name: "plain error falls back to internal",
|
||||
err: fmt.Errorf("plain"),
|
||||
want: errs.CategoryInternal,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := errs.CategoryOf(tt.err); got != tt.want {
|
||||
t.Errorf("CategoryOf(%v) = %q, want %q", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProblemOf_NilProblemReturnsFalse pins that a problemCarrier whose
|
||||
// ProblemDetail() returns nil does NOT satisfy ProblemOf — otherwise
|
||||
// CategoryOf / IsRetryable and other downstream readers would dereference
|
||||
// nil and panic. *Problem(nil) is a directly constructable trigger: its
|
||||
// ProblemDetail method `return p` is nil-safe and yields nil.
|
||||
func TestProblemOf_NilProblemReturnsFalse(t *testing.T) {
|
||||
var nilP *errs.Problem
|
||||
var err error = nilP // *Problem implements error via Error() (nil-receiver safe)
|
||||
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if ok {
|
||||
t.Fatalf("ProblemOf(*Problem(nil)) = (%v, true); want (nil, false)", p)
|
||||
}
|
||||
if p != nil {
|
||||
t.Errorf("ProblemOf(*Problem(nil)).p = %v; want nil", p)
|
||||
}
|
||||
|
||||
// Downstream readers must not panic on the same input.
|
||||
if cat := errs.CategoryOf(err); cat != errs.CategoryInternal {
|
||||
t.Errorf("CategoryOf(*Problem(nil)) = %q, want fallback %q", cat, errs.CategoryInternal)
|
||||
}
|
||||
if retryable := errs.IsRetryable(err); retryable {
|
||||
t.Errorf("IsRetryable(*Problem(nil)) = true; want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedPredicates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
pred func(error) bool
|
||||
want bool
|
||||
}{
|
||||
{"IsValidation+", &errs.ValidationError{}, errs.IsValidation, true},
|
||||
{"IsValidation-", &errs.APIError{}, errs.IsValidation, false},
|
||||
{"IsPermission+", &errs.PermissionError{}, errs.IsPermission, true},
|
||||
{"IsPermission-", &errs.APIError{}, errs.IsPermission, false},
|
||||
{"IsNetwork+", &errs.NetworkError{}, errs.IsNetwork, true},
|
||||
{"IsAPI+", &errs.APIError{}, errs.IsAPI, true},
|
||||
{"IsSecurityPolicy+", &errs.SecurityPolicyError{}, errs.IsSecurityPolicy, true},
|
||||
{"IsContentSafety+", &errs.ContentSafetyError{}, errs.IsContentSafety, true},
|
||||
{"IsInternal+", &errs.InternalError{}, errs.IsInternal, true},
|
||||
{"IsConfirmationRequired+", &errs.ConfirmationRequiredError{}, errs.IsConfirmationRequired, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.pred(tc.err); got != tc.want {
|
||||
t.Errorf("%s: predicate = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
38
errs/problem.go
Normal file
38
errs/problem.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// Problem is the RFC 7807-aligned shared shape embedded by every typed error.
|
||||
//
|
||||
// Message is REQUIRED. Producers must populate it; an empty Message will make
|
||||
// Error() return "" — a known Go footgun for fmt.Errorf("...: %v", err).
|
||||
//
|
||||
// Wire-format notes:
|
||||
// - No Component field. Service / shortcut component is metric-only
|
||||
// enrichment derived by the dispatcher from the cobra command path; it
|
||||
// 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.
|
||||
// - Retryable uses omitempty so only `true` is emitted; consumers treat
|
||||
// absence as false.
|
||||
type Problem struct {
|
||||
Category Category `json:"type"`
|
||||
Subtype Subtype `json:"subtype,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
LogID string `json:"log_id,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
}
|
||||
|
||||
// Error satisfies the standard `error` interface. A nil receiver is treated
|
||||
// as the empty string so a stray nil *Problem stored in an error interface
|
||||
// cannot panic the dispatcher.
|
||||
func (p *Problem) Error() string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.Message
|
||||
}
|
||||
func (p *Problem) ProblemDetail() *Problem { return p }
|
||||
72
errs/problem_test.go
Normal file
72
errs/problem_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProblemError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
p Problem
|
||||
want string
|
||||
}{
|
||||
{"empty message", Problem{}, ""},
|
||||
{"plain message", Problem{Message: "boom"}, "boom"},
|
||||
{"message ignores hint", Problem{Message: "msg", Hint: "do x"}, "msg"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := (&tt.p).Error(); got != tt.want {
|
||||
t.Errorf("Error() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProblemError_NilReceiverDoesNotPanic pins the nil-receiver guard on
|
||||
// (*Problem).Error(). Without it, a nil *Problem stored in an error interface
|
||||
// would panic when the root dispatcher calls err.Error() for logging.
|
||||
func TestProblemError_NilReceiverDoesNotPanic(t *testing.T) {
|
||||
var p *Problem // nil
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("(*Problem)(nil).Error() panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
if got := p.Error(); got != "" {
|
||||
t.Errorf("(*Problem)(nil).Error() = %q, want \"\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemDetailReturnsReceiver(t *testing.T) {
|
||||
p := &Problem{Message: "x"}
|
||||
if got := p.ProblemDetail(); got != p {
|
||||
t.Errorf("ProblemDetail() = %p, want receiver %p", got, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemHasNoComponentField(t *testing.T) {
|
||||
if f, ok := reflect.TypeOf(Problem{}).FieldByName("Component"); ok {
|
||||
t.Errorf("Problem.Component must not exist; got field %#v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemHasNoDocURLField(t *testing.T) {
|
||||
if f, ok := reflect.TypeOf(Problem{}).FieldByName("DocURL"); ok {
|
||||
t.Errorf("Problem.DocURL must not exist on the base Problem (PermissionError carries ConsoleURL instead); got field %#v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemCategoryTagIsType(t *testing.T) {
|
||||
f, ok := reflect.TypeOf(Problem{}).FieldByName("Category")
|
||||
if !ok {
|
||||
t.Fatalf("Problem.Category must exist")
|
||||
}
|
||||
if got := f.Tag.Get("json"); got != "type" {
|
||||
t.Errorf("Problem.Category json tag = %q, want %q", got, "type")
|
||||
}
|
||||
}
|
||||
75
errs/subtypes.go
Normal file
75
errs/subtypes.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// Subtype is the second-level taxonomy axis. Wire JSON: "subtype".
|
||||
type Subtype string
|
||||
|
||||
const (
|
||||
SubtypeUnknown Subtype = "unknown" // catch-all fallback; producers must prefer a specific subtype
|
||||
)
|
||||
|
||||
// CategoryValidation subtypes
|
||||
const (
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
const (
|
||||
SubtypeTokenMissing Subtype = "token_missing" // no token in request (Authorization header absent / no local token cache)
|
||||
SubtypeTokenInvalid Subtype = "token_invalid" // token present but content/format wrong
|
||||
SubtypeTokenExpired Subtype = "token_expired" // token explicitly expired
|
||||
SubtypeRefreshTokenInvalid Subtype = "refresh_token_invalid" // refresh_token is v1 legacy format, unusable
|
||||
SubtypeRefreshTokenExpired Subtype = "refresh_token_expired" // refresh_token expired
|
||||
SubtypeRefreshTokenRevoked Subtype = "refresh_token_revoked" // refresh_token revoked (user logout / admin action)
|
||||
SubtypeRefreshTokenReused Subtype = "refresh_token_reused" // refresh_token already used (single-use rotation triggered)
|
||||
SubtypeRefreshServerError Subtype = "refresh_server_error" // refresh endpoint transient error (retryable)
|
||||
)
|
||||
|
||||
// CategoryAuthorization subtypes
|
||||
const (
|
||||
SubtypeMissingScope Subtype = "missing_scope" // user authorized app but did not grant this scope
|
||||
SubtypeUserUnauthorized Subtype = "user_unauthorized" // user never authorized the app
|
||||
SubtypeAppScopeNotApplied Subtype = "app_scope_not_applied" // app did not apply for this scope on the open platform
|
||||
SubtypeTokenScopeInsufficient Subtype = "token_scope_insufficient" // token was issued without this scope (RFC 6750 alignment)
|
||||
SubtypeAppUnavailable Subtype = "app_unavailable" // app status unavailable
|
||||
SubtypeAppNotInstalled Subtype = "app_not_installed" // app not enabled / not installed in this tenant
|
||||
)
|
||||
|
||||
// CategoryConfig subtypes
|
||||
const (
|
||||
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
|
||||
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
|
||||
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
|
||||
)
|
||||
|
||||
// CategoryNetwork subtypes
|
||||
const (
|
||||
SubtypeNetworkTransport Subtype = "transport" // transport-layer failure (timeout / TLS / DNS / 5xx); see NetworkError.CauseKind
|
||||
)
|
||||
|
||||
// CategoryAPI subtypes
|
||||
const (
|
||||
SubtypeRateLimit Subtype = "rate_limit" // request rate limit exceeded
|
||||
SubtypeConflict Subtype = "conflict" // resource state conflict (e.g. concurrent modification)
|
||||
SubtypeCrossTenant Subtype = "cross_tenant" // operation crosses tenant boundary (not supported)
|
||||
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
|
||||
)
|
||||
|
||||
// CategoryPolicy subtypes (security-policy envelope shape)
|
||||
const (
|
||||
SubtypeChallengeRequired Subtype = "challenge_required" // user must complete browser challenge / MFA
|
||||
SubtypeAccessDenied Subtype = "access_denied" // policy denies access outright
|
||||
)
|
||||
|
||||
// CategoryInternal subtypes
|
||||
const (
|
||||
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
|
||||
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
|
||||
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
|
||||
)
|
||||
|
||||
// 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"
|
||||
)
|
||||
136
errs/types.go
Normal file
136
errs/types.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// ValidationError is the typed error for CategoryValidation.
|
||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
||||
// it is intentionally not serialized.
|
||||
type ValidationError struct {
|
||||
Problem
|
||||
Param string `json:"param,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
|
||||
// it. A nil typed-pointer held inside an error interface is treated as
|
||||
// "no cause" so callers cannot panic on `errors.Unwrap(err)`.
|
||||
func (e *ValidationError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// AuthenticationError is the typed error for CategoryAuthentication.
|
||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
||||
// it is intentionally not serialized.
|
||||
type AuthenticationError struct {
|
||||
Problem
|
||||
UserOpenID string `json:"user_open_id,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
func (e *AuthenticationError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// PermissionError is the typed error for CategoryAuthorization.
|
||||
type PermissionError struct {
|
||||
Problem
|
||||
MissingScopes []string `json:"missing_scopes,omitempty"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
ConsoleURL string `json:"console_url,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
func (e *ConfigError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// NetworkError is the typed error for CategoryNetwork.
|
||||
// CauseKind (string) is one of: "timeout" | "tls" | "dns" | "5xx" — the
|
||||
// canonical wire taxonomy (emitted as JSON key "cause"). Cause preserves an
|
||||
// optional wrapped sentinel for errors.Is / errors.Unwrap; it is intentionally
|
||||
// not serialized.
|
||||
type NetworkError struct {
|
||||
Problem
|
||||
CauseKind string `json:"cause,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
func (e *NetworkError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// APIError is the typed error for CategoryAPI (catch-all for classified Lark API
|
||||
// business errors). Detail preserves the raw Lark error map for diagnostics.
|
||||
type APIError struct {
|
||||
Problem
|
||||
Detail map[string]any `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Problem
|
||||
ChallengeURL string `json:"challenge_url,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
func (e *SecurityPolicyError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// ContentSafetyError is the typed error for CategoryPolicy content-safety subtypes.
|
||||
type ContentSafetyError struct {
|
||||
Problem
|
||||
Rules []string `json:"rules,omitempty"`
|
||||
}
|
||||
|
||||
// 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:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
func (e *InternalError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// ConfirmationRequiredError is the typed error for CategoryConfirmation.
|
||||
// Risk is one of: "read" | "write" | "high-risk-write".
|
||||
type ConfirmationRequiredError struct {
|
||||
Problem
|
||||
Risk string `json:"risk"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
154
errs/types_test.go
Normal file
154
errs/types_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPermissionErrorJSONShape(t *testing.T) {
|
||||
perm := &PermissionError{
|
||||
Problem: Problem{
|
||||
Category: CategoryAuthorization,
|
||||
Subtype: SubtypeMissingScope,
|
||||
Message: "x",
|
||||
},
|
||||
MissingScopes: []string{"docx:document"},
|
||||
}
|
||||
b, err := json.Marshal(perm)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
|
||||
mustContain := []string{
|
||||
`"type":"authorization"`,
|
||||
`"subtype":"missing_scope"`,
|
||||
`"missing_scopes":["docx:document"]`,
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("json output missing %q\nfull output: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
mustNotContain := []string{
|
||||
`"component"`,
|
||||
`"doc_url"`,
|
||||
`"retryable":false`,
|
||||
}
|
||||
for _, bad := range mustNotContain {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("json output unexpectedly contains %q\nfull output: %s", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbedSemanticChasm proves the documented Go embed limitation:
|
||||
// errors.As(*PermissionError, &p *Problem) returns false even though
|
||||
// PermissionError embeds Problem. ProblemOf works around this by routing
|
||||
// via the unexported problemCarrier interface.
|
||||
func TestEmbedSemanticChasm(t *testing.T) {
|
||||
perm := &PermissionError{
|
||||
Problem: Problem{
|
||||
Category: CategoryAuthorization,
|
||||
Subtype: SubtypeMissingScope,
|
||||
Message: "missing",
|
||||
},
|
||||
}
|
||||
|
||||
var p *Problem
|
||||
if errors.As(perm, &p) {
|
||||
t.Errorf("errors.As(*PermissionError, &*Problem) unexpectedly succeeded; Go embed semantic changed")
|
||||
}
|
||||
|
||||
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 != CategoryAuthorization {
|
||||
t.Errorf("extracted Problem.Category = %q, want %q", got.Category, CategoryAuthorization)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityPolicyErrorUnwrap(t *testing.T) {
|
||||
orig := errors.New("transport stalled")
|
||||
spe := &SecurityPolicyError{
|
||||
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("challenge_required"), Message: "blocked"},
|
||||
Cause: orig,
|
||||
}
|
||||
if got := errors.Unwrap(spe); got != orig {
|
||||
t.Fatalf("errors.Unwrap(spe) = %v, want %v", got, orig)
|
||||
}
|
||||
if !errors.Is(spe, orig) {
|
||||
t.Fatal("errors.Is(spe, orig) = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypedErrors_UnwrapNilReceiver pins the nil-receiver guard on every typed
|
||||
// error's Unwrap. Without these, a typed-nil pointer stored in an error
|
||||
// interface would panic when the root dispatcher or any caller walks the
|
||||
// errors.Is / errors.Unwrap chain.
|
||||
//
|
||||
// The doc comments on these types claim "nil-receiver safe" but until this
|
||||
// test landed nothing actually pinned that claim — exactly the
|
||||
// behavioral-comment-without-test footgun caught in PR #984 review.
|
||||
func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
|
||||
t.Helper()
|
||||
checks := []struct {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{"ValidationError", func() error { var e *ValidationError; return e.Unwrap() }},
|
||||
{"AuthenticationError", func() error { var e *AuthenticationError; return e.Unwrap() }},
|
||||
{"ConfigError", func() error { var e *ConfigError; return e.Unwrap() }},
|
||||
{"NetworkError", func() error { var e *NetworkError; return e.Unwrap() }},
|
||||
{"SecurityPolicyError", func() error { var e *SecurityPolicyError; return e.Unwrap() }},
|
||||
{"InternalError", func() error { var e *InternalError; return e.Unwrap() }},
|
||||
}
|
||||
for _, c := range checks {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("(*%s)(nil).Unwrap() panicked: %v", c.name, r)
|
||||
}
|
||||
}()
|
||||
if got := c.call(); got != nil {
|
||||
t.Errorf("(*%s)(nil).Unwrap() = %v, want nil", c.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
|
||||
// `return nil` and the test suite would still pass.
|
||||
func TestTypedErrors_UnwrapPropagatesCause(t *testing.T) {
|
||||
cause := errors.New("upstream cause")
|
||||
cases := []struct {
|
||||
name string
|
||||
err interface{ Unwrap() error }
|
||||
}{
|
||||
{"ValidationError", &ValidationError{Cause: cause}},
|
||||
{"AuthenticationError", &AuthenticationError{Cause: cause}},
|
||||
{"ConfigError", &ConfigError{Cause: cause}},
|
||||
{"NetworkError", &NetworkError{Cause: cause}},
|
||||
{"SecurityPolicyError", &SecurityPolicyError{Cause: cause}},
|
||||
{"InternalError", &InternalError{Cause: cause}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := c.err.Unwrap(); got != cause {
|
||||
t.Errorf("(*%s).Unwrap() = %v, want %v", c.name, got, cause)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
27
errs/wrap.go
Normal file
27
errs/wrap.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import "errors"
|
||||
|
||||
// WrapInternal wraps a non-typed error into *InternalError.
|
||||
// Typed errors (anything implementing problemCarrier) pass through unchanged.
|
||||
// Component is metric-only and derived by the dispatcher, so it is not a parameter here.
|
||||
func WrapInternal(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var c problemCarrier
|
||||
if errors.As(err, &c) {
|
||||
return err
|
||||
}
|
||||
return &InternalError{
|
||||
Problem: Problem{
|
||||
Category: CategoryInternal,
|
||||
Subtype: SubtypeUnknown,
|
||||
Message: err.Error(),
|
||||
},
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
49
errs/wrap_test.go
Normal file
49
errs/wrap_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWrapInternalPlainError(t *testing.T) {
|
||||
orig := fmt.Errorf("boom")
|
||||
wrapped := WrapInternal(orig)
|
||||
|
||||
var ie *InternalError
|
||||
if !errors.As(wrapped, &ie) {
|
||||
t.Fatalf("WrapInternal did not produce *InternalError; got %T", wrapped)
|
||||
}
|
||||
if ie.Category != CategoryInternal {
|
||||
t.Errorf("Category = %q, want %q", ie.Category, CategoryInternal)
|
||||
}
|
||||
if ie.Subtype != SubtypeUnknown {
|
||||
t.Errorf("Subtype = %q, want %q", ie.Subtype, SubtypeUnknown)
|
||||
}
|
||||
if ie.Message != "boom" {
|
||||
t.Errorf("Message = %q, want %q", ie.Message, "boom")
|
||||
}
|
||||
if ie.Cause != orig {
|
||||
t.Errorf("Cause = %v, want original error %v", ie.Cause, orig)
|
||||
}
|
||||
if got := errors.Unwrap(wrapped); got != orig {
|
||||
t.Errorf("errors.Unwrap = %v, want original %v", got, orig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapInternalPassesThroughTyped(t *testing.T) {
|
||||
apiErr := &APIError{Problem: Problem{Category: CategoryAPI, Message: "api boom"}}
|
||||
got := WrapInternal(apiErr)
|
||||
if got != apiErr {
|
||||
t.Errorf("WrapInternal should pass through typed errors unchanged; got %#v want %#v", got, apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapInternalNil(t *testing.T) {
|
||||
if got := WrapInternal(nil); got != nil {
|
||||
t.Errorf("WrapInternal(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
116
events/minutes/minute_generated.go
Normal file
116
events/minutes/minute_generated.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
minutesDetailRetryDelay = 500 * time.Millisecond
|
||||
minutesDetailMaxRetries = 2
|
||||
)
|
||||
|
||||
// MinutesMinuteSourceOutput is the flattened minute source payload.
|
||||
type MinutesMinuteSourceOutput struct {
|
||||
SourceType string `json:"source_type,omitempty" desc:"Minute source type"`
|
||||
SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"`
|
||||
}
|
||||
|
||||
// MinutesMinuteGeneratedOutput is the flattened shape for minutes.minute.generated_v1.
|
||||
type MinutesMinuteGeneratedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always minutes.minute.generated_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
MinuteToken string `json:"minute_token,omitempty" desc:"Minute token"`
|
||||
Title string `json:"title,omitempty" desc:"Minute title"`
|
||||
MinuteSource *MinutesMinuteSourceOutput `json:"minute_source,omitempty" desc:"Minute source metadata"`
|
||||
}
|
||||
|
||||
func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
MinuteSource struct {
|
||||
SourceType string `json:"source_type"`
|
||||
SourceEntityID string `json:"source_entity_id"`
|
||||
} `json:"minute_source"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
out := &MinutesMinuteGeneratedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MinuteToken: envelope.Event.MinuteToken,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
if src := envelope.Event.MinuteSource; src.SourceType != "" || src.SourceEntityID != "" {
|
||||
out.MinuteSource = &MinutesMinuteSourceOutput{
|
||||
SourceType: src.SourceType,
|
||||
SourceEntityID: src.SourceEntityID,
|
||||
}
|
||||
}
|
||||
|
||||
if rt != nil && out.MinuteToken != "" {
|
||||
fillMinutesMinuteGeneratedDetails(ctx, rt, out)
|
||||
}
|
||||
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func fillMinutesMinuteGeneratedDetails(ctx context.Context, rt event.APIClient, out *MinutesMinuteGeneratedOutput) {
|
||||
if rt == nil || out == nil || out.MinuteToken == "" {
|
||||
return
|
||||
}
|
||||
|
||||
path := fmt.Sprintf(pathMinuteDetailFmt, validate.EncodePathSegment(out.MinuteToken))
|
||||
|
||||
type minuteDetailResp struct {
|
||||
Data struct {
|
||||
Minute struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"minute"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
for attempt := 0; attempt <= minutesDetailMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(minutesDetailRetryDelay)
|
||||
}
|
||||
|
||||
raw, err := rt.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var resp minuteDetailResp
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.Data.Minute.Title == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
out.Title = resp.Data.Minute.Title
|
||||
return
|
||||
}
|
||||
}
|
||||
353
events/minutes/minute_generated_test.go
Normal file
353
events/minutes/minute_generated_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
if s.callFn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.callFn(ctx, method, path, body)
|
||||
}
|
||||
|
||||
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
|
||||
t.Helper()
|
||||
want := map[string]string{"event_type": wantEventType}
|
||||
if !reflect.DeepEqual(gotBody, want) {
|
||||
t.Fatalf("request body = %#v, want %#v", gotBody, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
for _, k := range Keys() {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestMinutesKeys_ProcessedMinuteGeneratedRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup(eventTypeMinuteGenerated)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for processed key")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Error("PreConsume must not be nil for processed key")
|
||||
}
|
||||
if len(def.Scopes) != 1 || def.Scopes[0] != "minutes:minutes.basic:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
var gotMethod, gotPath string
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
gotMethod = method
|
||||
gotPath = path
|
||||
if body != nil {
|
||||
t.Fatalf("GET detail body = %#v, want nil", body)
|
||||
}
|
||||
return json.RawMessage(`{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"minute": {
|
||||
"token": "<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
|
||||
}
|
||||
33
events/minutes/preconsume.go
Normal file
33
events/minutes/preconsume.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
42
events/minutes/register.go
Normal file
42
events/minutes/register.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package minutes registers Minutes-domain EventKeys.
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMinuteGenerated = "minutes.minute.generated_v1"
|
||||
|
||||
pathMinuteSubscribe = "/open-apis/minutes/v1/minutes/subscription"
|
||||
pathMinuteUnsubscribe = "/open-apis/minutes/v1/minutes/unsubscription"
|
||||
|
||||
pathMinuteDetailFmt = "/open-apis/minutes/v1/minutes/%s"
|
||||
)
|
||||
|
||||
// Keys returns all Minutes-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeMinuteGenerated,
|
||||
DisplayName: "Minute generated",
|
||||
Description: "Triggered when a minute has been generated",
|
||||
EventType: eventTypeMinuteGenerated,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})},
|
||||
},
|
||||
Process: processMinutesMinuteGenerated,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe),
|
||||
Scopes: []string{"minutes:minutes.basic:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeMinuteGenerated},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,17 @@ package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// Mail is intentionally omitted: only IM is wired up this phase.
|
||||
// Mail is intentionally omitted in this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
vc.Keys(),
|
||||
}
|
||||
for _, keys := range all {
|
||||
for _, k := range keys {
|
||||
|
||||
77
events/vc/participant_meeting_ended.go
Normal file
77
events/vc/participant_meeting_ended.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCParticipantMeetingEndedOutput is the flattened shape for vc.meeting.participant_meeting_ended_v1.
|
||||
type VCParticipantMeetingEndedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_ended_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
||||
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
||||
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
||||
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
||||
EndTime string `json:"end_time,omitempty" desc:"Meeting end time in RFC3339, converted to the local timezone"`
|
||||
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
||||
}
|
||||
|
||||
func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Meeting struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
MeetingNo string `json:"meeting_no"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CalendarEventID string `json:"calendar_event_id"`
|
||||
} `json:"meeting"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
meeting := envelope.Event.Meeting
|
||||
out := &VCParticipantMeetingEndedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MeetingID: meeting.ID,
|
||||
Topic: meeting.Topic,
|
||||
MeetingNo: meeting.MeetingNo,
|
||||
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
||||
EndTime: unixSecondsToLocalRFC3339(meeting.EndTime),
|
||||
CalendarEventID: meeting.CalendarEventID,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func unixSecondsToLocalRFC3339(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
secs, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(secs, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
203
events/vc/participant_meeting_ended_test.go
Normal file
203
events/vc/participant_meeting_ended_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
for _, k := range Keys() {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestVCKeys_ProcessedMeetingEndedRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup(eventTypeMeetingEnded)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventTypeMeetingEnded)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for processed key")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Error("PreConsume must not be nil for processed key")
|
||||
}
|
||||
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_end_001",
|
||||
"event_type": "vc.meeting.participant_meeting_ended_v1",
|
||||
"create_time": "1608725989000",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "6911188411934433028",
|
||||
"topic": "my meeting",
|
||||
"meeting_no": "235812466",
|
||||
"start_time": "1608883322",
|
||||
"end_time": "1608883899",
|
||||
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingEnded(t, payload)
|
||||
|
||||
if out.Type != eventTypeMeetingEnded {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_vc_end_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.Timestamp != "1608725989000" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
if out.MeetingID != "6911188411934433028" {
|
||||
t.Errorf("MeetingID = %q", out.MeetingID)
|
||||
}
|
||||
if out.Topic != "my meeting" || out.MeetingNo != "235812466" {
|
||||
t.Errorf("Topic/MeetingNo = %q/%q", out.Topic, out.MeetingNo)
|
||||
}
|
||||
if out.CalendarEventID != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
|
||||
t.Errorf("CalendarEventID = %q", out.CalendarEventID)
|
||||
}
|
||||
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out.StartTime != want {
|
||||
t.Errorf("StartTime = %q, want %q", out.StartTime, want)
|
||||
}
|
||||
if want := time.Unix(1608883899, 0).Local().Format(time.RFC3339); out.EndTime != want {
|
||||
t.Errorf("EndTime = %q, want %q", out.EndTime, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded_InvalidMeetingTimes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_end_002",
|
||||
"event_type": "vc.meeting.participant_meeting_ended_v1",
|
||||
"create_time": "1608725989001"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "meeting_invalid_time",
|
||||
"start_time": "bad",
|
||||
"end_time": ""
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingEnded(t, payload)
|
||||
if out.StartTime != "" || out.EndTime != "" {
|
||||
t.Errorf("StartTime/EndTime = %q/%q, want empty strings", out.StartTime, out.EndTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMeetingEnded,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVCParticipantMeetingEnded_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup("vc.meeting.participant_meeting_ended_v1")
|
||||
if !ok {
|
||||
t.Fatal("vc.meeting.participant_meeting_ended_v1 should be registered via Keys()")
|
||||
}
|
||||
|
||||
type call struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}
|
||||
var calls []call
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
calls = append(calls, call{method: method, path: path, body: body})
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatal("cleanup must not be nil")
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
||||
}
|
||||
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, eventTypeMeetingEnded)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, eventTypeMeetingEnded)
|
||||
}
|
||||
|
||||
func runMeetingEnded(t *testing.T, payload string) VCParticipantMeetingEndedOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMeetingEnded,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out VCParticipantMeetingEndedOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid VCParticipantMeetingEndedOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
33
events/vc/preconsume.go
Normal file
33
events/vc/preconsume.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
43
events/vc/register.go
Normal file
43
events/vc/register.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// 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},
|
||||
},
|
||||
}
|
||||
}
|
||||
30
events/vc/test_helpers_test.go
Normal file
30
events/vc/test_helpers_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
if s.callFn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.callFn(ctx, method, path, body)
|
||||
}
|
||||
|
||||
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
|
||||
t.Helper()
|
||||
want := map[string]string{"event_type": wantEventType}
|
||||
if !reflect.DeepEqual(gotBody, want) {
|
||||
t.Fatalf("request body = %#v, want %#v", gotBody, want)
|
||||
}
|
||||
}
|
||||
@@ -8,21 +8,14 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const (
|
||||
LarkErrBlockByPolicy = 21001 // access denied by access control policy
|
||||
LarkErrBlockByPolicyTryAuth = 21000 // access denied by access control policy; challenge is required to be completed by user in order to gain access
|
||||
needUserAuthorizationMarker = "need_user_authorization"
|
||||
)
|
||||
|
||||
// RefreshTokenRetryable contains error codes that allow one immediate retry.
|
||||
// All other refresh errors clear the token immediately.
|
||||
var RefreshTokenRetryable = map[int]bool{
|
||||
output.LarkErrRefreshServerError: true,
|
||||
}
|
||||
|
||||
// TokenRetryCodes contains error codes that allow retry after token refresh.
|
||||
var TokenRetryCodes = map[int]bool{
|
||||
output.LarkErrTokenInvalid: true,
|
||||
@@ -51,6 +44,7 @@ func IsNeedUserAuthorizationError(err error) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Deprecated: legacy *output.ExitError / string-match branches; removed after typed migration.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return strings.Contains(exitErr.Detail.Message, needUserAuthorizationMarker)
|
||||
@@ -58,24 +52,7 @@ func IsNeedUserAuthorizationError(err error) bool {
|
||||
return strings.Contains(err.Error(), needUserAuthorizationMarker)
|
||||
}
|
||||
|
||||
// SecurityPolicyError is returned when a request is blocked by access control policies.
|
||||
type SecurityPolicyError struct {
|
||||
Code int
|
||||
Message string
|
||||
ChallengeURL string
|
||||
CLIHint string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns the error message for SecurityPolicyError.
|
||||
func (e *SecurityPolicyError) Error() string {
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("security policy error [%d]: %s: %v", e.Code, e.Message, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("security policy error [%d]: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error.
|
||||
func (e *SecurityPolicyError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
// SecurityPolicyError is preserved as a Go type alias so existing
|
||||
// errors.As(&SecurityPolicyError{}) consumers (cmd/root.go etc.) keep working.
|
||||
// The concrete struct lives in errs/types.go.
|
||||
type SecurityPolicyError = errs.SecurityPolicyError
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
@@ -85,34 +87,56 @@ func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response,
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// tryHandleMCPResponse attempts to parse a JSON-RPC (MCP) formatted error response.
|
||||
// tryHandleMCPResponse attempts to parse a JSON-RPC (MCP) formatted error
|
||||
// response coming back from a remote server (this transport is installed on
|
||||
// lark-cli's outbound HTTP client; the bodies it inspects are produced by the
|
||||
// remote, not by lark-cli itself).
|
||||
//
|
||||
// Observed production shape from the MCP gateway — Lark code in the outer
|
||||
// `error.code` slot, hint under `data.cli_hint`:
|
||||
//
|
||||
// {"jsonrpc": "2.0", "id": 1,
|
||||
// "error": {"code": 21000, "message": "...",
|
||||
// "data": {"challenge_url": "...", "cli_hint": "..."}}}
|
||||
//
|
||||
// The parser also accepts a JSON-RPC-canonical shape (outer `error.code`
|
||||
// carrying the JSON-RPC status like -32603, Lark code under `error.data.code`,
|
||||
// hint under `data.hint`) so a future server-side migration to that layout
|
||||
// would not silently drop policy detection. The Lark code is looked up in the
|
||||
// central code registry; the hint key is read from `data.hint` first and
|
||||
// falls back to `data.cli_hint`.
|
||||
func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interface{}) error {
|
||||
// MCP (JSON-RPC) response format:
|
||||
// {
|
||||
// "error": {
|
||||
// "code": 21000,
|
||||
// "message": "...",
|
||||
// "data": { "challenge_url": "...", "cli_hint": "..." }
|
||||
// }
|
||||
// }
|
||||
errMap, ok := result["error"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
code := getInt(errMap, "code", 0)
|
||||
if code != LarkErrBlockByPolicyTryAuth && code != LarkErrBlockByPolicy {
|
||||
dataMap, _ := errMap["data"].(map[string]interface{})
|
||||
|
||||
// Try data.code first (shape B); fall back to outer error.code (shape A).
|
||||
code := 0
|
||||
if dataMap != nil {
|
||||
code = getInt(dataMap, "code", 0)
|
||||
}
|
||||
if code == 0 {
|
||||
code = getInt(errMap, "code", 0)
|
||||
}
|
||||
meta, ok := errclass.LookupCodeMeta(code)
|
||||
if !ok || meta.Category != errs.CategoryPolicy {
|
||||
return nil
|
||||
}
|
||||
|
||||
dataMap, ok := errMap["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
if dataMap == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clean up backticks and spaces from challenge_url
|
||||
challengeUrl := strings.Trim(getStr(dataMap, "challenge_url"), " `")
|
||||
cliHint := getStr(dataMap, "cli_hint")
|
||||
// Read `hint` first; fall back to `cli_hint` so either spelling surfaces.
|
||||
cliHint := getStr(dataMap, "hint")
|
||||
if cliHint == "" {
|
||||
cliHint = getStr(dataMap, "cli_hint")
|
||||
}
|
||||
msg := getStr(errMap, "message")
|
||||
|
||||
if challengeUrl != "" || cliHint != "" {
|
||||
@@ -122,11 +146,15 @@ func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interfa
|
||||
}
|
||||
|
||||
if challengeUrl != "" || cliHint != "" {
|
||||
return &SecurityPolicyError{
|
||||
Code: code,
|
||||
Message: msg,
|
||||
return &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Hint: cliHint,
|
||||
},
|
||||
ChallengeURL: challengeUrl,
|
||||
CLIHint: cliHint,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,8 +174,9 @@ func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interf
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if it's a security policy error
|
||||
if code != LarkErrBlockByPolicyTryAuth && code != LarkErrBlockByPolicy {
|
||||
// 2. Check if it's a security policy error (consult central code registry)
|
||||
meta, ok := errclass.LookupCodeMeta(code)
|
||||
if !ok || meta.Category != errs.CategoryPolicy {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -173,11 +202,15 @@ func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interf
|
||||
}
|
||||
|
||||
if msg != "" || challengeUrl != "" || cliHint != "" {
|
||||
return &SecurityPolicyError{
|
||||
Code: code,
|
||||
Message: msg,
|
||||
return &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Hint: cliHint,
|
||||
},
|
||||
ChallengeURL: challengeUrl,
|
||||
CLIHint: cliHint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
114
internal/auth/transport_test.go
Normal file
114
internal/auth/transport_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestTryHandleMCPResponse_RecognisesDataCode pins the parser's primary path:
|
||||
// when the outer `error.code` carries a JSON-RPC status (e.g. -32603) and the
|
||||
// Lark numeric code lives in `error.data.code`, the transport reads `data.code`
|
||||
// to look up the codeMeta and converts the response into *errs.SecurityPolicyError.
|
||||
// This shape is forward-compat for a future server-side migration to the
|
||||
// JSON-RPC-canonical layout; see also TestTryHandleMCPResponse_FallsBackToOuterCode
|
||||
// for the shape observed in production today.
|
||||
func TestTryHandleMCPResponse_RecognisesDataCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
transport := &SecurityPolicyTransport{}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": map[string]interface{}{
|
||||
"code": -32603, // JSON-RPC internal error
|
||||
"message": "challenge required",
|
||||
"data": map[string]interface{}{
|
||||
"code": 21000, // Lark code for challenge_required
|
||||
"type": "policy",
|
||||
"subtype": "challenge_required",
|
||||
"challenge_url": "https://example.com/challenge",
|
||||
"hint": "please complete the challenge in your browser",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := transport.tryHandleMCPResponse(result)
|
||||
var spErr *errs.SecurityPolicyError
|
||||
if !errors.As(got, &spErr) {
|
||||
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
|
||||
}
|
||||
if spErr.Code != 21000 {
|
||||
t.Errorf("Code = %d, want 21000", spErr.Code)
|
||||
}
|
||||
if spErr.Subtype != errs.SubtypeChallengeRequired {
|
||||
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeChallengeRequired)
|
||||
}
|
||||
if spErr.ChallengeURL != "https://example.com/challenge" {
|
||||
t.Errorf("ChallengeURL = %q", spErr.ChallengeURL)
|
||||
}
|
||||
if spErr.Hint != "please complete the challenge in your browser" {
|
||||
t.Errorf("Hint = %q", spErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryHandleMCPResponse_FallsBackToOuterCode pins the inbound shape observed
|
||||
// in production from the MCP gateway: the Lark code sits in the outer
|
||||
// `error.code` slot (no `data.code`), and the hint surfaces as `data.cli_hint`.
|
||||
// The transport's outer-code fallback path must recognise the policy code and
|
||||
// surface the typed error with the hint promoted.
|
||||
func TestTryHandleMCPResponse_FallsBackToOuterCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
transport := &SecurityPolicyTransport{}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"code": 21001, // outer slot carries the Lark code
|
||||
"message": "access denied",
|
||||
"data": map[string]interface{}{
|
||||
"challenge_url": "https://example.com/c",
|
||||
"cli_hint": "contact admin",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := transport.tryHandleMCPResponse(result)
|
||||
var spErr *errs.SecurityPolicyError
|
||||
if !errors.As(got, &spErr) {
|
||||
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
|
||||
}
|
||||
if spErr.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeAccessDenied)
|
||||
}
|
||||
// `cli_hint` must surface when `hint` is absent.
|
||||
if spErr.Hint != "contact admin" {
|
||||
t.Errorf("Hint = %q, want fallback from cli_hint", spErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTryHandleMCPResponse_NonPolicyCodeIgnored verifies the transport returns
|
||||
// nil (passes through) when the Lark code does not classify as
|
||||
// CategoryPolicy — keeps regular API errors out of the security-policy path.
|
||||
func TestTryHandleMCPResponse_NonPolicyCodeIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
transport := &SecurityPolicyTransport{}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"code": -32603,
|
||||
"message": "permission denied",
|
||||
"data": map[string]interface{}{
|
||||
"code": 99991672, // app_scope_not_enabled — Authorization, not Policy
|
||||
"type": "authorization",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := transport.tryHandleMCPResponse(result); err != nil {
|
||||
t.Fatalf("expected nil (non-policy code), got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -223,16 +225,21 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
|
||||
}
|
||||
|
||||
code := getInt(data, "code", -1)
|
||||
if code == LarkErrBlockByPolicy || code == LarkErrBlockByPolicyTryAuth {
|
||||
meta, metaOK := errclass.LookupCodeMeta(code)
|
||||
if metaOK && meta.Category == errs.CategoryPolicy {
|
||||
challengeUrl := getStr(data, "challenge_url")
|
||||
cliHint := getStr(data, "cli_hint")
|
||||
msg := getStr(data, "error_description")
|
||||
|
||||
return nil, &SecurityPolicyError{
|
||||
Code: code,
|
||||
Message: msg,
|
||||
return nil, &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Hint: cliHint,
|
||||
},
|
||||
ChallengeURL: challengeUrl,
|
||||
CLIHint: cliHint,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +247,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
|
||||
|
||||
if (code != -1 && code != 0) || errStr != "" {
|
||||
// Retryable server error: retry once, then clear token on second failure.
|
||||
if RefreshTokenRetryable[code] {
|
||||
if metaOK && meta.Category == errs.CategoryAuthentication && meta.Retryable {
|
||||
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh transient error (code=%d) for %s, retrying once\n", code, opts.UserOpenId)
|
||||
data, err = callEndpoint()
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -19,10 +20,31 @@ const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard
|
||||
// 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
|
||||
}
|
||||
var existing *output.ExitError
|
||||
if errors.As(err, &existing) {
|
||||
return err
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
if isJSONDecodeError(err, false) {
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
|
||||
@@ -32,6 +54,11 @@ func WrapDoAPIError(err error) error {
|
||||
|
||||
// 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
|
||||
|
||||
@@ -6,31 +6,15 @@ package client
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWrapDoAPIError_BareEOFIsNetworkError(t *testing.T) {
|
||||
err := WrapDoAPIError(io.EOF)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("expected ExitNetwork, got %d", exitErr.Code)
|
||||
}
|
||||
if strings.Contains(exitErr.Error(), "invalid JSON response") {
|
||||
t.Fatalf("unexpected JSON diagnostic for bare EOF: %q", exitErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
|
||||
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
|
||||
if err == nil {
|
||||
@@ -66,3 +50,127 @@ func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
|
||||
t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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_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"},
|
||||
}
|
||||
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_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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_PassthroughBeforeJSONDecode pins that even if a typed/legacy
|
||||
// error wraps a JSON decode error somewhere in its chain, the outer
|
||||
// classification takes precedence — we never re-classify an already-typed error
|
||||
// as a JSON parse error.
|
||||
func TestWrapDoAPIError_PassthroughBeforeJSONDecode(t *testing.T) {
|
||||
jsonErr := &json.SyntaxError{Offset: 1}
|
||||
authWrappingJSON := fmt.Errorf("%w: wrapped %w", output.ErrAuth("token expired"), jsonErr)
|
||||
|
||||
got := WrapDoAPIError(authWrappingJSON)
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", got)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("outer auth classification should win, Code = %d want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,12 +91,28 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
|
||||
// DoSDKRequest resolves auth for the given identity and executes a pre-built SDK request.
|
||||
// This is the shared auth+execute path used by both DoAPI (generic API calls via RawApiRequest)
|
||||
// and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls).
|
||||
//
|
||||
// SDK Do() failures are normalised through WrapDoAPIError so every caller
|
||||
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without each
|
||||
// one remembering to wrap. In stage 1 that wire shape is still the legacy
|
||||
// *output.ExitError envelope (network / api_error) — the stage-4 framework
|
||||
// boundary migration flips WrapDoAPIError to typed *errs.NetworkError /
|
||||
// *errs.InternalError per the contract in errs/ERROR_CONTRACT.md.
|
||||
// Errors that arrive already-classified (legacy *output.ExitError from
|
||||
// resolveAccessToken's missing-credential paths, or a typed *errs.* from
|
||||
// future stages) flow through unchanged.
|
||||
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
var opts []larkcore.RequestOptionFunc
|
||||
|
||||
token, err := c.resolveAccessToken(ctx, as)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// WrapDoAPIError is idempotent on already-classified errors:
|
||||
// the *output.ExitError that resolveAccessToken returns for missing
|
||||
// tokens (via output.ErrAuth) passes through with its auth category
|
||||
// and exit 3 intact, and any future typed *errs.* error from the
|
||||
// credential chain survives the same way. Only stray untyped errors
|
||||
// (raw fmt.Errorf) get the transport-or-internal fallback.
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
if as.IsBot() {
|
||||
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}
|
||||
@@ -107,7 +123,11 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
|
||||
}
|
||||
|
||||
opts = append(opts, extraOpts...)
|
||||
return c.SDK.Do(ctx, req, opts...)
|
||||
resp, err := c.SDK.Do(ctx, req, opts...)
|
||||
if err != nil {
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
|
||||
@@ -123,7 +143,10 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
|
||||
// Resolve auth
|
||||
token, err := c.resolveAccessToken(ctx, as)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// See DoSDKRequest comment on the same wrap pattern; the typed
|
||||
// auth-error pass-through plus untyped fallback applies equally to
|
||||
// streaming requests.
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
|
||||
// Build URL
|
||||
@@ -259,14 +282,27 @@ func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore
|
||||
return c.DoSDKRequest(ctx, apiReq, request.As, extraOpts...)
|
||||
}
|
||||
|
||||
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse.
|
||||
// Use DoAPI directly when the response may not be JSON (e.g. file downloads).
|
||||
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse. Use DoAPI
|
||||
// directly when the response may not be JSON (e.g. file downloads).
|
||||
//
|
||||
// JSON parse failures are wrapped via WrapJSONResponseParseError so callers
|
||||
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
|
||||
// see an *output.ExitError envelope (api_error for malformed JSON, network
|
||||
// for everything else) instead of a bare fmt.Errorf. Without this, an empty
|
||||
// or malformed page body would surface to the root handler as a plain-text
|
||||
// "Error: ..." line, bypassing the JSON stderr envelope contract. Stage-4
|
||||
// framework-boundary migration will flip this wrapper to typed
|
||||
// *errs.InternalError / *errs.NetworkError.
|
||||
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
|
||||
resp, err := c.DoAPI(ctx, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseJSONResponse(resp)
|
||||
result, parseErr := ParseJSONResponse(resp)
|
||||
if parseErr != nil {
|
||||
return nil, WrapJSONResponseParseError(parseErr, resp.RawBody)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// paginateLoop runs the core pagination loop. For each successful page (code == 0),
|
||||
@@ -410,10 +446,14 @@ func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onIt
|
||||
return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil
|
||||
}
|
||||
|
||||
// CheckLarkResponse inspects a Lark API response for business-level errors (non-zero code).
|
||||
// Uses type assertion instead of interface{} == nil to satisfy interface_nil_check lint.
|
||||
// Returns nil if result is not a map, map is nil, or code is 0.
|
||||
func CheckLarkResponse(result interface{}) error {
|
||||
// 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
|
||||
|
||||
@@ -45,12 +45,6 @@ func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.Token
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
type missingTokenResolver struct{}
|
||||
|
||||
func (s *missingTokenResolver) ResolveToken(_ context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, &credential.TokenUnavailableError{Source: "default", Type: req.Type}
|
||||
}
|
||||
|
||||
// newTestAPIClient creates an APIClient with a mock HTTP transport.
|
||||
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
@@ -434,42 +428,118 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoSDKRequest_MissingTokenReturnsAuthError(t *testing.T) {
|
||||
ac, _ := newTestAPIClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
t.Fatal("unexpected HTTP request")
|
||||
return nil, nil
|
||||
}))
|
||||
ac.Credential = credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil)
|
||||
// failingTokenResolver always returns TokenUnavailableError, exercising the
|
||||
// auth/credential failure path through resolveAccessToken.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/test",
|
||||
}, core.AsBot)
|
||||
if err == nil {
|
||||
t.Fatal("DoSDKRequest() error = nil, want auth error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("DoSDKRequest() error = %v, want auth error", err)
|
||||
}
|
||||
func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, &credential.TokenUnavailableError{Source: "test", Type: spec.Type}
|
||||
}
|
||||
|
||||
func TestDoStream_MissingTokenReturnsAuthError(t *testing.T) {
|
||||
// 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_AuthFailurePreservesAuthCategory(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "https://example.com/open-apis/test",
|
||||
}, core.AsBot)
|
||||
ApiPath: "/open-apis/contact/v3/users/me",
|
||||
}, core.AsUser)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("DoStream() error = nil, want auth error")
|
||||
t.Fatal("expected auth error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("DoStream() error = %v, want auth error", err)
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("Code = %d, want %d (auth) — confirms ErrAuth was downgraded to network at SDK wrap", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("Detail.Type = %v, want auth", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoSDKRequest_TransportFailureWrapsAsNetwork pins that genuinely untyped
|
||||
// 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 recognises as a transport error.
|
||||
func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
|
||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
})
|
||||
ac, _ := newTestAPIClient(t, rt)
|
||||
|
||||
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/contact/v3/users/me",
|
||||
}, core.AsBot)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error from broken transport, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||
t.Fatalf("Detail.Type = %v, want network", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// 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{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{ malformed`)),
|
||||
}, nil
|
||||
})
|
||||
ac, _ := newTestAPIClient(t, rt)
|
||||
|
||||
_, err := ac.CallAPI(context.Background(), RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/contact/v3/users/me",
|
||||
As: "bot",
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected JSON parse error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("Code = %d, want %d (api)", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
||||
t.Fatalf("Detail.Type = %v, want api_error", exitErr.Detail)
|
||||
}
|
||||
if exitErr.Detail.Hint != rawAPIJSONHint {
|
||||
t.Errorf("Detail.Hint = %q, want rawAPIJSONHint", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,25 +8,38 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// PaginationOptions contains pagination control options.
|
||||
type PaginationOptions struct {
|
||||
PageLimit int // max pages to fetch; 0 = unlimited (default: 10)
|
||||
PageDelay int // ms, default 200
|
||||
PageLimit int // max pages to fetch; 0 = unlimited (default: 10)
|
||||
PageDelay int // ms, default 200
|
||||
Identity core.Identity // identity passed to checkErr; defaults to AsUser when empty
|
||||
}
|
||||
|
||||
// PaginateWithJq aggregates all pages, checks for API errors, then applies a jq filter.
|
||||
// If checkErr detects an error, the raw result is printed as JSON before returning the error.
|
||||
func PaginateWithJq(ctx context.Context, ac *APIClient, request RawApiRequest,
|
||||
jqExpr string, out io.Writer, pagOpts PaginationOptions,
|
||||
checkErr func(interface{}) error) error {
|
||||
checkErr func(interface{}, core.Identity) error) error {
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("API call failed: %v", err)
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result); apiErr != nil {
|
||||
// Identity resolution honors pagOpts.Identity first, then the request's
|
||||
// own identity, and only falls back to AsUser when neither caller
|
||||
// supplied one. Without checking request.As, bot/auto requests would
|
||||
// always be classified as user identity for checkErr.
|
||||
identity := pagOpts.Identity
|
||||
if identity == "" {
|
||||
identity = request.As
|
||||
}
|
||||
if identity == "" || identity == core.AsAuto {
|
||||
identity = core.AsUser
|
||||
}
|
||||
if apiErr := checkErr(result, identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return apiErr
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
@@ -30,8 +31,13 @@ type ResponseOptions struct {
|
||||
ErrOut io.Writer // stderr
|
||||
FileIO fileio.FileIO // file transfer abstraction; required when saving files (--output or binary response)
|
||||
CommandPath string // raw cobra CommandPath() for content safety scanning
|
||||
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
|
||||
CheckError func(interface{}) error
|
||||
// Identity is forwarded to CheckError (default or caller-supplied) so the
|
||||
// classifier can populate identity-aware fields (e.g. PermissionError.Identity).
|
||||
// Defaults to core.AsUser when empty.
|
||||
Identity core.Identity
|
||||
// CheckError is called on parsed JSON results. Nil defaults to (*APIClient).CheckResponse
|
||||
// with the Identity field (or AsUser when unset).
|
||||
CheckError func(result interface{}, identity core.Identity) error
|
||||
}
|
||||
|
||||
// HandleResponse routes a raw *larkcore.ApiResp to the appropriate output:
|
||||
@@ -40,9 +46,21 @@ type ResponseOptions struct {
|
||||
// 3. If Content-Type is non-JSON and no --output, auto-save binary to file.
|
||||
func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
identity := opts.Identity
|
||||
if identity == "" {
|
||||
identity = core.AsUser
|
||||
}
|
||||
check := opts.CheckError
|
||||
if check == nil {
|
||||
check = CheckLarkResponse
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly
|
||||
@@ -58,7 +76,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
if err != nil {
|
||||
return WrapJSONResponseParseError(err, resp.RawBody)
|
||||
}
|
||||
if apiErr := check(result); apiErr != nil {
|
||||
if apiErr := check(result, identity); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
// Content safety scanning
|
||||
|
||||
@@ -234,37 +234,6 @@ func TestHandleResponse_JSONWithError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_EmptyJSONBody_ShowsDiagnostic(t *testing.T) {
|
||||
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty JSON body")
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatal("expected detail on exit error")
|
||||
}
|
||||
if exitErr.Detail.Message != "API returned an empty JSON response body" {
|
||||
t.Fatalf("unexpected message: %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--output") {
|
||||
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
@@ -424,17 +393,3 @@ func TestSaveResponse_MetadataContainsAbsolutePath(t *testing.T) {
|
||||
t.Errorf("saved_path should be absolute, got %q", savedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) {
|
||||
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
|
||||
resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403 JSON with non-zero code")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "99991400") {
|
||||
t.Errorf("expected lark error code in message, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,13 @@ func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any {
|
||||
// Message comes from CommandDeniedError.Error(), no Hint. Callers that
|
||||
// need a custom Message or an independent Hint (strict-mode) should
|
||||
// compose CommandDeniedFromDenial + DenialDetailMap themselves.
|
||||
//
|
||||
// Deprecated: BuildDenialError produces a legacy *output.ExitError that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — denial signals should move to a typed *errs.XxxError (a dedicated
|
||||
// typed Error for policy denial is tracked for the cmdpolicy migration PR).
|
||||
// This helper is retained only while existing call sites are migrated; it
|
||||
// will be removed once they have moved to the typed surface.
|
||||
func BuildDenialError(path string, d Denial) *output.ExitError {
|
||||
cd := CommandDeniedFromDenial(path, d)
|
||||
return &output.ExitError{
|
||||
|
||||
@@ -19,6 +19,13 @@ import (
|
||||
// command: agents already know their original invocation and only need to
|
||||
// append --yes per the hint, which keeps the protocol free of shell-quoting
|
||||
// pitfalls.
|
||||
// Deprecated: RequireConfirmation produces a legacy *output.ExitError that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — confirmation-required signals should move to typed
|
||||
// *errs.ConfirmationRequiredError carrying the same agent-protocol metadata
|
||||
// (level/action) as typed extension fields. This helper is retained only
|
||||
// while existing call sites are migrated; it will be removed once they have
|
||||
// moved to the typed surface.
|
||||
func RequireConfirmation(action string) error {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitConfirmationRequired,
|
||||
@@ -27,7 +34,7 @@ func RequireConfirmation(action string) error {
|
||||
Message: fmt.Sprintf("%s requires confirmation", action),
|
||||
Hint: "add --yes to confirm",
|
||||
Risk: &output.RiskDetail{
|
||||
Level: "high-risk-write",
|
||||
Level: RiskHighRiskWrite,
|
||||
Action: action,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,11 +7,20 @@ import "github.com/spf13/cobra"
|
||||
|
||||
const riskLevelAnnotationKey = "risk_level"
|
||||
|
||||
// Risk level constants — the three-tier convention used across the CLI.
|
||||
// Use these in place of string literals so the typo radius is one place,
|
||||
// not every call site.
|
||||
const (
|
||||
RiskRead = "read"
|
||||
RiskWrite = "write"
|
||||
RiskHighRiskWrite = "high-risk-write"
|
||||
)
|
||||
|
||||
// SetRisk stores a command's static risk level on cobra annotations so the
|
||||
// help renderer (cmd/root.go) can surface a Risk: line without importing
|
||||
// shortcuts/common. Levels follow the three-tier convention: "read" | "write"
|
||||
// | "high-risk-write". Framework-level confirmation gating only acts on
|
||||
// "high-risk-write".
|
||||
// shortcuts/common. Levels follow the three-tier convention: RiskRead |
|
||||
// RiskWrite | RiskHighRiskWrite. Framework-level confirmation gating only
|
||||
// acts on RiskHighRiskWrite.
|
||||
func SetRisk(cmd *cobra.Command, level string) {
|
||||
if level == "" {
|
||||
return
|
||||
|
||||
@@ -236,7 +236,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
app := raw.CurrentAppConfig(profileOverride)
|
||||
if app == nil {
|
||||
return nil, &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: "config",
|
||||
Message: fmt.Sprintf("profile %q not found", profileOverride),
|
||||
Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())),
|
||||
@@ -244,20 +244,19 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
}
|
||||
|
||||
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
|
||||
return nil, &ConfigError{Code: 2, Type: "config",
|
||||
return nil, &ConfigError{Code: 3, Type: "config",
|
||||
Message: "appId and appSecret keychain key are out of sync",
|
||||
Hint: err.Error()}
|
||||
}
|
||||
|
||||
secret, err := ResolveSecretInput(app.AppSecret, kc)
|
||||
if err != nil {
|
||||
// If the error comes from the keychain, it will already be wrapped as an ExitError.
|
||||
// For other errors (e.g. file read errors, unknown sources), wrap them as ConfigError.
|
||||
// Deprecated: legacy *output.ExitError passthrough; removed after typed migration.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return nil, exitErr
|
||||
}
|
||||
return nil, &ConfigError{Code: 2, Type: "config", Message: err.Error()}
|
||||
return nil, &ConfigError{Code: 3, Type: "config", Message: err.Error()}
|
||||
}
|
||||
cfg := &CliConfig{
|
||||
ProfileName: app.ProfileName(),
|
||||
|
||||
@@ -8,7 +8,7 @@ import "fmt"
|
||||
// ConfigError is a structured error from config resolution.
|
||||
// It carries enough information for main.go to convert it into an output.ExitError.
|
||||
type ConfigError struct {
|
||||
Code int // exit code: 2=validation, 3=auth
|
||||
Code int // exit code: 3 (config errors share the auth exit code)
|
||||
Type string // "config" or "auth"
|
||||
Message string
|
||||
Hint string
|
||||
|
||||
@@ -31,7 +31,7 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) {
|
||||
// keeps it on the standard structured-envelope path at the root
|
||||
// command's error sink.
|
||||
return nil, &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: "config",
|
||||
Message: fmt.Sprintf("failed to load config: %v", err),
|
||||
}
|
||||
@@ -71,14 +71,14 @@ func NotConfiguredError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: "config",
|
||||
Message: "not configured",
|
||||
Hint: localInitHint,
|
||||
}
|
||||
}
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("%s context detected but lark-cli is not bound to it", ws.Display()),
|
||||
Hint: agentBindHint,
|
||||
@@ -105,14 +105,14 @@ func NoActiveProfileError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: "config",
|
||||
Message: "no active profile",
|
||||
Hint: localInitHint,
|
||||
}
|
||||
}
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Code: 3,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("no active profile in %s workspace", ws.Display()),
|
||||
Hint: agentBindHint,
|
||||
|
||||
284
internal/errclass/classify.go
Normal file
284
internal/errclass/classify.go
Normal file
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
||||
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
||||
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
||||
// depend on internal/core (which would create an import cycle).
|
||||
type ClassifyContext struct {
|
||||
Brand string // "feishu" | "lark" — drives console_url host
|
||||
AppID string // placed in console_url
|
||||
Identity string // "user" / "bot" / "" — caller converts core.Identity at the boundary
|
||||
}
|
||||
|
||||
// BuildAPIError consumes a parsed Lark API response and returns a typed error.
|
||||
// Returns nil when resp is nil or resp["code"] is 0.
|
||||
//
|
||||
// Routing by Category:
|
||||
//
|
||||
// Authorization → *errs.PermissionError (with MissingScopes / Identity / ConsoleURL)
|
||||
// Authentication → *errs.AuthenticationError
|
||||
// Config → *errs.ConfigError
|
||||
// Policy → *errs.SecurityPolicyError
|
||||
// Validation → *errs.ValidationError
|
||||
// Network → *errs.NetworkError
|
||||
// Internal → *errs.InternalError
|
||||
// Confirmation → *errs.ConfirmationRequiredError
|
||||
// default (CategoryAPI) → *errs.APIError (Detail preserves raw response)
|
||||
//
|
||||
// Unknown Lark codes (LookupCodeMeta returns false) fall back to
|
||||
// CategoryAPI + SubtypeUnknown.
|
||||
func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
code := intFromAny(resp["code"])
|
||||
if code == 0 {
|
||||
return nil
|
||||
}
|
||||
msg, _ := resp["msg"].(string)
|
||||
if msg == "" {
|
||||
// Upstream omitted or sent non-string msg. Keep Problem.Message non-empty
|
||||
// so the typed wire envelope still carries a human-readable signal.
|
||||
msg = fmt.Sprintf("API error: [%d]", code)
|
||||
}
|
||||
// Lark API responses sometimes carry log_id at the top level
|
||||
// ({"code":..., "log_id":"..."}) and sometimes nested under "error"
|
||||
// ({"code":..., "error":{"log_id":"..."}}). Prefer top level and fall
|
||||
// back to the nested location so log_id always surfaces on the typed
|
||||
// envelope.
|
||||
logID, _ := resp["log_id"].(string)
|
||||
if logID == "" {
|
||||
if errBlock, ok := resp["error"].(map[string]any); ok {
|
||||
if nested, ok := errBlock["log_id"].(string); ok {
|
||||
logID = nested
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
meta, ok := LookupCodeMeta(code)
|
||||
if !ok {
|
||||
meta = CodeMeta{Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown}
|
||||
}
|
||||
|
||||
base := errs.Problem{
|
||||
Category: meta.Category,
|
||||
Subtype: meta.Subtype,
|
||||
Code: code,
|
||||
Message: msg,
|
||||
LogID: logID,
|
||||
Retryable: meta.Retryable,
|
||||
}
|
||||
|
||||
switch meta.Category {
|
||||
case errs.CategoryAuthorization:
|
||||
return buildPermissionError(base, resp, cc)
|
||||
case errs.CategoryAuthentication:
|
||||
return &errs.AuthenticationError{Problem: base}
|
||||
case errs.CategoryConfig:
|
||||
return &errs.ConfigError{Problem: base}
|
||||
case errs.CategoryPolicy:
|
||||
return buildSecurityPolicyError(base, resp)
|
||||
case errs.CategoryValidation:
|
||||
return &errs.ValidationError{Problem: base}
|
||||
case errs.CategoryNetwork:
|
||||
return &errs.NetworkError{Problem: base}
|
||||
case errs.CategoryInternal:
|
||||
return &errs.InternalError{Problem: base}
|
||||
case errs.CategoryConfirmation:
|
||||
return &errs.ConfirmationRequiredError{Problem: base}
|
||||
default:
|
||||
return &errs.APIError{Problem: base, Detail: resp}
|
||||
}
|
||||
}
|
||||
|
||||
// buildSecurityPolicyError extracts challenge_url and the hint from a Lark API
|
||||
// response's data block, so the typed SecurityPolicyError carries the same
|
||||
// browser-challenge information that internal/auth/transport.go surfaces at
|
||||
// the HTTP layer.
|
||||
//
|
||||
// Data shapes accepted (whichever the upstream sends):
|
||||
//
|
||||
// {"code": 21000, "msg": "...", "data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}
|
||||
// {"code": 21000, "error": {"data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}}
|
||||
//
|
||||
// challenge_url is dropped (set to "") if it is not an https:// URL — same
|
||||
// validation policy as internal/auth/transport.go.isValidChallengeURL.
|
||||
// Hint is read from `data.hint` first and falls back to `data.cli_hint` so
|
||||
// either spelling surfaces, matching the transport layer.
|
||||
func buildSecurityPolicyError(p errs.Problem, resp map[string]any) *errs.SecurityPolicyError {
|
||||
dataMap, _ := resp["data"].(map[string]any)
|
||||
if dataMap == nil {
|
||||
if errBlock, ok := resp["error"].(map[string]any); ok {
|
||||
dataMap, _ = errBlock["data"].(map[string]any)
|
||||
}
|
||||
}
|
||||
if dataMap == nil {
|
||||
return &errs.SecurityPolicyError{Problem: p}
|
||||
}
|
||||
|
||||
challengeURL := strings.Trim(stringFromAny(dataMap["challenge_url"]), " `")
|
||||
if challengeURL != "" && !isHTTPSURL(challengeURL) {
|
||||
challengeURL = ""
|
||||
}
|
||||
|
||||
hint := stringFromAny(dataMap["hint"])
|
||||
if hint == "" {
|
||||
hint = stringFromAny(dataMap["cli_hint"])
|
||||
}
|
||||
if hint != "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
|
||||
return &errs.SecurityPolicyError{
|
||||
Problem: p,
|
||||
ChallengeURL: challengeURL,
|
||||
}
|
||||
}
|
||||
|
||||
// isHTTPSURL is the local-to-errclass duplicate of internal/auth/transport.go's
|
||||
// isValidChallengeURL. Kept local to avoid coupling errclass to internal/auth;
|
||||
// the two will collapse when the auth transport adopts BuildAPIError in stage 4.
|
||||
func isHTTPSURL(rawURL string) bool {
|
||||
if rawURL == "" {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Scheme == "https"
|
||||
}
|
||||
|
||||
// stringFromAny coerces a map value to string when it is a string, returning "" otherwise.
|
||||
func stringFromAny(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
||||
missing := extractMissingScopes(resp)
|
||||
identity := cc.Identity
|
||||
if identity == "" {
|
||||
identity = "user"
|
||||
}
|
||||
p.Hint = PermissionHint(missing, identity, p.Subtype)
|
||||
return &errs.PermissionError{
|
||||
Problem: p,
|
||||
MissingScopes: missing,
|
||||
Identity: identity,
|
||||
ConsoleURL: ConsoleURL(cc.Brand, cc.AppID, missing),
|
||||
}
|
||||
}
|
||||
|
||||
// PermissionHint returns an actionable next-step string for a permission
|
||||
// error. User identity with a missing user-scope is recovered by re-running
|
||||
// `auth login --scope ...`; bot identity or app-level scope errors are
|
||||
// recovered by enabling scopes in the open-platform console. The subtype
|
||||
// argument distinguishes app-level failures (e.g. SubtypeAppScopeNotApplied)
|
||||
// where re-authentication will not help regardless of the caller identity.
|
||||
//
|
||||
// Exported so direct construction sites (cmd/service/service.go's
|
||||
// checkServiceScopes) can produce hints that match the dispatcher path
|
||||
// byte-for-byte instead of hand-rolling divergent strings.
|
||||
func PermissionHint(missing []string, identity string, subtype errs.Subtype) string {
|
||||
// app_scope_not_enabled means the scope has not been granted at the
|
||||
// app (developer console) level — re-authenticating cannot fix it,
|
||||
// so route every caller identity to the console hint.
|
||||
useConsole := identity == "bot" || subtype == errs.SubtypeAppScopeNotApplied
|
||||
if len(missing) == 0 {
|
||||
if useConsole {
|
||||
return "check the app's scope grant in the Lark open platform console"
|
||||
}
|
||||
return "ensure the calling identity has been granted the required scopes"
|
||||
}
|
||||
scopes := strings.Join(missing, " ")
|
||||
if useConsole {
|
||||
return fmt.Sprintf("the app is missing required scope(s): %s. Open the app's open platform console and add them.", scopes)
|
||||
}
|
||||
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authenticate with the missing scope(s)", scopes)
|
||||
}
|
||||
|
||||
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
||||
// Returns nil when the structure is absent.
|
||||
func extractMissingScopes(resp map[string]any) []string {
|
||||
errBlock, ok := resp["error"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
raw, ok := errBlock["permission_violations"].([]any)
|
||||
if !ok || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, v := range raw {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s, _ := m["subject"].(string)
|
||||
if s == "" || seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
|
||||
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
|
||||
// scopes list returns the bare /auth landing page; scopes are joined with
|
||||
// commas in the `q` query parameter so the console can pre-select them.
|
||||
//
|
||||
// brand is "feishu" or "lark"; unknown values default to feishu.
|
||||
func ConsoleURL(brand, appID string, scopes []string) string {
|
||||
if appID == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == "lark" {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
// PathEscape on appID — it sits in the URL path. QueryEscape on the
|
||||
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
|
||||
// content must not be able to inject extra query parameters via `&`/`#`.
|
||||
pathID := url.PathEscape(appID)
|
||||
if len(scopes) == 0 {
|
||||
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
|
||||
}
|
||||
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
|
||||
}
|
||||
|
||||
func intFromAny(v any) int {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err == nil {
|
||||
return int(i)
|
||||
}
|
||||
f, err := n.Float64()
|
||||
if err == nil {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
747
internal/errclass/classify_test.go
Normal file
747
internal/errclass/classify_test.go
Normal file
@@ -0,0 +1,747 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// missingScopeResp builds a minimal Lark missing-scope response with one
|
||||
// violation. Shared across the envelope-shape and brand-switch tests.
|
||||
func missingScopeResp(scope string) map[string]any {
|
||||
return map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "scope missing",
|
||||
"error": map[string]any{
|
||||
"permission_violations": []any{
|
||||
map[string]any{"subject": scope},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_NilAndZeroCode(t *testing.T) {
|
||||
if got := errclass.BuildAPIError(nil, errclass.ClassifyContext{}); got != nil {
|
||||
t.Errorf("nil resp should return nil error, got %v", got)
|
||||
}
|
||||
if got := errclass.BuildAPIError(map[string]any{"code": 0, "msg": "ok"}, errclass.ClassifyContext{}); got != nil {
|
||||
t.Errorf("code=0 should return nil error, got %v", got)
|
||||
}
|
||||
// json.Number 0 path (real-world SDK decodes with UseNumber)
|
||||
resp := map[string]any{"code": json.Number("0"), "msg": "ok"}
|
||||
if got := errclass.BuildAPIError(resp, errclass.ClassifyContext{}); got != nil {
|
||||
t.Errorf("json.Number(0) should return nil error, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// matchesTypedError reports whether err is the typed-error variant identified by
|
||||
// wantTyped (e.g. "ValidationError" → *errs.ValidationError). Used by the
|
||||
// ExitCode matrix so a wrong-Category routing (e.g. CategoryValidation falling
|
||||
// through to *APIError) fails loudly instead of passing on Category alone.
|
||||
func matchesTypedError(err error, wantTyped string) bool {
|
||||
switch wantTyped {
|
||||
case "PermissionError":
|
||||
var x *errs.PermissionError
|
||||
return errors.As(err, &x)
|
||||
case "AuthenticationError":
|
||||
var x *errs.AuthenticationError
|
||||
return errors.As(err, &x)
|
||||
case "ValidationError":
|
||||
var x *errs.ValidationError
|
||||
return errors.As(err, &x)
|
||||
case "NetworkError":
|
||||
var x *errs.NetworkError
|
||||
return errors.As(err, &x)
|
||||
case "ConfigError":
|
||||
var x *errs.ConfigError
|
||||
return errors.As(err, &x)
|
||||
case "InternalError":
|
||||
var x *errs.InternalError
|
||||
return errors.As(err, &x)
|
||||
case "ConfirmationRequiredError":
|
||||
var x *errs.ConfirmationRequiredError
|
||||
return errors.As(err, &x)
|
||||
case "SecurityPolicyError":
|
||||
var x *errs.SecurityPolicyError
|
||||
return errors.As(err, &x)
|
||||
case "APIError":
|
||||
// APIError is the default fallback; use a direct type assertion to avoid
|
||||
// matching against typed subclasses that also satisfy IsAPI.
|
||||
_, ok := err.(*errs.APIError)
|
||||
return ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantExit int
|
||||
wantTyped string
|
||||
}{
|
||||
{"99991672 app_missing_scope", 99991672, errs.CategoryAuthorization, errs.SubtypeAppScopeNotApplied, 3, "PermissionError"},
|
||||
{"99991676 token_no_permission", 99991676, errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, 3, "PermissionError"},
|
||||
{"99991679 missing_scope", 99991679, errs.CategoryAuthorization, errs.SubtypeMissingScope, 3, "PermissionError"},
|
||||
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
|
||||
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.Subtype("task_permission_denied"), 3, "PermissionError"},
|
||||
{"1470400 task_invalid_params", 1470400, errs.CategoryValidation, errs.Subtype("task_invalid_params"), 2, "ValidationError"},
|
||||
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
||||
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
||||
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
|
||||
{"unknown code 999999", 999999, errs.CategoryAPI, errs.SubtypeUnknown, 1, "APIError"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp := map[string]any{"code": tc.code, "msg": "x"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for code %d, got nil", tc.code)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok for code %d (err = %T)", tc.code, err)
|
||||
}
|
||||
if p.Category != tc.wantCat {
|
||||
t.Errorf("Category = %q, want %q", p.Category, tc.wantCat)
|
||||
}
|
||||
if p.Subtype != tc.wantSubtype {
|
||||
t.Errorf("Subtype = %q, want %q", p.Subtype, tc.wantSubtype)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != tc.wantExit {
|
||||
t.Errorf("ExitCodeOf = %d, want %d (typed = %s)", got, tc.wantExit, tc.wantTyped)
|
||||
}
|
||||
if !matchesTypedError(err, tc.wantTyped) {
|
||||
t.Errorf("typed-error mismatch: got %T, want %s", err, tc.wantTyped)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_ValidationRoutesToValidationError pins that code 1470400
|
||||
// (taskCodeMeta → CategoryValidation) produces *errs.ValidationError, not
|
||||
// the default *errs.APIError. The dispatcher must read codeMeta.Category and
|
||||
// route accordingly so the embedded Problem.Category matches the wire type.
|
||||
func TestBuildAPIError_ValidationRoutesToValidationError(t *testing.T) {
|
||||
resp := map[string]any{"code": 1470400, "msg": "bad params"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 1470400")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if _, isAPI := err.(*errs.APIError); isAPI {
|
||||
t.Fatalf("unexpected *errs.APIError fallthrough (F2 regression): %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionErrorEnvelopeShape(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "missing scope",
|
||||
"log_id": "lg-1",
|
||||
"error": map[string]any{
|
||||
"permission_violations": []any{
|
||||
map[string]any{"subject": "docx:document"},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
|
||||
var buf bytes.Buffer
|
||||
ok := output.WriteTypedErrorEnvelope(&buf, err, "user")
|
||||
if !ok {
|
||||
t.Fatal("WriteTypedErrorEnvelope returned false for typed error")
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
// positive assertions
|
||||
for _, want := range []string{
|
||||
`"type": "authorization"`,
|
||||
`"subtype": "missing_scope"`,
|
||||
`"code": 99991679`,
|
||||
`"missing_scopes":`,
|
||||
`"docx:document"`,
|
||||
`"console_url":`,
|
||||
`open.feishu.cn/app/cli_a123/auth`,
|
||||
`"identity": "user"`,
|
||||
`"log_id": "lg-1"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("envelope missing %q\nfull: %s", want, out)
|
||||
}
|
||||
}
|
||||
// negative assertions on the wire format
|
||||
for _, mustNot := range []string{
|
||||
`"component"`,
|
||||
`"doc_url"`,
|
||||
`"retryable":`, // Retryable defaults false, omitempty → key absent
|
||||
} {
|
||||
if strings.Contains(out, mustNot) {
|
||||
t.Errorf("envelope must not contain %q\nfull: %s", mustNot, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryableEnvelope_TrueOnly(t *testing.T) {
|
||||
// Test 1: Retryable:true → key present
|
||||
apiErr := &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Message: "x", Retryable: true,
|
||||
}}
|
||||
var buf bytes.Buffer
|
||||
output.WriteTypedErrorEnvelope(&buf, apiErr, "user")
|
||||
if !strings.Contains(buf.String(), `"retryable": true`) {
|
||||
t.Errorf("Retryable:true should emit key; got: %s", buf.String())
|
||||
}
|
||||
|
||||
// Test 2: Retryable:false → key absent
|
||||
buf.Reset()
|
||||
apiErr2 := &errs.APIError{Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI, Message: "x", Retryable: false,
|
||||
}}
|
||||
if ok := output.WriteTypedErrorEnvelope(&buf, apiErr2, "user"); !ok {
|
||||
t.Fatal("WriteTypedErrorEnvelope returned false for typed error — emission failed silently")
|
||||
}
|
||||
if strings.Contains(buf.String(), `"retryable"`) {
|
||||
t.Errorf("Retryable:false should omit key; got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleURL_FeishuBrand(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
pe, ok := err.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleURL_LarkBrand(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "user"})
|
||||
pe, ok := err.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
|
||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleURL_EmptyAppID(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "user"})
|
||||
pe := err.(*errs.PermissionError)
|
||||
if pe.ConsoleURL != "" {
|
||||
t.Errorf("ConsoleURL with empty AppID should be empty; got %q", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleURL_EscapesDangerousChars pins that ConsoleURL escapes appID and
|
||||
// scope values so a hostile value cannot break out of the URL framing
|
||||
// (e.g. by smuggling extra `&` parameters or a `#` fragment).
|
||||
func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appID string
|
||||
scopes []string
|
||||
wantInURL []string // substrings that MUST appear
|
||||
denyInURL []string // substrings that MUST NOT appear
|
||||
}{
|
||||
{
|
||||
name: "ampersand in scope smuggles extra param",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope&evil=injected"},
|
||||
wantInURL: []string{"q=scope%26evil%3Dinjected"},
|
||||
denyInURL: []string{"q=scope&evil=injected"},
|
||||
},
|
||||
{
|
||||
name: "hash in scope splits fragment",
|
||||
appID: "cli_good",
|
||||
scopes: []string{"scope#fragment"},
|
||||
wantInURL: []string{"q=scope%23fragment"},
|
||||
denyInURL: []string{"q=scope#fragment"},
|
||||
},
|
||||
{
|
||||
name: "question mark in appID prematurely opens query",
|
||||
appID: "good?q=injected",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%3Fq=injected/auth"},
|
||||
denyInURL: []string{"/app/good?q=injected/auth"},
|
||||
},
|
||||
{
|
||||
name: "hash in appID truncates URL",
|
||||
appID: "good#fragment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%23fragment/auth"},
|
||||
denyInURL: []string{"/app/good#fragment/auth"},
|
||||
},
|
||||
{
|
||||
name: "slash in appID escapes path segment",
|
||||
appID: "good/extra/segment",
|
||||
scopes: []string{"docx:document"},
|
||||
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := errclass.ConsoleURL("feishu", tt.appID, tt.scopes)
|
||||
for _, want := range tt.wantInURL {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("ConsoleURL missing escaped substring\n want: %s\n got: %s", want, got)
|
||||
}
|
||||
}
|
||||
for _, deny := range tt.denyInURL {
|
||||
if strings.Contains(got, deny) {
|
||||
t.Errorf("ConsoleURL contains unescaped dangerous substring\n deny: %s\n got: %s", deny, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionError_DefaultIdentity(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123" /* no Identity */})
|
||||
pe := err.(*errs.PermissionError)
|
||||
if pe.Identity != "user" {
|
||||
t.Errorf("default Identity should be \"user\"; got %q", pe.Identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionError_NoViolations(t *testing.T) {
|
||||
// permission error without a permission_violations array → MissingScopes nil,
|
||||
// ConsoleURL falls back to the no-scope form.
|
||||
resp := map[string]any{"code": 99991679, "msg": "x"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
pe := err.(*errs.PermissionError)
|
||||
if pe.MissingScopes != nil {
|
||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
||||
}
|
||||
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
|
||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMissingScopes_Dedup(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "x",
|
||||
"error": map[string]any{
|
||||
"permission_violations": []any{
|
||||
map[string]any{"subject": "docx:document"},
|
||||
map[string]any{"subject": "docx:document"}, // dup
|
||||
map[string]any{"subject": ""}, // ignored
|
||||
map[string]any{"subject": "im:message"},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
pe := err.(*errs.PermissionError)
|
||||
if got, want := len(pe.MissingScopes), 2; got != want {
|
||||
t.Fatalf("MissingScopes len = %d, want %d (raw: %v)", got, want, pe.MissingScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceShortcutEnvelopeConverge guards that the wire envelope is
|
||||
// identical whether produced via the dispatcher (BuildAPIError — the normal
|
||||
// service / shortcut path) or constructed directly at the call site (the
|
||||
// cmd/service permission path).
|
||||
//
|
||||
// cmd/service/service.go's checkServiceScopes builds PermissionError using the
|
||||
// exported PermissionHint and ConsoleURL helpers — the same helpers
|
||||
// BuildAPIError uses. The hand-constructed branch below intentionally mirrors
|
||||
// service.go line-by-line so a future drift on either side (e.g. a new
|
||||
// extension field on PermissionError that only BuildAPIError populates) fails
|
||||
// loudly here. The remaining limitation is that this test invokes the helpers
|
||||
// directly rather than driving checkServiceScopes (which requires a credential
|
||||
// + factory mock). TODO: lift this into cmd/service_test.go once a lightweight
|
||||
// mock harness lands.
|
||||
func TestServiceShortcutEnvelopeConverge(t *testing.T) {
|
||||
const (
|
||||
brand = "feishu"
|
||||
appID = "cli_a123"
|
||||
identity = "user"
|
||||
)
|
||||
missing := []string{"docx:document"}
|
||||
|
||||
// Path A: dispatcher — BuildAPIError parsing a Lark API response.
|
||||
resp := missingScopeResp(missing[0])
|
||||
dispatcherErr := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: brand, AppID: appID, Identity: identity})
|
||||
dispatcherPE, ok := dispatcherErr.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("BuildAPIError did not return *PermissionError, got %T", dispatcherErr)
|
||||
}
|
||||
|
||||
// Path B: direct construction — exactly mirrors cmd/service/service.go's
|
||||
// checkServiceScopes (same helpers, same field-fill order). Code
|
||||
// and Message are copied from Path A so the byte-comparison below isolates
|
||||
// the contract under test (Hint + Identity + ConsoleURL convergence).
|
||||
directErr := &errs.PermissionError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthorization,
|
||||
Subtype: errs.SubtypeMissingScope,
|
||||
Code: dispatcherPE.Code,
|
||||
Message: dispatcherPE.Message,
|
||||
Hint: errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope),
|
||||
},
|
||||
MissingScopes: missing,
|
||||
Identity: identity,
|
||||
ConsoleURL: errclass.ConsoleURL(brand, appID, missing),
|
||||
}
|
||||
|
||||
var bufA, bufB bytes.Buffer
|
||||
if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok {
|
||||
t.Fatal("dispatcher path failed to emit typed envelope")
|
||||
}
|
||||
if ok := output.WriteTypedErrorEnvelope(&bufB, directErr, identity); !ok {
|
||||
t.Fatal("direct path failed to emit typed envelope")
|
||||
}
|
||||
|
||||
if bufA.String() != bufB.String() {
|
||||
t.Errorf("dispatcher vs direct-construction envelopes diverge:\nDispatcher: %s\nDirect: %s", bufA.String(), bufB.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectPermissionPath_TypedExitCode(t *testing.T) {
|
||||
// Mirrors what the cmd/service direct-construction path produces.
|
||||
pe := &errs.PermissionError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthorization,
|
||||
Subtype: errs.SubtypeMissingScope,
|
||||
Message: "missing required scope(s): docx:document",
|
||||
},
|
||||
MissingScopes: []string{"docx:document"},
|
||||
Identity: "user",
|
||||
}
|
||||
if got := output.ExitCodeOf(pe); got != 3 {
|
||||
t.Errorf("ExitCodeOf = %d, want 3", got)
|
||||
}
|
||||
if !errs.IsPermission(pe) {
|
||||
t.Error("expected IsPermission(pe) == true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteTypedEnvelope_UntypedReturnsFalse(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if output.WriteTypedErrorEnvelope(&buf, errors.New("plain"), "user") {
|
||||
t.Error("expected WriteTypedErrorEnvelope to return false for untyped error")
|
||||
}
|
||||
if buf.Len() > 0 {
|
||||
t.Errorf("expected no output for untyped error, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_LogIDNestedInError(t *testing.T) {
|
||||
// Some Lark API responses carry log_id nested under "error" rather than
|
||||
// at the top level. BuildAPIError must surface either location.
|
||||
resp := map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "missing scope",
|
||||
"error": map[string]any{
|
||||
"log_id": "lg-nested-123",
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_x", Identity: "user"})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
||||
}
|
||||
if p.LogID != "lg-nested-123" {
|
||||
t.Errorf("LogID = %q, want lg-nested-123", p.LogID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_LogIDTopLevel(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "missing scope",
|
||||
"log_id": "lg-top-456",
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Identity: "user"})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
||||
}
|
||||
if p.LogID != "lg-top-456" {
|
||||
t.Errorf("LogID = %q, want lg-top-456", p.LogID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_UserWithScopes(t *testing.T) {
|
||||
got := errclass.PermissionHint([]string{"docx:document", "im:message"}, "user", errs.SubtypeMissingScope)
|
||||
if !strings.Contains(got, "lark-cli auth login") {
|
||||
t.Errorf("user hint should suggest `lark-cli auth login`; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "docx:document") || !strings.Contains(got, "im:message") {
|
||||
t.Errorf("user hint should include missing scopes; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_BotWithScopes(t *testing.T) {
|
||||
got := errclass.PermissionHint([]string{"docx:document"}, "bot", errs.SubtypeMissingScope)
|
||||
if !strings.Contains(got, "open platform console") {
|
||||
t.Errorf("bot hint should mention the open-platform console; got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "auth login") {
|
||||
t.Errorf("bot hint must not suggest re-running `auth login`; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_NoScopes(t *testing.T) {
|
||||
if got := errclass.PermissionHint(nil, "user", errs.SubtypeMissingScope); !strings.Contains(got, "required scopes") {
|
||||
t.Errorf("user no-scope hint missing fallback wording; got %q", got)
|
||||
}
|
||||
if got := errclass.PermissionHint(nil, "bot", errs.SubtypeMissingScope); !strings.Contains(got, "open platform console") {
|
||||
t.Errorf("bot no-scope hint should still point at the console; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
|
||||
// 99991672 / app_scope_not_enabled means the scope has not been granted
|
||||
// at the app level — re-authenticating cannot fix it. The hint must
|
||||
// point to the developer console regardless of caller identity, or
|
||||
// agents will loop on `auth login` forever.
|
||||
for _, identity := range []string{"user", "bot", ""} {
|
||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied)
|
||||
if !strings.Contains(got, "open platform console") {
|
||||
t.Errorf("identity=%q: hint should point to console; got %q", identity, got)
|
||||
}
|
||||
if strings.Contains(got, "auth login") {
|
||||
t.Errorf("identity=%q: hint must not suggest `auth login`; got %q", identity, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_AppMissingScope_UserIdentityHintRoutesToConsole(t *testing.T) {
|
||||
// Regression: code 99991672 with user identity previously emitted
|
||||
// `lark-cli auth login --scope ...` which sends agents into a re-auth
|
||||
// loop because the missing scope is not yet enabled at the app level.
|
||||
resp := map[string]any{
|
||||
"code": 99991672,
|
||||
"msg": "app scope not enabled",
|
||||
"error": map[string]any{"permission_violations": []any{map[string]any{"subject": "contact:contact"}}},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_x", Identity: "user"})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeAppScopeNotApplied)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "open platform console") {
|
||||
t.Errorf("Hint should route to console; got %q", p.Hint)
|
||||
}
|
||||
if strings.Contains(p.Hint, "auth login") {
|
||||
t.Errorf("Hint must not suggest `auth login` for app-level scope errors; got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionError_HintPopulated(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
||||
}
|
||||
if p.Hint == "" {
|
||||
t.Error("PermissionError.Hint should be populated by BuildAPIError")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "docx:document") {
|
||||
t.Errorf("Hint should reference missing scope; got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_JSONNumberCode(t *testing.T) {
|
||||
// SDK parses with json.Number; verify intFromAny handles it.
|
||||
resp := map[string]any{"code": json.Number("99991679"), "msg": "x"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for json.Number-encoded code")
|
||||
}
|
||||
if _, ok := err.(*errs.PermissionError); !ok {
|
||||
t.Errorf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyExtractsChallenge pins that policy responses
|
||||
// passing through BuildAPIError keep the browser-challenge URL and hint —
|
||||
// agents need challenge_url to drive the user through MFA / device-trust
|
||||
// flows. Without extraction, the typed envelope is degenerate vs. what the
|
||||
// internal/auth/transport.go HTTP-layer interceptor already produces.
|
||||
func TestBuildAPIError_SecurityPolicyExtractsChallenge(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 21000,
|
||||
"msg": "challenge required",
|
||||
"data": map[string]any{
|
||||
"challenge_url": "https://passport.feishu.cn/challenge/xyz",
|
||||
"hint": "complete MFA in the browser, then retry",
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "https://passport.feishu.cn/challenge/xyz" {
|
||||
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/challenge/xyz", spe.ChallengeURL)
|
||||
}
|
||||
if spe.Hint != "complete MFA in the browser, then retry" {
|
||||
t.Errorf("Hint = %q, want MFA hint", spe.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyHintFallsBackToCliHint pins that responses
|
||||
// using data.cli_hint still surface via Hint when data.hint is absent.
|
||||
func TestBuildAPIError_SecurityPolicyHintFallsBackToCliHint(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 21001,
|
||||
"msg": "access denied",
|
||||
"data": map[string]any{
|
||||
"cli_hint": "ask your admin for elevated approval",
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.Hint != "ask your admin for elevated approval" {
|
||||
t.Errorf("Hint = %q, want cli_hint fallback", spe.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge pins that an
|
||||
// untrusted challenge_url (non-https) is dropped — same policy as
|
||||
// internal/auth/transport.go isValidChallengeURL.
|
||||
func TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge(t *testing.T) {
|
||||
cases := []string{
|
||||
"http://attacker.example.com/challenge",
|
||||
"javascript:alert(1)",
|
||||
"ftp://example.com/challenge",
|
||||
"not a url at all",
|
||||
}
|
||||
for _, bad := range cases {
|
||||
t.Run(bad, func(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 21000,
|
||||
"msg": "challenge required",
|
||||
"data": map[string]any{"challenge_url": bad, "hint": "h"},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "" {
|
||||
t.Errorf("ChallengeURL should be dropped for %q, got %q", bad, spe.ChallengeURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyNoData pins the no-data case — typed
|
||||
// envelope still routes correctly with empty extension fields when the
|
||||
// upstream response carries only code+msg.
|
||||
func TestBuildAPIError_SecurityPolicyNoData(t *testing.T) {
|
||||
resp := map[string]any{"code": 21000, "msg": "challenge required"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "" {
|
||||
t.Errorf("ChallengeURL should be empty without data; got %q", spe.ChallengeURL)
|
||||
}
|
||||
if spe.Message != "challenge required" {
|
||||
t.Errorf("Message = %q, want challenge required", spe.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyMalformedData pins that malformed `data`
|
||||
// blocks (wrong type, wrong shape, non-string fields) degrade gracefully —
|
||||
// extension fields stay empty, no panic. Server-side bugs or transitional
|
||||
// API shapes must never crash the CLI dispatcher.
|
||||
func TestBuildAPIError_SecurityPolicyMalformedData(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resp map[string]any
|
||||
}{
|
||||
{"data is string not map", map[string]any{"code": 21000, "msg": "x", "data": "oops"}},
|
||||
{"data is array not map", map[string]any{"code": 21000, "msg": "x", "data": []any{1, 2}}},
|
||||
{"data is nil", map[string]any{"code": 21000, "msg": "x", "data": nil}},
|
||||
{"challenge_url is int", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"challenge_url": 123}}},
|
||||
{"challenge_url is nil", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"challenge_url": nil}}},
|
||||
{"hint is array", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"hint": []any{"a"}}}},
|
||||
{"error.data is wrong type", map[string]any{"code": 21000, "msg": "x", "error": map[string]any{"data": "oops"}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("BuildAPIError panicked on malformed data: %v", r)
|
||||
}
|
||||
}()
|
||||
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError even with malformed data, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "" {
|
||||
t.Errorf("ChallengeURL should be empty for malformed data, got %q", spe.ChallengeURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyErrorDataShape pins extraction from the
|
||||
// {"error": {"data": {...}}} envelope variant — same lookup paths the
|
||||
// transport-layer interceptor uses on inbound responses.
|
||||
func TestBuildAPIError_SecurityPolicyErrorDataShape(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 21000,
|
||||
"msg": "challenge required",
|
||||
"error": map[string]any{
|
||||
"data": map[string]any{
|
||||
"challenge_url": "https://passport.feishu.cn/c/abc",
|
||||
"hint": "wrapped variant",
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
spe, ok := err.(*errs.SecurityPolicyError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
||||
}
|
||||
if spe.ChallengeURL != "https://passport.feishu.cn/c/abc" {
|
||||
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/c/abc", spe.ChallengeURL)
|
||||
}
|
||||
if spe.Hint != "wrapped variant" {
|
||||
t.Errorf("Hint = %q, want wrapped variant", spe.Hint)
|
||||
}
|
||||
}
|
||||
87
internal/errclass/codemeta.go
Normal file
87
internal/errclass/codemeta.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// CodeMeta is the classification metadata attached to a Lark numeric code.
|
||||
// It does NOT carry Message or Hint — those are derived at the dispatcher
|
||||
// (see BuildAPIError).
|
||||
type CodeMeta struct {
|
||||
Category errs.Category
|
||||
Subtype errs.Subtype
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
// codeMeta is the central registry. Top-level entries (auth/authorization/api/
|
||||
// policy/config codes shared across services) live here; service-specific
|
||||
// sub-tables (e.g. task) live in dedicated files like codemeta_task.go and
|
||||
// merge into this map via init().
|
||||
//
|
||||
// Go language guarantees package-level vars initialize before init() functions,
|
||||
// so sub-tables registering via init() can always assume codeMeta is non-nil.
|
||||
var codeMeta = map[int]CodeMeta{
|
||||
// CategoryAuthentication
|
||||
99991661: {errs.CategoryAuthentication, errs.SubtypeTokenMissing, false}, // Authorization header missing
|
||||
99991671: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // token format error (must start with t- / u-)
|
||||
99991668: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // UAT invalid/expired (server does not distinguish)
|
||||
99991663: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // access_token invalid
|
||||
99991677: {errs.CategoryAuthentication, errs.SubtypeTokenExpired, false}, // UAT expired
|
||||
20026: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false}, // refresh_token v1 legacy format
|
||||
20037: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false}, // refresh_token expired
|
||||
20064: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false}, // refresh_token revoked
|
||||
20073: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false}, // refresh_token already used
|
||||
20050: {errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true}, // refresh endpoint transient error
|
||||
|
||||
// CategoryAuthorization
|
||||
99991672: {errs.CategoryAuthorization, errs.SubtypeAppScopeNotApplied, false},
|
||||
99991676: {errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, false},
|
||||
99991679: {errs.CategoryAuthorization, errs.SubtypeMissingScope, false}, // user authorized app but did not grant this scope
|
||||
230027: {errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false}, // user never authorized the app
|
||||
99991673: {errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false}, // app status unavailable
|
||||
99991662: {errs.CategoryAuthorization, errs.SubtypeAppNotInstalled, false}, // app not enabled / not installed in tenant
|
||||
|
||||
// CategoryAPI
|
||||
99991400: {errs.CategoryAPI, errs.SubtypeRateLimit, true},
|
||||
1061045: {errs.CategoryAPI, errs.SubtypeConflict, true},
|
||||
131009: {errs.CategoryAPI, errs.SubtypeConflict, true}, // wiki write-path lock contention; retryable with backoff
|
||||
1064510: {errs.CategoryAPI, errs.SubtypeCrossTenant, false},
|
||||
1064511: {errs.CategoryAPI, errs.SubtypeCrossBrand, false},
|
||||
1310246: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
1063006: {errs.CategoryAPI, errs.SubtypeRateLimit, false}, // drive perm-apply quota; 5/day, not short-term retryable
|
||||
1063007: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
231205: {errs.CategoryAPI, errs.SubtypeOwnershipMismatch, false},
|
||||
|
||||
// CategoryConfig
|
||||
99991543: {errs.CategoryConfig, errs.SubtypeInvalidClient, false}, // RFC 6749 §5.2 — app_id / app_secret incorrect
|
||||
|
||||
// CategoryPolicy
|
||||
21000: {errs.CategoryPolicy, errs.SubtypeChallengeRequired, false},
|
||||
21001: {errs.CategoryPolicy, errs.SubtypeAccessDenied, false},
|
||||
}
|
||||
|
||||
// LookupCodeMeta is the single lookup entry. Returns ok=false for unknown codes —
|
||||
// the caller (BuildAPIError) is responsible for falling back to
|
||||
// CategoryAPI/SubtypeUnknown.
|
||||
func LookupCodeMeta(code int) (CodeMeta, bool) {
|
||||
m, ok := codeMeta[code]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// mergeCodeMeta is invoked by sub-table init() functions to merge service-specific
|
||||
// codes into the central registry. Panics on duplicate code so a misregistration
|
||||
// fails fast at startup rather than producing silently-inconsistent classification.
|
||||
func mergeCodeMeta(src map[int]CodeMeta, owner string) {
|
||||
for code, meta := range src {
|
||||
if existing, dup := codeMeta[code]; dup {
|
||||
panic(fmt.Sprintf("codeMeta dup: code %d already mapped %+v; %s wants %+v",
|
||||
code, existing, owner, meta))
|
||||
}
|
||||
codeMeta[code] = meta
|
||||
}
|
||||
}
|
||||
24
internal/errclass/codemeta_task.go
Normal file
24
internal/errclass/codemeta_task.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// taskCodeMeta holds the task-service-specific Lark code classifications.
|
||||
// 1470403 permission_denied is CategoryAuthorization (exit 3); the other task
|
||||
// codes route to CategoryAPI / CategoryValidation. BuildAPIError consumes this
|
||||
// map via mergeCodeMeta + LookupCodeMeta.
|
||||
var taskCodeMeta = map[int]CodeMeta{
|
||||
1470400: {errs.CategoryValidation, errs.SubtypeTaskInvalidParams, false},
|
||||
1470403: {errs.CategoryAuthorization, errs.SubtypeTaskPermissionDenied, false}, // permission_denied
|
||||
1470404: {errs.CategoryAPI, errs.SubtypeTaskNotFound, false},
|
||||
1470422: {errs.CategoryAPI, errs.SubtypeTaskConflict, true},
|
||||
1470500: {errs.CategoryAPI, errs.SubtypeTaskServerError, true},
|
||||
1470610: {errs.CategoryAPI, errs.SubtypeTaskAssigneeLimit, false},
|
||||
1470611: {errs.CategoryAPI, errs.SubtypeTaskFollowerLimit, false},
|
||||
1470612: {errs.CategoryAPI, errs.SubtypeTaskTasklistMemberLimit, false},
|
||||
1470613: {errs.CategoryAPI, errs.SubtypeTaskReminderExists, false},
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(taskCodeMeta, "task") }
|
||||
105
internal/errclass/codemeta_test.go
Normal file
105
internal/errclass/codemeta_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestLookupCodeMeta_MissingScope(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(99991679)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(99991679) ok=false, want true")
|
||||
}
|
||||
want := CodeMeta{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope, Retryable: false}
|
||||
if got != want {
|
||||
t.Fatalf("LookupCodeMeta(99991679) = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(1470403)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(1470403) ok=false, want true (task sub-table init merge)")
|
||||
}
|
||||
if got.Category != errs.CategoryAuthorization {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||
}
|
||||
if got.Subtype != errs.Subtype("task_permission_denied") {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, "task_permission_denied")
|
||||
}
|
||||
if got.Retryable {
|
||||
t.Errorf("Retryable = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(20050)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(20050) ok=false, want true")
|
||||
}
|
||||
if !got.Retryable {
|
||||
t.Errorf("LookupCodeMeta(20050).Retryable = false, want true (sole retryable refresh code)")
|
||||
}
|
||||
if got.Category != errs.CategoryAuthentication {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthentication)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(99991400)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(99991400) ok=false, want true")
|
||||
}
|
||||
if !got.Retryable {
|
||||
t.Errorf("LookupCodeMeta(99991400).Retryable = false, want true (rate_limit retryable)")
|
||||
}
|
||||
if got.Subtype != errs.SubtypeRateLimit {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypeRateLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_Unknown(t *testing.T) {
|
||||
_, ok := LookupCodeMeta(999999)
|
||||
if ok {
|
||||
t.Fatalf("LookupCodeMeta(999999) ok=true, want false for unknown code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_PolicyChallengeRequired(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(21000)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(21000) ok=false, want true")
|
||||
}
|
||||
if got.Category != errs.CategoryPolicy {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryPolicy)
|
||||
}
|
||||
if got.Subtype != errs.Subtype("challenge_required") {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, "challenge_required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCodeMeta_PanicsOnDuplicate(t *testing.T) {
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatalf("mergeCodeMeta with duplicate code did not panic")
|
||||
}
|
||||
msg, ok := r.(string)
|
||||
if !ok {
|
||||
t.Fatalf("panic value is not a string: %T (%v)", r, r)
|
||||
}
|
||||
for _, needle := range []string{"1470403", "task_permission_denied", "intruder", "test"} {
|
||||
if !strings.Contains(msg, needle) {
|
||||
t.Errorf("panic message %q missing substring %q", msg, needle)
|
||||
}
|
||||
}
|
||||
}()
|
||||
mergeCodeMeta(map[int]CodeMeta{
|
||||
1470403: {Category: errs.CategoryAPI, Subtype: errs.Subtype("intruder")},
|
||||
}, "test")
|
||||
}
|
||||
32
internal/errcompat/promote.go
Normal file
32
internal/errcompat/promote.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package errcompat bridges the legacy *core.ConfigError shape into the
|
||||
// canonical typed errors taxonomy in errs/. It is a thin boundary helper —
|
||||
// placed in its own package so it can import both core (for the legacy
|
||||
// type) and errs (for the typed targets) without creating an import cycle
|
||||
// with internal/errclass, which intentionally avoids depending on
|
||||
// internal/core.
|
||||
package errcompat
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// PromoteConfigError is the stage-2 boundary helper that will convert a
|
||||
// *core.ConfigError into the matching typed errs.* error. In stage 1 it
|
||||
// is a passthrough — the dispatcher continues to render *core.ConfigError
|
||||
// via the legacy envelope path (cmd/root.go asExitError) so the wire
|
||||
// shape stays identical to pre-PR. Per-domain typed migration in stage 2+
|
||||
// will fill in the actual promotion logic alongside its corresponding
|
||||
// wire-change announcement.
|
||||
func PromoteConfigError(cfgErr *core.ConfigError) error {
|
||||
if cfgErr == nil {
|
||||
return nil
|
||||
}
|
||||
return cfgErr
|
||||
}
|
||||
|
||||
// _ keeps the errs import live so stage-2 fill-in does not need to re-add it.
|
||||
var _ = errs.CategoryConfig
|
||||
37
internal/errcompat/promote_test.go
Normal file
37
internal/errcompat/promote_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errcompat_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
)
|
||||
|
||||
// TestPromoteConfigError_Stage1Passthrough pins the stage-1 passthrough
|
||||
// behaviour: every input *core.ConfigError flows out unchanged so the
|
||||
// dispatcher's legacy envelope path emits the same wire shape as pre-PR.
|
||||
// Per-domain typed migration will replace this in stage 2+.
|
||||
func TestPromoteConfigError_Stage1Passthrough(t *testing.T) {
|
||||
for _, cfgType := range []string{"config", "auth", "openclaw", ""} {
|
||||
t.Run(cfgType, func(t *testing.T) {
|
||||
src := &core.ConfigError{Code: 3, Type: cfgType, Message: "msg", Hint: "hint"}
|
||||
out := errcompat.PromoteConfigError(src)
|
||||
var got *core.ConfigError
|
||||
if !errors.As(out, &got) || got != src {
|
||||
t.Fatalf("Type=%q: expected passthrough of original *core.ConfigError, got %T (%v)", cfgType, out, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteConfigError_NilInputReturnsNil pins that PromoteConfigError on a
|
||||
// nil input returns nil rather than panicking on the (cfgErr.Type) access.
|
||||
func TestPromoteConfigError_NilInputReturnsNil(t *testing.T) {
|
||||
if got := errcompat.PromoteConfigError(nil); got != nil {
|
||||
t.Errorf("PromoteConfigError(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,13 @@ func runObserverSafe(ctx context.Context, obs ObserverEntry, inv platform.Invoca
|
||||
// *output.ExitError so cmd/root.go's envelope writer emits the right
|
||||
// JSON structure (type="hook"). Non-AbortError values pass through
|
||||
// unchanged.
|
||||
//
|
||||
// Deprecated: wrapAbortError converts to a legacy *output.ExitError that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// add producers of this shape — hook abort signals should move to a typed
|
||||
// *errs.XxxError (typed hook error is tracked for the hook framework
|
||||
// migration PR). This helper is retained only while existing call sites are
|
||||
// migrated; it will be removed once they have moved to the typed surface.
|
||||
func wrapAbortError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@@ -41,6 +41,7 @@ func wrapError(op string, err error) error {
|
||||
if errors.Is(err, errNotInitialized) {
|
||||
hint = "The keychain master key may have been cleaned up or deleted. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain, you can try running this outside the sandbox. Otherwise, please reconfigure the CLI by running lark-cli config init."
|
||||
}
|
||||
hint += extraHint(err)
|
||||
|
||||
func() {
|
||||
defer func() { recover() }()
|
||||
|
||||
@@ -43,6 +43,12 @@ var keyringGet = keyring.Get
|
||||
// keyringSet is overridden in tests to simulate system keychain writes.
|
||||
var keyringSet = keyring.Set
|
||||
|
||||
// errKeychainBlocked is returned when the OS Keychain is reachable but
|
||||
// denies access — sandbox restriction, user-denied prompt, or a 5-second
|
||||
// timeout (typically caused by an ignored permission dialog). Distinct
|
||||
// from errNotInitialized (master key entry genuinely absent).
|
||||
var errKeychainBlocked = errors.New("keychain access blocked")
|
||||
|
||||
// StorageDir returns the storage directory for a given service name on macOS.
|
||||
func StorageDir(service string) string {
|
||||
home, err := vfs.UserHomeDir()
|
||||
@@ -85,7 +91,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
return
|
||||
} else if !errors.Is(err, keyring.ErrNotFound) {
|
||||
// Not ErrNotFound, which means access was denied or blocked by the system
|
||||
resCh <- result{key: nil, err: errors.New("keychain access blocked")}
|
||||
resCh <- result{key: nil, err: errKeychainBlocked}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,7 +123,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
return res.key, res.err
|
||||
case <-ctx.Done():
|
||||
// Timeout is usually caused by ignored/blocked permission prompts
|
||||
return nil, errors.New("keychain access blocked")
|
||||
return nil, errKeychainBlocked
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,11 +271,7 @@ func platformGet(service, account string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := decryptData(data, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return plaintext, nil
|
||||
return decryptData(data, key)
|
||||
}
|
||||
|
||||
// platformSet stores a value in the macOS keychain.
|
||||
@@ -316,3 +318,116 @@ func platformRemove(service, account string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DowngradeResult reports what DowngradeMasterKeyToFile did. The command
|
||||
// never writes to or removes from the OS Keychain — it only reads from it
|
||||
// and only writes to the local file fallback.
|
||||
type DowngradeResult int
|
||||
|
||||
const (
|
||||
// DowngradeAlreadyDone means master.key.file was already present and valid.
|
||||
DowngradeAlreadyDone DowngradeResult = iota
|
||||
// DowngradeUsedKeychainKey means the existing OS Keychain master key was
|
||||
// copied verbatim into the local file fallback. Existing .enc credentials
|
||||
// remain readable via the file path.
|
||||
DowngradeUsedKeychainKey
|
||||
// DowngradeCreatedNewKey means the OS Keychain held no master key, so a
|
||||
// fresh random key was generated and written to the file fallback only.
|
||||
// The OS Keychain was not touched.
|
||||
DowngradeCreatedNewKey
|
||||
)
|
||||
|
||||
// MasterKeyFilePath returns the absolute path of the file fallback master key
|
||||
// for the given service.
|
||||
func MasterKeyFilePath(service string) string {
|
||||
return filepath.Join(StorageDir(service), fileMasterKeyName)
|
||||
}
|
||||
|
||||
// DowngradeMasterKeyToFile materializes the OS Keychain master key into the
|
||||
// local file fallback so that subsequent platformGet calls take the file-first
|
||||
// path and bypass the OS Keychain entirely. The Keychain entry itself is kept
|
||||
// as a cold backup; nothing is removed there.
|
||||
//
|
||||
// Idempotent: if master.key.file is already present and valid, returns
|
||||
// DowngradeAlreadyDone without touching anything.
|
||||
func DowngradeMasterKeyToFile(service string) (DowngradeResult, error) {
|
||||
dir := StorageDir(service)
|
||||
keyPath := filepath.Join(dir, fileMasterKeyName)
|
||||
|
||||
existing, statErr := vfs.ReadFile(keyPath)
|
||||
if statErr == nil {
|
||||
if len(existing) == masterKeyBytes {
|
||||
return DowngradeAlreadyDone, nil
|
||||
}
|
||||
return 0, errors.New("keychain is corrupted")
|
||||
}
|
||||
if !errors.Is(statErr, os.ErrNotExist) {
|
||||
return 0, statErr
|
||||
}
|
||||
|
||||
result := DowngradeUsedKeychainKey
|
||||
key, err := getMasterKey(service, false)
|
||||
if err != nil {
|
||||
if !errors.Is(err, errNotInitialized) {
|
||||
return 0, err
|
||||
}
|
||||
// Keychain has no master key. Generate a fresh one *locally* — do
|
||||
// NOT call getMasterKey(service, true), which would write the new
|
||||
// key into the OS Keychain as a side effect. keychain-downgrade
|
||||
// must never modify the OS Keychain; it only ever reads from it.
|
||||
key = make([]byte, masterKeyBytes)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
result = DowngradeCreatedNewKey
|
||||
}
|
||||
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
file, err := vfs.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
concurrent, readErr := vfs.ReadFile(keyPath)
|
||||
if readErr == nil && len(concurrent) == masterKeyBytes {
|
||||
return DowngradeAlreadyDone, nil
|
||||
}
|
||||
if readErr != nil {
|
||||
return 0, readErr
|
||||
}
|
||||
return 0, errors.New("keychain is corrupted")
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
writeFailed := true
|
||||
defer func() {
|
||||
if writeFailed {
|
||||
_ = vfs.Remove(keyPath)
|
||||
}
|
||||
}()
|
||||
if _, err := file.Write(key); err != nil {
|
||||
_ = file.Close()
|
||||
return 0, err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
writeFailed = false
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// extraHint appends a darwin-specific suggestion to wrapError's hint message
|
||||
// when the failure is one keychain-downgrade can recover from: either the
|
||||
// master key is missing (errNotInitialized) or the OS Keychain is reachable
|
||||
// but blocking access (errKeychainBlocked — sandbox, denied prompt, timeout).
|
||||
// In both cases the user can run keychain-downgrade from an interactive
|
||||
// Terminal session, after which the file fallback is readable from any
|
||||
// context (sandbox, automation, CI, etc.). Corruption errors are
|
||||
// deliberately excluded — downgrade would re-read the same bad bytes and
|
||||
// fail; the right fix there is to delete the corrupt Keychain entry first.
|
||||
func extraHint(err error) string {
|
||||
if errors.Is(err, errNotInitialized) || errors.Is(err, errKeychainBlocked) {
|
||||
return " On macOS, you can also open an interactive Terminal session (where the system Keychain is reachable) and run `lark-cli config keychain-downgrade` to materialize the master key into a local file; subsequent runs in this sandbox/automation context will then read from the file and succeed. Trade-off: after downgrade, any process running as your macOS user can read that file (file permissions replace the Keychain's per-app ACL)."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
@@ -111,6 +113,305 @@ func TestPlatformGetPrefersFileMasterKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDowngradeAlreadyDoneIsIdempotent verifies that re-running downgrade
|
||||
// when master.key.file already exists is a no-op and reports AlreadyDone
|
||||
// without touching the system keychain.
|
||||
func TestDowngradeAlreadyDoneIsIdempotent(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(service, user string) (string, error) {
|
||||
t.Fatalf("keyringGet should not be called when master.key.file is already valid")
|
||||
return "", nil
|
||||
}
|
||||
keyringSet = func(service, user, password string) error {
|
||||
t.Fatalf("keyringSet should not be called when master.key.file is already valid")
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
service := "test-service"
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
preExisting := make([]byte, masterKeyBytes)
|
||||
for i := range preExisting {
|
||||
preExisting[i] = byte(i + 7)
|
||||
}
|
||||
keyPath := filepath.Join(dir, fileMasterKeyName)
|
||||
if err := os.WriteFile(keyPath, preExisting, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(master key) error = %v", err)
|
||||
}
|
||||
|
||||
result, err := DowngradeMasterKeyToFile(service)
|
||||
if err != nil {
|
||||
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
|
||||
}
|
||||
if result != DowngradeAlreadyDone {
|
||||
t.Fatalf("result = %v, want DowngradeAlreadyDone", result)
|
||||
}
|
||||
|
||||
after, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if !bytesEqual(after, preExisting) {
|
||||
t.Fatalf("master.key.file content changed; want preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDowngradeCopiesKeychainKeyToFile verifies the happy path: a keychain
|
||||
// key exists, the file does not, and downgrade copies the bytes verbatim
|
||||
// so that existing .enc files (encrypted with the keychain key) remain
|
||||
// readable via the file fallback.
|
||||
func TestDowngradeCopiesKeychainKeyToFile(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
keychainKey := make([]byte, masterKeyBytes)
|
||||
for i := range keychainKey {
|
||||
keychainKey[i] = byte(i + 11)
|
||||
}
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(service, user string) (string, error) {
|
||||
return base64.StdEncoding.EncodeToString(keychainKey), nil
|
||||
}
|
||||
keyringSet = func(service, user, password string) error {
|
||||
t.Fatalf("keyringSet should not be called when keychain already has a master key")
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
service := "test-service"
|
||||
|
||||
result, err := DowngradeMasterKeyToFile(service)
|
||||
if err != nil {
|
||||
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
|
||||
}
|
||||
if result != DowngradeUsedKeychainKey {
|
||||
t.Fatalf("result = %v, want DowngradeUsedKeychainKey", result)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(MasterKeyFilePath(service))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(master.key.file) error = %v", err)
|
||||
}
|
||||
if !bytesEqual(got, keychainKey) {
|
||||
t.Fatalf("file key bytes do not match keychain key; existing .enc files would become unreadable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDowngradeCreatesNewKeyWhenStorageEmpty verifies the "fresh user"
|
||||
// path: keychain is empty and no .enc files exist, so we generate a new
|
||||
// random key and write it to the file fallback. The OS Keychain is NOT
|
||||
// modified (regression guard for the side-effecting getMasterKey(_, true)
|
||||
// call we used to make).
|
||||
func TestDowngradeCreatesNewKeyWhenStorageEmpty(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(service, user string) (string, error) {
|
||||
return "", keyring.ErrNotFound
|
||||
}
|
||||
keyringSet = func(service, user, password string) error {
|
||||
t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain")
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
service := "test-service"
|
||||
|
||||
result, err := DowngradeMasterKeyToFile(service)
|
||||
if err != nil {
|
||||
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
|
||||
}
|
||||
if result != DowngradeCreatedNewKey {
|
||||
t.Fatalf("result = %v, want DowngradeCreatedNewKey", result)
|
||||
}
|
||||
|
||||
fileKey, err := os.ReadFile(MasterKeyFilePath(service))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(master.key.file) error = %v", err)
|
||||
}
|
||||
if len(fileKey) != masterKeyBytes {
|
||||
t.Fatalf("file key length = %d, want %d", len(fileKey), masterKeyBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDowngradeDoesNotClobberConcurrentlyWrittenKey is the regression guard
|
||||
// for the TOCTOU between the initial existence check and the final write.
|
||||
// Race trace the fix closes:
|
||||
//
|
||||
// T0 proc A: ReadFile(keyPath) → ErrNotExist (initial check passes)
|
||||
// T1 proc B: platformSet → getFileMasterKey(_, true) creates keyPath with K_B
|
||||
// then writes .enc encrypted with K_B
|
||||
// T2 proc A: rand.Read → K_A; would overwrite K_B and orphan B's .enc
|
||||
//
|
||||
// We simulate proc B's interleaving by performing the concurrent file write
|
||||
// inside the keyringGet hook — by the time DowngradeMasterKeyToFile gets back
|
||||
// to the final OpenFile call, the file already exists, the O_EXCL branch
|
||||
// fires, and the concurrent key is preserved verbatim.
|
||||
func TestDowngradeDoesNotClobberConcurrentlyWrittenKey(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
service := "test-service"
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
|
||||
concurrentKey := make([]byte, masterKeyBytes)
|
||||
for i := range concurrentKey {
|
||||
concurrentKey[i] = byte(i + 77)
|
||||
}
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(svc, user string) (string, error) {
|
||||
if err := os.WriteFile(filepath.Join(dir, fileMasterKeyName), concurrentKey, 0600); err != nil {
|
||||
t.Fatalf("simulated concurrent write failed: %v", err)
|
||||
}
|
||||
return "", keyring.ErrNotFound
|
||||
}
|
||||
keyringSet = func(svc, user, password string) error {
|
||||
t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain")
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
result, err := DowngradeMasterKeyToFile(service)
|
||||
if err != nil {
|
||||
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
|
||||
}
|
||||
if result != DowngradeAlreadyDone {
|
||||
t.Fatalf("result = %v, want DowngradeAlreadyDone (concurrent write must be preserved)", result)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, fileMasterKeyName))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile error = %v", err)
|
||||
}
|
||||
if !bytesEqual(got, concurrentKey) {
|
||||
t.Fatalf("master.key.file was clobbered; concurrent platformSet's encrypted credentials would be orphaned")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformGetSurfacesKeychainBlocked verifies that "keychain access blocked"
|
||||
// (the sandbox case) propagates as errKeychainBlocked through platformGet, so
|
||||
// the wrapError hint chain can attach the keychain-downgrade suggestion.
|
||||
func TestPlatformGetSurfacesKeychainBlocked(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(service, user string) (string, error) {
|
||||
return "", errors.New("sandbox denied keychain access")
|
||||
}
|
||||
keyringSet = func(service, user, password string) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
service := "test-service"
|
||||
account := "test-account"
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
|
||||
lostKey := make([]byte, masterKeyBytes)
|
||||
for i := range lostKey {
|
||||
lostKey[i] = byte(i + 55)
|
||||
}
|
||||
encrypted, err := encryptData("secret", lostKey)
|
||||
if err != nil {
|
||||
t.Fatalf("encryptData() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), encrypted, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(.enc) error = %v", err)
|
||||
}
|
||||
|
||||
_, err = platformGet(service, account)
|
||||
if !errors.Is(err, errKeychainBlocked) {
|
||||
t.Fatalf("err = %v, want errKeychainBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapErrorHintMentionsDowngradeForRecoverableCases is the regression
|
||||
// guard for the bug where `lark-cli api ...` inside a sandbox surfaced
|
||||
// "keychain access blocked" but the hint did NOT mention keychain-downgrade
|
||||
// — the very command meant to recover from that exact situation. Root cause:
|
||||
// the blocked path used an anonymous errors.New string, so the extraHint
|
||||
// `errors.Is` check (only matched errNotInitialized) couldn't recognize it.
|
||||
//
|
||||
// Asserts the full wrapError → ExitError.Detail.Hint pipeline:
|
||||
// - errKeychainBlocked + errNotInitialized → hint mentions keychain-downgrade
|
||||
// - "keychain is corrupted" (downgrade would re-read the same bad bytes) → no mention
|
||||
// - generic errors → no mention
|
||||
//
|
||||
// Add new cases here whenever extraHint's matcher widens, to keep the
|
||||
// promise that the hint is suggested iff downgrade can actually help.
|
||||
func TestWrapErrorHintMentionsDowngradeForRecoverableCases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
wantHint bool
|
||||
}{
|
||||
{"access blocked (sandbox / denied prompt / timeout)", errKeychainBlocked, true},
|
||||
{"not initialized (missing master key)", errNotInitialized, true},
|
||||
{"corrupted (downgrade would re-read the same bad bytes)", errors.New("keychain is corrupted"), false},
|
||||
{"unrelated generic error", errors.New("something else entirely"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := wrapError("Get", tc.err)
|
||||
var ee *output.ExitError
|
||||
if !errors.As(err, &ee) || ee.Detail == nil {
|
||||
t.Fatalf("wrapError returned %#v; expected *output.ExitError with Detail", err)
|
||||
}
|
||||
got := strings.Contains(ee.Detail.Hint, "keychain-downgrade")
|
||||
if got != tc.wantHint {
|
||||
t.Fatalf("hint mentions keychain-downgrade = %v, want %v\n full hint: %q", got, tc.wantHint, ee.Detail.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestPlatformSetPrefersExistingFileMasterKey verifies writes stay on the file-based
|
||||
// master key path once the fallback master key already exists.
|
||||
func TestPlatformSetPrefersExistingFileMasterKey(t *testing.T) {
|
||||
|
||||
10
internal/keychain/keychain_hint_other.go
Normal file
10
internal/keychain/keychain_hint_other.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !darwin
|
||||
|
||||
package keychain
|
||||
|
||||
// extraHint is a no-op on non-darwin platforms. The keychain-downgrade
|
||||
// command is macOS-only, so there is no extra suggestion to surface.
|
||||
func extraHint(err error) string { return "" }
|
||||
@@ -14,6 +14,14 @@ type Envelope struct {
|
||||
}
|
||||
|
||||
// ErrorEnvelope is the standard error response wrapper.
|
||||
//
|
||||
// Deprecated: ErrorEnvelope belongs to the legacy *output.ExitError surface
|
||||
// that predates the typed error contract introduced by errs/. New code MUST
|
||||
// NOT use it — the typed envelope shape is owned by
|
||||
// internal/output.WriteTypedErrorEnvelope which marshals typed errs.* errors
|
||||
// directly via JSON reflection (no wrapper struct needed). This struct is
|
||||
// retained only while existing *ExitError call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
type ErrorEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
@@ -23,6 +31,13 @@ type ErrorEnvelope struct {
|
||||
}
|
||||
|
||||
// ErrDetail describes a structured error.
|
||||
//
|
||||
// Deprecated: ErrDetail belongs to the legacy *output.ExitError surface that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — typed errs.* structs embed errs.Problem and own their wire shape
|
||||
// via JSON tags (Category, Subtype, Hint, etc. promote to the top level).
|
||||
// This struct is retained only while existing *ExitError call sites are
|
||||
// migrated; it will be removed once they have moved to the typed surface.
|
||||
type ErrDetail struct {
|
||||
Type string `json:"type"`
|
||||
Code int `json:"code,omitempty"`
|
||||
@@ -37,6 +52,14 @@ type ErrDetail struct {
|
||||
// confirmation_required errors. Level is one of "read" | "write" |
|
||||
// "high-risk-write". Action identifies the command for the agent (e.g.
|
||||
// "mail +send", "drive.files.delete").
|
||||
//
|
||||
// Deprecated: RiskDetail is reachable only via *output.ExitError.Detail.Risk,
|
||||
// part of the legacy envelope surface that predates the typed error contract
|
||||
// introduced by errs/. New code MUST NOT use it — confirmation-required
|
||||
// signals belong on *errs.ConfirmationRequiredError (its own typed extension
|
||||
// fields can carry agent-protocol metadata directly). This struct is
|
||||
// retained only while existing *ExitError call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
type RiskDetail struct {
|
||||
Level string `json:"level"`
|
||||
Action string `json:"action"`
|
||||
|
||||
@@ -9,16 +9,26 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// ExitError is a structured error that carries an exit code and optional detail.
|
||||
// It is propagated up the call chain and handled by main.go to produce
|
||||
// a JSON error envelope on stderr and the correct exit code.
|
||||
//
|
||||
// Deprecated: *output.ExitError is the legacy error type that predates the
|
||||
// typed error contract introduced by errs/. New code MUST NOT instantiate it
|
||||
// — return a typed *errs.XxxError (see errs/ for the available categories:
|
||||
// *AuthenticationError / *PermissionError / *ValidationError / *NetworkError /
|
||||
// *APIError / *InternalError / etc.). This type is retained only while
|
||||
// existing call sites are migrated; it will be removed once they have moved
|
||||
// to the typed surface.
|
||||
type ExitError struct {
|
||||
Code int
|
||||
Detail *ErrDetail
|
||||
Err error
|
||||
Raw bool // when true, skip enrichment (e.g. enrichPermissionError) and preserve original error
|
||||
Raw bool // when true, the dispatcher skips enrichment (e.g. enrichPermissionError) and preserves the original error detail
|
||||
}
|
||||
|
||||
func (e *ExitError) Error() string {
|
||||
@@ -35,7 +45,31 @@ func (e *ExitError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// MarkRaw sets Raw=true on an ExitError so that the dispatcher skips
|
||||
// enrichment (e.g. enrichPermissionError, enrichMissingScopeError) and
|
||||
// preserves the original API error detail. Returns the original error
|
||||
// unchanged if it is not (or does not wrap) an ExitError.
|
||||
//
|
||||
// Used by `cmd/api` and other "passthrough" call sites where the caller
|
||||
// explicitly wants the raw Lark API detail (log_id, troubleshooter, etc.)
|
||||
// on the wire rather than the enriched message/hint variant.
|
||||
func MarkRaw(err error) error {
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
exitErr.Raw = true
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteErrorEnvelope writes a JSON error envelope for the given ExitError to w.
|
||||
//
|
||||
// Deprecated: WriteErrorEnvelope is the legacy envelope writer paired with
|
||||
// *output.ExitError, which predates the typed error contract introduced by
|
||||
// errs/. New code MUST NOT call this directly — return a typed *errs.XxxError
|
||||
// from the command, and cmd/root.go handleRootError will dispatch through
|
||||
// WriteTypedErrorEnvelope. This writer is retained only while existing
|
||||
// *ExitError producers are migrated; it will be removed once they have moved
|
||||
// to the typed surface.
|
||||
func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
|
||||
if err.Detail == nil {
|
||||
return
|
||||
@@ -60,6 +94,13 @@ func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
|
||||
// --- Convenience constructors ---
|
||||
|
||||
// Errorf creates an ExitError with the given code, type, and formatted message.
|
||||
//
|
||||
// Deprecated: Errorf belongs to the legacy *output.ExitError surface that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — construct a typed *errs.XxxError directly (e.g.
|
||||
// *errs.ValidationError, *errs.InternalError). This helper is retained only
|
||||
// while existing call sites are migrated; it will be removed once they have
|
||||
// moved to the typed surface.
|
||||
func Errorf(code int, errType, format string, args ...any) *ExitError {
|
||||
var err error
|
||||
for _, arg := range args {
|
||||
@@ -75,23 +116,58 @@ func Errorf(code int, errType, format string, args ...any) *ExitError {
|
||||
}
|
||||
}
|
||||
|
||||
// ErrValidation creates a validation ExitError (exit 2).
|
||||
// ErrValidation creates a validation ExitError (exit 2, wire type
|
||||
// "validation"). The legacy *output.ExitError envelope emits only
|
||||
// `type`+`message` — no `subtype`/`param` extension fields.
|
||||
//
|
||||
// Stage-1 status: still acceptable to use in new code that only needs the
|
||||
// (type, message) pair. To carry extension fields (Subtype, Param, etc.)
|
||||
// on the wire, construct `&errs.ValidationError{...}` directly so
|
||||
// cmd/root.go routes it through the typed envelope writer. Per-domain
|
||||
// typed migration in stage 2+ will migrate existing call sites and
|
||||
// remove this helper.
|
||||
func ErrValidation(format string, args ...any) *ExitError {
|
||||
return Errorf(ExitValidation, "validation", format, args...)
|
||||
}
|
||||
|
||||
// ErrAuth creates an auth ExitError (exit 3).
|
||||
// ErrAuth creates an authentication ExitError (exit 3, wire type "auth").
|
||||
//
|
||||
// Stage-1 status: kept as the canonical helper for token-missing /
|
||||
// login-required errors, so the 19 existing call sites in cmd/auth,
|
||||
// cmd/config, cmd/event, internal/client, and shortcuts/common keep
|
||||
// emitting `type: "auth"`. To migrate a single call site to the typed
|
||||
// taxonomy (`type: "authentication"` on the wire), construct
|
||||
// `&errs.AuthenticationError{...}` directly — but note that flips a
|
||||
// user-visible wire field and belongs in the per-domain stage-2 PR for
|
||||
// that area, not in unrelated new code.
|
||||
func ErrAuth(format string, args ...any) *ExitError {
|
||||
return Errorf(ExitAuth, "auth", format, args...)
|
||||
}
|
||||
|
||||
// ErrNetwork creates a network ExitError (exit 4).
|
||||
// ErrNetwork creates a network ExitError (exit 4, wire type "network").
|
||||
// The legacy *output.ExitError envelope emits only `type`+`message` — no
|
||||
// `subtype`/`cause` extension fields.
|
||||
//
|
||||
// Stage-1 status: still acceptable to use in new code that only needs the
|
||||
// (type, message) pair. To carry extension fields (Subtype "transport" /
|
||||
// "timeout" / "tls" / "dns", retryable hint, etc.) on the wire, construct
|
||||
// `&errs.NetworkError{...}` directly. Per-domain typed migration in
|
||||
// stage 2+ will migrate existing call sites and remove this helper.
|
||||
func ErrNetwork(format string, args ...any) *ExitError {
|
||||
return Errorf(ExitNetwork, "network", format, args...)
|
||||
}
|
||||
|
||||
// ErrAPI creates an API ExitError using ClassifyLarkError.
|
||||
// For permission errors, uses a concise message; the raw API response is preserved in Detail.
|
||||
//
|
||||
// Deprecated: ErrAPI belongs to the legacy *output.ExitError surface that
|
||||
// predates the typed error contract introduced by errs/. New code SHOULD
|
||||
// construct a typed *errs.XxxError directly. The stage-2+ migration will
|
||||
// route classification through internal/errclass.BuildAPIError (shipped
|
||||
// but not yet invoked from production paths) so the typed envelope carries
|
||||
// Category, Subtype, MissingScopes, ConsoleURL, and Identity from the
|
||||
// source. This helper is retained only while existing call sites are
|
||||
// migrated; it will be removed once they have moved to the typed surface.
|
||||
func ErrAPI(larkCode int, msg string, detail any) *ExitError {
|
||||
exitCode, errType, hint := ClassifyLarkError(larkCode, msg)
|
||||
if errType == "permission" {
|
||||
@@ -110,6 +186,13 @@ func ErrAPI(larkCode int, msg string, detail any) *ExitError {
|
||||
}
|
||||
|
||||
// ErrWithHint creates an ExitError with a hint string.
|
||||
//
|
||||
// Deprecated: ErrWithHint belongs to the legacy *output.ExitError surface
|
||||
// that predates the typed error contract introduced by errs/. New code MUST
|
||||
// NOT use it — construct a typed *errs.XxxError directly and set its Hint
|
||||
// field (the typed envelope promotes Problem.Hint to the wire). This helper
|
||||
// is retained only while existing call sites are migrated; it will be
|
||||
// removed once they have moved to the typed surface.
|
||||
func ErrWithHint(code int, errType, msg, hint string) *ExitError {
|
||||
return &ExitError{
|
||||
Code: code,
|
||||
@@ -119,17 +202,62 @@ func ErrWithHint(code int, errType, msg, hint string) *ExitError {
|
||||
|
||||
// ErrBare creates an ExitError with only an exit code and no envelope.
|
||||
// Used for cases like `auth check` where the JSON output is already written to stdout.
|
||||
//
|
||||
// Deprecated: ErrBare belongs to the legacy *output.ExitError surface that
|
||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
||||
// use it — express the "exit with code, emit no envelope" semantics
|
||||
// explicitly at the call site (e.g. return a typed *errs.XxxError or call
|
||||
// os.Exit directly from RunE). This helper is retained only while existing
|
||||
// call sites are migrated; it will be removed once they have moved to the
|
||||
// typed surface.
|
||||
func ErrBare(code int) *ExitError {
|
||||
return &ExitError{Code: code}
|
||||
}
|
||||
|
||||
// MarkRaw sets Raw=true on an ExitError so that enrichment (e.g. enrichPermissionError)
|
||||
// is skipped and the original API error is preserved. Returns the original error unchanged
|
||||
// if it is not an ExitError.
|
||||
func MarkRaw(err error) error {
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
exitErr.Raw = true
|
||||
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
|
||||
// Each typed error owns its wire shape via its own struct tags: Problem fields
|
||||
// are promoted to the top level through embedding, and extension fields
|
||||
// (MissingScopes, ChallengeURL, etc.) sit alongside as siblings — not inside
|
||||
// a `detail` sub-object.
|
||||
//
|
||||
// Returns true when err was a typed error (envelope written) and false when
|
||||
// err had no Problem (caller should fall back to WriteErrorEnvelope).
|
||||
func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool {
|
||||
typed, ok := errs.UnwrapTypedError(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return err
|
||||
env := typedEnvelope{
|
||||
OK: false,
|
||||
Identity: identity,
|
||||
Error: typed,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if encErr := enc.Encode(env); encErr != nil {
|
||||
// Encoding failed — emit nothing here and let the dispatcher fall
|
||||
// back to the legacy envelope writer so stderr is never blank.
|
||||
return false
|
||||
}
|
||||
if _, writeErr := buf.WriteTo(w); writeErr != nil {
|
||||
// Write failed mid-envelope. Return false so the dispatcher does
|
||||
// not silently treat a half-written stderr as a successful emit
|
||||
// and skip every other fallback.
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// typedEnvelope wraps a typed error for wire emission. Error is `error` so the
|
||||
// underlying typed error's own json tags determine the inner shape via
|
||||
// encoding/json reflection; Notice mirrors the existing ErrorEnvelope (see
|
||||
// GetNotice in envelope.go).
|
||||
type typedEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
Error error `json:"error"`
|
||||
Notice map[string]interface{} `json:"_notice,omitempty"`
|
||||
}
|
||||
|
||||
@@ -6,40 +6,10 @@ package output
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkRaw_ExitError(t *testing.T) {
|
||||
err := ErrAPI(99991672, "API error: [99991672] scope not enabled", nil)
|
||||
if err.Raw {
|
||||
t.Fatal("expected Raw=false before MarkRaw")
|
||||
}
|
||||
|
||||
result := MarkRaw(err)
|
||||
if result != err {
|
||||
t.Error("expected MarkRaw to return the same error")
|
||||
}
|
||||
if !err.Raw {
|
||||
t.Error("expected Raw=true after MarkRaw")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkRaw_NonExitError(t *testing.T) {
|
||||
plain := fmt.Errorf("some plain error")
|
||||
result := MarkRaw(plain)
|
||||
if result != plain {
|
||||
t.Error("expected MarkRaw to return the same error for non-ExitError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkRaw_Nil(t *testing.T) {
|
||||
result := MarkRaw(nil)
|
||||
if result != nil {
|
||||
t.Error("expected MarkRaw(nil) to return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorEnvelope_WithNotice(t *testing.T) {
|
||||
// Set up PendingNotice
|
||||
origNotice := PendingNotice
|
||||
@@ -148,3 +118,89 @@ func TestGetNotice(t *testing.T) {
|
||||
|
||||
PendingNotice = origNotice
|
||||
}
|
||||
|
||||
// TestErrValidation_LegacyExitErrorShape pins the stage-1 wire contract for
|
||||
// output.ErrValidation: the helper MUST return *output.ExitError (so callers
|
||||
// using errors.As(&exitErr) continue to work), with wire fields restricted
|
||||
// to type+message — no `subtype` emission. The typed envelope shape (which
|
||||
// adds subtype, param, etc.) is reserved for stage-2 per-domain migration.
|
||||
func TestErrValidation_LegacyExitErrorShape(t *testing.T) {
|
||||
err := ErrValidation("bad arg: %s", "x")
|
||||
|
||||
var exitErr *ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("ErrValidation must return *ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != ExitValidation {
|
||||
t.Errorf("Code = %d, want ExitValidation (%d)", exitErr.Code, ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("Detail must be populated")
|
||||
}
|
||||
if exitErr.Detail.Type != "validation" {
|
||||
t.Errorf("Detail.Type = %q, want %q", exitErr.Detail.Type, "validation")
|
||||
}
|
||||
if exitErr.Detail.Message != "bad arg: x" {
|
||||
t.Errorf("Detail.Message = %q, want %q", exitErr.Detail.Message, "bad arg: x")
|
||||
}
|
||||
|
||||
// Wire envelope must have only type+message — no subtype field.
|
||||
var buf bytes.Buffer
|
||||
WriteErrorEnvelope(&buf, exitErr, "user")
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &wire); err != nil {
|
||||
t.Fatalf("envelope JSON parse failed: %v\nraw: %s", err, buf.String())
|
||||
}
|
||||
errObj, ok := wire["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope missing 'error' object; got: %s", buf.String())
|
||||
}
|
||||
if _, hasSubtype := errObj["subtype"]; hasSubtype {
|
||||
t.Errorf("legacy ErrValidation envelope must NOT emit `subtype`; got: %s", buf.String())
|
||||
}
|
||||
if errObj["type"] != "validation" {
|
||||
t.Errorf("envelope error.type = %v, want \"validation\"", errObj["type"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrNetwork_LegacyExitErrorShape pins the stage-1 wire contract for
|
||||
// output.ErrNetwork: same legacy *output.ExitError shape as ErrValidation —
|
||||
// no subtype field, errors.As(&exitErr) must succeed, exit code ExitNetwork.
|
||||
func TestErrNetwork_LegacyExitErrorShape(t *testing.T) {
|
||||
err := ErrNetwork("conn refused: %s", "10.0.0.1")
|
||||
|
||||
var exitErr *ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("ErrNetwork must return *ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != ExitNetwork {
|
||||
t.Errorf("Code = %d, want ExitNetwork (%d)", exitErr.Code, ExitNetwork)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("Detail must be populated")
|
||||
}
|
||||
if exitErr.Detail.Type != "network" {
|
||||
t.Errorf("Detail.Type = %q, want %q", exitErr.Detail.Type, "network")
|
||||
}
|
||||
if exitErr.Detail.Message != "conn refused: 10.0.0.1" {
|
||||
t.Errorf("Detail.Message = %q, want %q", exitErr.Detail.Message, "conn refused: 10.0.0.1")
|
||||
}
|
||||
|
||||
// Wire envelope must have only type+message — no subtype field.
|
||||
var buf bytes.Buffer
|
||||
WriteErrorEnvelope(&buf, exitErr, "user")
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(buf.Bytes(), &wire); err != nil {
|
||||
t.Fatalf("envelope JSON parse failed: %v\nraw: %s", err, buf.String())
|
||||
}
|
||||
errObj, ok := wire["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope missing 'error' object; got: %s", buf.String())
|
||||
}
|
||||
if _, hasSubtype := errObj["subtype"]; hasSubtype {
|
||||
t.Errorf("legacy ErrNetwork envelope must NOT emit `subtype`; got: %s", buf.String())
|
||||
}
|
||||
if errObj["type"] != "network" {
|
||||
t.Errorf("envelope error.type = %v, want \"network\"", errObj["type"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// Fine-grained error types (permission, not_found, rate_limit, etc.)
|
||||
// are communicated via the JSON error envelope's "type" field,
|
||||
// not via exit codes.
|
||||
@@ -16,3 +22,48 @@ const (
|
||||
ExitContentSafety = 6 // content safety violation (block mode)
|
||||
ExitConfirmationRequired = 10 // 高风险操作需要 --yes 确认(agent 协议信号)
|
||||
)
|
||||
|
||||
// ExitCodeForCategory maps an errs.Category to the shell exit code.
|
||||
// Multiple categories may share an exit code (Authentication / Authorization /
|
||||
// Config all map to 3), so the relationship is many-to-one.
|
||||
func ExitCodeForCategory(cat errs.Category) int {
|
||||
switch cat {
|
||||
case errs.CategoryValidation:
|
||||
return ExitValidation
|
||||
case errs.CategoryAuthentication, errs.CategoryAuthorization, errs.CategoryConfig:
|
||||
return ExitAuth
|
||||
case errs.CategoryNetwork:
|
||||
return ExitNetwork
|
||||
case errs.CategoryAPI:
|
||||
return ExitAPI
|
||||
case errs.CategoryPolicy:
|
||||
return ExitContentSafety
|
||||
case errs.CategoryInternal:
|
||||
return ExitInternal
|
||||
case errs.CategoryConfirmation:
|
||||
return ExitConfirmationRequired
|
||||
}
|
||||
return ExitInternal
|
||||
}
|
||||
|
||||
// ExitCodeOf returns the shell exit code for any error.
|
||||
// - typed errors (*errs.PermissionError, *errs.APIError, ...) → routed by Category
|
||||
// - legacy *output.ExitError → uses its own Code field
|
||||
// - *core.ConfigError → reaches the dispatcher as a legacy
|
||||
// *output.ExitError via cmd/root asExitError (stage 1); the typed
|
||||
// promotion path through internal/errcompat.PromoteConfigError is
|
||||
// reserved for stage 2+.
|
||||
// - untyped → ExitInternal
|
||||
func ExitCodeOf(err error) int {
|
||||
if err == nil {
|
||||
return ExitOK
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return ExitCodeForCategory(errs.CategoryOf(err))
|
||||
}
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.Code
|
||||
}
|
||||
return ExitInternal
|
||||
}
|
||||
|
||||
68
internal/output/exitcode_test.go
Normal file
68
internal/output/exitcode_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestExitCodeForCategory(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cat errs.Category
|
||||
want int
|
||||
}{
|
||||
{"validation", errs.CategoryValidation, 2},
|
||||
{"authentication", errs.CategoryAuthentication, 3},
|
||||
{"authorization", errs.CategoryAuthorization, 3},
|
||||
{"config", errs.CategoryConfig, 3},
|
||||
{"network", errs.CategoryNetwork, 4},
|
||||
{"api", errs.CategoryAPI, 1},
|
||||
{"policy", errs.CategoryPolicy, 6},
|
||||
{"internal", errs.CategoryInternal, 5},
|
||||
{"confirmation", errs.CategoryConfirmation, 10},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := ExitCodeForCategory(tc.cat); got != tc.want {
|
||||
t.Errorf("ExitCodeForCategory(%q) = %d, want %d", tc.cat, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeForCategory_UnknownDefaults(t *testing.T) {
|
||||
if got := ExitCodeForCategory(errs.Category("not_a_real_category")); got != ExitInternal {
|
||||
t.Errorf("ExitCodeForCategory(unknown) = %d, want %d (ExitInternal)", got, ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_Nil(t *testing.T) {
|
||||
if got := ExitCodeOf(nil); got != 0 {
|
||||
t.Errorf("ExitCodeOf(nil) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_PermissionError(t *testing.T) {
|
||||
err := &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization}}
|
||||
if got := ExitCodeOf(err); got != 3 {
|
||||
t.Errorf("ExitCodeOf(PermissionError) = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_APIError(t *testing.T) {
|
||||
err := &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI}}
|
||||
if got := ExitCodeOf(err); got != 1 {
|
||||
t.Errorf("ExitCodeOf(APIError) = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_UntypedFallsBackToInternal(t *testing.T) {
|
||||
if got := ExitCodeOf(fmt.Errorf("plain")); got != 5 {
|
||||
t.Errorf("ExitCodeOf(plain) = %d, want 5 (untyped → CategoryInternal → ExitInternal)", got)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,19 @@
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
)
|
||||
|
||||
// Lark API generic error code constants.
|
||||
// ref: https://open.feishu.cn/document/server-docs/api-call-guide/generic-error-code
|
||||
//
|
||||
// Kept as exported identifiers because external shortcut packages reference
|
||||
// them by name (e.g. LarkErrOwnershipMismatch). The canonical Category /
|
||||
// Subtype / Retryable metadata for each code lives in internal/errclass and
|
||||
// must remain the single source of truth — ClassifyLarkError below resolves
|
||||
// classification through errclass.LookupCodeMeta.
|
||||
const (
|
||||
// Auth: token missing / invalid / expired.
|
||||
LarkErrTokenMissing = 99991661 // Authorization header missing or empty
|
||||
@@ -32,13 +43,16 @@ const (
|
||||
LarkErrRefreshExpired = 20037 // refresh_token expired
|
||||
LarkErrRefreshRevoked = 20064 // refresh_token revoked
|
||||
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
|
||||
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
|
||||
|
||||
// Drive shortcut / cross-space constraints.
|
||||
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
|
||||
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
|
||||
LarkErrDriveCrossBrand = 1064511 // cross brand not support
|
||||
|
||||
// Wiki write-path lock contention (e.g. concurrent wiki +node-create under the
|
||||
// same parent). Server-side write lock; transient, safe to retry with backoff.
|
||||
LarkErrWikiLockContention = 131009
|
||||
|
||||
// Sheets float image: width/height/offset out of range or invalid.
|
||||
LarkErrSheetsFloatImageInvalidDims = 1310246
|
||||
|
||||
@@ -54,57 +68,159 @@ const (
|
||||
LarkErrOwnershipMismatch = 231205
|
||||
)
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
|
||||
// errType provides fine-grained classification in the JSON envelope;
|
||||
// exitCode is kept coarse (ExitAuth or ExitAPI).
|
||||
func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
switch code {
|
||||
// auth: token missing / invalid / expired
|
||||
case LarkErrTokenMissing, LarkErrTokenBadFmt:
|
||||
return ExitAuth, "auth", "run: lark-cli auth login to re-authorize"
|
||||
case LarkErrTokenInvalid, LarkErrATInvalid, LarkErrTokenExpired:
|
||||
return ExitAuth, "auth", "run: lark-cli auth login to re-authorize"
|
||||
// legacyHints supplies the per-code actionable hint string for the legacy
|
||||
// (exitCode, errType, hint) tuple returned by ClassifyLarkError. Hint
|
||||
// composition is not yet centralized in errclass (the canonical
|
||||
// PermissionHint lives there but the long-form per-code hints below are
|
||||
// still wire-stable strings), so this small lookup remains here. Codes
|
||||
// absent from this map fall back to "".
|
||||
var legacyHints = map[int]string{
|
||||
LarkErrTokenMissing: "run: lark-cli auth login to re-authorize",
|
||||
LarkErrTokenBadFmt: "run: lark-cli auth login to re-authorize",
|
||||
LarkErrTokenInvalid: "run: lark-cli auth login to re-authorize",
|
||||
LarkErrATInvalid: "run: lark-cli auth login to re-authorize",
|
||||
LarkErrTokenExpired: "run: lark-cli auth login to re-authorize",
|
||||
|
||||
// permission: scope not granted
|
||||
case LarkErrAppScopeNotEnabled, LarkErrTokenNoPermission,
|
||||
LarkErrUserScopeInsufficient, LarkErrUserNotAuthorized:
|
||||
return ExitAPI, "permission", "check app permissions or re-authorize: lark-cli auth login"
|
||||
LarkErrAppScopeNotEnabled: "check app permissions or re-authorize: lark-cli auth login",
|
||||
LarkErrTokenNoPermission: "check app permissions or re-authorize: lark-cli auth login",
|
||||
LarkErrUserScopeInsufficient: "check app permissions or re-authorize: lark-cli auth login",
|
||||
LarkErrUserNotAuthorized: "check app permissions or re-authorize: lark-cli auth login",
|
||||
|
||||
// app credential / status
|
||||
case LarkErrAppCredInvalid:
|
||||
return ExitAuth, "config", "check app_id / app_secret: lark-cli config set"
|
||||
case LarkErrAppNotInUse, LarkErrAppUnauthorized:
|
||||
return ExitAuth, "app_status", "app is disabled or not installed — check developer console"
|
||||
LarkErrAppCredInvalid: "check app_id / app_secret: lark-cli config set",
|
||||
LarkErrAppNotInUse: "app is disabled or not installed — check developer console",
|
||||
LarkErrAppUnauthorized: "app is disabled or not installed — check developer console",
|
||||
|
||||
// rate limit
|
||||
case LarkErrRateLimit:
|
||||
return ExitAPI, "rate_limit", "please try again later"
|
||||
|
||||
// drive-specific constraints that benefit from actionable hints
|
||||
case LarkErrDriveResourceContention:
|
||||
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
|
||||
case LarkErrDriveCrossTenantUnit:
|
||||
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
|
||||
case LarkErrDriveCrossBrand:
|
||||
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
|
||||
|
||||
// sheets-specific constraints that benefit from actionable hints
|
||||
case LarkErrSheetsFloatImageInvalidDims:
|
||||
return ExitAPI, "invalid_params",
|
||||
"check --width / --height / --offset-x / --offset-y: " +
|
||||
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height"
|
||||
|
||||
// drive permission-apply specific guidance
|
||||
case LarkErrDrivePermApplyRateLimit:
|
||||
return ExitAPI, "rate_limit",
|
||||
"permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly"
|
||||
case LarkErrDrivePermApplyNotApplicable:
|
||||
return ExitAPI, "invalid_params",
|
||||
"this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly"
|
||||
|
||||
case LarkErrOwnershipMismatch:
|
||||
return ExitAPI, "ownership_mismatch", buildOwnershipRecoveryHint()
|
||||
}
|
||||
|
||||
return ExitAPI, "api_error", ""
|
||||
LarkErrRateLimit: "please try again later",
|
||||
LarkErrDriveResourceContention: "please retry later and avoid concurrent duplicate requests",
|
||||
LarkErrWikiLockContention: "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes",
|
||||
LarkErrDriveCrossTenantUnit: "operate on source and target within the same tenant and region/unit",
|
||||
LarkErrDriveCrossBrand: "operate on source and target within the same brand environment",
|
||||
LarkErrSheetsFloatImageInvalidDims: "check --width / --height / --offset-x / --offset-y: " +
|
||||
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height",
|
||||
LarkErrDrivePermApplyRateLimit: "permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly",
|
||||
LarkErrDrivePermApplyNotApplicable: "this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly",
|
||||
}
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to the legacy
|
||||
// (exitCode, errType, hint) tuple consumed by the *ExitError path.
|
||||
//
|
||||
// Classification (Category / Subtype) is sourced from
|
||||
// errclass.LookupCodeMeta — the single source of truth shipped for both
|
||||
// this legacy adapter and the stage-2+ typed pipeline (errclass.BuildAPIError,
|
||||
// not yet invoked in production). This function adapts that result back to
|
||||
// the legacy tuple shape for callers that still go through *ExitError:
|
||||
//
|
||||
// - exitCode: derived from (Category, Subtype) via legacyExitCode below.
|
||||
// Note this differs from the typed pipeline's ExitCodeForCategory in
|
||||
// two preserved-legacy-quirks: Authorization+permission subtypes return
|
||||
// ExitAPI (legacy treats "permission" as exit 1) and Config returns
|
||||
// ExitAuth (legacy bundles "check app_id/secret" under exit 3).
|
||||
// - errType: legacy short string per (Category, Subtype), mapped by
|
||||
// legacyErrType. Subtypes not present in the legacy taxonomy fall back
|
||||
// to "api_error".
|
||||
// - hint: per-code lookup in legacyHints; "" when absent.
|
||||
//
|
||||
// Unknown codes (LookupCodeMeta returns false) classify as
|
||||
// (ExitAPI, "api_error", "") — matching the prior default.
|
||||
//
|
||||
// Deprecated: ClassifyLarkError belongs to the legacy *output.ExitError
|
||||
// surface that predates the typed error contract introduced by errs/. New
|
||||
// code MUST NOT use it — classify Lark API responses via
|
||||
// internal/errclass.BuildAPIError, which emits a typed *errs.XxxError with
|
||||
// Category, Subtype, and identity-aware extension fields populated at the
|
||||
// source. This helper is retained only while existing call sites are
|
||||
// migrated; it will be removed once they have moved to the typed surface.
|
||||
func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
meta, ok := errclass.LookupCodeMeta(code)
|
||||
if !ok {
|
||||
return ExitAPI, "api_error", ""
|
||||
}
|
||||
exitCode := legacyExitCode(meta.Category, meta.Subtype)
|
||||
errType := legacyErrType(meta.Category, meta.Subtype)
|
||||
hint := legacyHints[code]
|
||||
// IM ownership mismatch keeps its dynamic recovery hint.
|
||||
if code == LarkErrOwnershipMismatch {
|
||||
hint = buildOwnershipRecoveryHint()
|
||||
}
|
||||
return exitCode, errType, hint
|
||||
}
|
||||
|
||||
// legacyExitCode maps (Category, Subtype) to the legacy *ExitError exit
|
||||
// code. It diverges from ExitCodeForCategory in two places to preserve the
|
||||
// historic wire:
|
||||
//
|
||||
// - CategoryAuthorization with a "permission" subtype (missing_scope,
|
||||
// app_scope_not_enabled, token_no_permission) → ExitAPI (1), not
|
||||
// ExitAuth (3). Legacy considered permission failures a generic API
|
||||
// refusal.
|
||||
// - CategoryConfig → ExitAuth (3). Legacy bundled "check app_id/secret"
|
||||
// under the auth bucket.
|
||||
func legacyExitCode(cat errs.Category, sub errs.Subtype) int {
|
||||
switch cat {
|
||||
case errs.CategoryAuthentication:
|
||||
return ExitAuth
|
||||
case errs.CategoryAuthorization:
|
||||
switch sub {
|
||||
case errs.SubtypeMissingScope,
|
||||
errs.SubtypeUserUnauthorized,
|
||||
errs.SubtypeAppScopeNotApplied,
|
||||
errs.SubtypeTokenScopeInsufficient:
|
||||
return ExitAPI
|
||||
case errs.SubtypeAppUnavailable,
|
||||
errs.SubtypeAppNotInstalled:
|
||||
return ExitAuth
|
||||
}
|
||||
return ExitAPI
|
||||
case errs.CategoryConfig:
|
||||
return ExitAuth
|
||||
}
|
||||
return ExitAPI
|
||||
}
|
||||
|
||||
// legacyErrType maps (Category, Subtype) to the legacy *ExitError errType
|
||||
// string (e.g. "permission", "rate_limit"). Subtypes outside the
|
||||
// historically-classified set fall back to "api_error", matching the prior
|
||||
// default-case behavior.
|
||||
func legacyErrType(cat errs.Category, sub errs.Subtype) string {
|
||||
switch cat {
|
||||
case errs.CategoryAuthentication:
|
||||
return "auth"
|
||||
case errs.CategoryAuthorization:
|
||||
switch sub {
|
||||
case errs.SubtypeMissingScope,
|
||||
errs.SubtypeUserUnauthorized,
|
||||
errs.SubtypeAppScopeNotApplied,
|
||||
errs.SubtypeTokenScopeInsufficient:
|
||||
return "permission"
|
||||
case errs.SubtypeAppUnavailable,
|
||||
errs.SubtypeAppNotInstalled:
|
||||
return "app_status"
|
||||
}
|
||||
return "permission"
|
||||
case errs.CategoryConfig:
|
||||
switch sub {
|
||||
case errs.SubtypeInvalidClient,
|
||||
errs.SubtypeNotConfigured,
|
||||
errs.SubtypeInvalidConfig:
|
||||
return "config"
|
||||
}
|
||||
return "config"
|
||||
case errs.CategoryAPI:
|
||||
switch sub {
|
||||
case errs.SubtypeRateLimit:
|
||||
return "rate_limit"
|
||||
case errs.SubtypeConflict:
|
||||
return "conflict"
|
||||
case errs.SubtypeCrossTenant:
|
||||
return "cross_tenant"
|
||||
case errs.SubtypeCrossBrand:
|
||||
return "cross_brand"
|
||||
case errs.SubtypeInvalidParameters:
|
||||
return "invalid_parameters"
|
||||
case errs.SubtypeOwnershipMismatch:
|
||||
return "ownership_mismatch"
|
||||
}
|
||||
return "api_error"
|
||||
}
|
||||
return "api_error"
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
name: "cross tenant unit",
|
||||
code: LarkErrDriveCrossTenantUnit,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "cross_tenant_unit",
|
||||
wantType: "cross_tenant",
|
||||
wantHint: "same tenant and region/unit",
|
||||
},
|
||||
{
|
||||
@@ -44,7 +44,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
name: "sheets float image invalid dims",
|
||||
code: LarkErrSheetsFloatImageInvalidDims,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "invalid_params",
|
||||
wantType: "invalid_parameters",
|
||||
wantHint: "--width / --height / --offset-x / --offset-y",
|
||||
},
|
||||
{
|
||||
@@ -58,7 +58,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
name: "drive permission apply not applicable",
|
||||
code: LarkErrDrivePermApplyNotApplicable,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "invalid_params",
|
||||
wantType: "invalid_parameters",
|
||||
wantHint: "does not accept a permission-apply request",
|
||||
},
|
||||
{
|
||||
@@ -90,3 +90,24 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyLarkError_WikiLockContention verifies the wiki write-lock
|
||||
// contention error (131009) maps to an actionable retry hint instead of
|
||||
// a generic "api_error". Surfaces during concurrent wiki +node-create
|
||||
// against the same parent (see larksuite/cli#1012).
|
||||
func TestClassifyLarkError_WikiLockContention(t *testing.T) {
|
||||
t.Parallel()
|
||||
gotExitCode, gotType, gotHint := ClassifyLarkError(LarkErrWikiLockContention, "raw msg")
|
||||
if gotExitCode != ExitAPI {
|
||||
t.Fatalf("exitCode=%d, want %d", gotExitCode, ExitAPI)
|
||||
}
|
||||
if gotType != "conflict" {
|
||||
t.Fatalf("type=%q, want %q", gotType, "conflict")
|
||||
}
|
||||
if !strings.Contains(gotHint, "wiki write lock") {
|
||||
t.Fatalf("hint=%q, want substring %q", gotHint, "wiki write lock")
|
||||
}
|
||||
if !strings.Contains(gotHint, "backoff") {
|
||||
t.Fatalf("hint=%q, want substring %q", gotHint, "backoff")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,64 @@ var registryFS embed.FS
|
||||
// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in.
|
||||
var embeddedMetaJSON []byte
|
||||
|
||||
// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers
|
||||
// that need to parse key order or other JSON-level structure not exposed by
|
||||
// LoadFromMeta (which loses map insertion order).
|
||||
func EmbeddedMetaJSON() []byte {
|
||||
return embeddedMetaJSON
|
||||
}
|
||||
|
||||
var (
|
||||
embeddedServicesMap map[string]map[string]interface{} // service name -> spec
|
||||
embeddedServiceNames []string // sorted
|
||||
embeddedParseOnce sync.Once
|
||||
)
|
||||
|
||||
// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map
|
||||
// without touching mergedServices. Safe to call multiple times (sync.Once).
|
||||
func parseEmbeddedServices() {
|
||||
embeddedParseOnce.Do(func() {
|
||||
embeddedServicesMap = make(map[string]map[string]interface{})
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return
|
||||
}
|
||||
var wrapper struct {
|
||||
Services []map[string]interface{} `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(embeddedMetaJSON, &wrapper); err != nil {
|
||||
return
|
||||
}
|
||||
for _, svc := range wrapper.Services {
|
||||
name, _ := svc["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
embeddedServicesMap[name] = svc
|
||||
}
|
||||
embeddedServiceNames = make([]string, 0, len(embeddedServicesMap))
|
||||
for name := range embeddedServicesMap {
|
||||
embeddedServiceNames = append(embeddedServiceNames, name)
|
||||
}
|
||||
sort.Strings(embeddedServiceNames)
|
||||
})
|
||||
}
|
||||
|
||||
// EmbeddedSpec returns the embedded spec for one service, or nil if unknown.
|
||||
// Bypasses remote overlay — used for deterministic envelope output.
|
||||
func EmbeddedSpec(serviceName string) map[string]interface{} {
|
||||
parseEmbeddedServices()
|
||||
return embeddedServicesMap[serviceName]
|
||||
}
|
||||
|
||||
// EmbeddedServiceNames returns sorted embedded service names (no overlay).
|
||||
// Returns a defensive copy — callers must not mutate the package-level slice.
|
||||
func EmbeddedServiceNames() []string {
|
||||
parseEmbeddedServices()
|
||||
out := make([]string, len(embeddedServiceNames))
|
||||
copy(out, embeddedServiceNames)
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
|
||||
mergedProjectList []string // sorted project names
|
||||
|
||||
82
internal/registry/scope_hint.go
Normal file
82
internal/registry/scope_hint.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// ExtractRequiredScopes pulls scope names out of the API error's
|
||||
// permission_violations field. The detail argument is the raw `error` block
|
||||
// that the platform returns alongside lark code 99991672 / 99991679 — typically
|
||||
// shaped as:
|
||||
//
|
||||
// { "permission_violations": [ {"subject": "<scope>"}, ... ] }
|
||||
//
|
||||
// Returns nil when the structure does not match or no non-empty subjects are
|
||||
// present, so callers can branch on a simple len() == 0 check.
|
||||
func ExtractRequiredScopes(detail interface{}) []string {
|
||||
m, ok := detail.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
violations, ok := m["permission_violations"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
scopes := make([]string, 0, len(violations))
|
||||
for _, v := range violations {
|
||||
vm, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if subject, ok := vm["subject"].(string); ok && subject != "" {
|
||||
scopes = append(scopes, subject)
|
||||
}
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
// SelectRecommendedScopeFromStrings is a string-typed convenience wrapper
|
||||
// around SelectRecommendedScope. When no scope is recognized by the priority
|
||||
// table, it falls back to the first input scope so callers always have
|
||||
// something to surface to users.
|
||||
func SelectRecommendedScopeFromStrings(scopes []string, identity string) string {
|
||||
if len(scopes) == 0 {
|
||||
return ""
|
||||
}
|
||||
ifaces := make([]interface{}, len(scopes))
|
||||
for i, s := range scopes {
|
||||
ifaces[i] = s
|
||||
}
|
||||
if recommended := SelectRecommendedScope(ifaces, identity); recommended != "" {
|
||||
return recommended
|
||||
}
|
||||
return scopes[0]
|
||||
}
|
||||
|
||||
// BuildConsoleScopeURL returns the developer-console "apply scope" URL for the
|
||||
// given app and scope, branded for feishu / lark. Returns "" when appID or
|
||||
// scope is empty so callers can omit the field cleanly.
|
||||
func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
|
||||
if appID == "" || scope == "" {
|
||||
return ""
|
||||
}
|
||||
host := "open.feishu.cn"
|
||||
if brand == core.BrandLark {
|
||||
host = "open.larksuite.com"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||
host,
|
||||
url.QueryEscape(appID),
|
||||
url.QueryEscape(scope),
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user