Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ jobs:
run: python3 scripts/fetch_meta.py
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
# ── errs/ contract guards (Rules B/C/D/E) ──
# Rule A is enforced by the forbidigo entry above (`errs-typed-only`).
# LABEL diagnostics for ad_hoc_* Subtypes pass through; CI workflows can
# grep stderr for `[needs-taxonomy-decision]` to apply the GitHub label.
- name: Run errs/ lint guards (lintcheck)
run: go run ./cmd/lintcheck .

coverage:
needs: fast-gate
Expand Down
35 changes: 33 additions & 2 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,34 @@ linters:
- path: _test\.go$
linters:
- bodyclose
- bidichk
- gocritic
- depguard
- forbidigo
- path-except: (shortcuts/|internal/)
- path-except: (shortcuts/|internal/|cmd/service/)
linters:
- forbidigo
- path: internal/vfs/
linters:
- forbidigo
# internal/lintcheck/ is a build-time CI tool that walks source files;
# it legitimately uses os / filepath directly and is not a runtime path.
- path: internal/lintcheck/
linters:
- forbidigo
# The shortcuts-no-raw-http forbidigo rule below is shortcuts-only;
# internal/ legitimately wraps raw HTTP for the client / credential layer.
- path-except: shortcuts/
text: shortcuts-no-raw-http
linters:
- forbidigo
# The errs-typed-only rule (Rule A, errs/ contract guards) only fires on
# the business path: shortcuts/** and cmd/service/**. cmd/api, cmd/auth,
# cmd/event, internal/output, internal/client and root.go are dispatcher
# / framework paths that may legitimately call the output constructors.
- path-except: (shortcuts/|cmd/service/)
text: errs-typed-only
linters:
- forbidigo

settings:
depguard:
Expand All @@ -79,6 +91,25 @@ linters:
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo:
forbid:
# ── errs/: business path must produce typed *errs.* errors ──
# Path-scoped to shortcuts/** and cmd/service/** via the exclusion rule
# above; dispatcher / framework paths (cmd/api, cmd/auth, cmd/event,
# internal/output, internal/client, cmd/root.go) keep direct access.
# CI runs golangci-lint with --new-from-rev=origin/main so this only
# blocks new diffs — existing unmigrated call sites stay green.
- pattern: output\.(ErrAPI|Errorf|ErrWithHint|ErrBare|ClassifyLarkError)\b
msg: >-
[errs-typed-only] business path must not call legacy output.* error
constructors (ErrAPI / Errorf / ErrWithHint / ErrBare / ClassifyLarkError).
Construct a typed *errs.XxxError directly (see errs/ERROR_CONTRACT.md
Quick reference). The errclass.BuildAPIError classifier is shipped
for stage 2+ — APIClient.CheckResponse will route through it once
the per-domain typed migration lands.
- pattern: output\.(ExitError|ErrDetail)\{
msg: >-
[errs-typed-only] business path must not construct legacy
*output.ExitError / output.ErrDetail literals. Return a typed
*errs.XxxError instead (see errs/ERROR_CONTRACT.md Quick reference).
# ── http: shortcuts must not construct raw HTTP requests ──
# Bans request / client construction; constants (http.MethodPost,
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
Expand Down
29 changes: 21 additions & 8 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,11 @@

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)

Check warning on line 245 in cmd/api/api.go

View check run for this annotation

Codecov / codecov/patch

cmd/api/api.go#L245

Added line #L245 was not covered by tests
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
Expand All @@ -248,9 +252,15 @@
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)
}
Expand All @@ -262,9 +272,12 @@
}

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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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
Expand All @@ -277,9 +290,9 @@
pf.FormatPage(items)
}, pagOpts)
if err != nil {
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
return output.MarkRaw(err)

Check warning on line 293 in cmd/api/api.go

View check run for this annotation

Codecov / codecov/patch

cmd/api/api.go#L293

Added line #L293 was not covered by tests
}
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)
}
Expand All @@ -291,9 +304,9 @@
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)

Check warning on line 307 in cmd/api/api.go

View check run for this annotation

Codecov / codecov/patch

cmd/api/api.go#L307

Added line #L307 was not covered by tests
}
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)
}
Expand Down
150 changes: 0 additions & 150 deletions cmd/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
package api

import (
"errors"
"os"
"sort"
"strings"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions cmd/auth/login_result.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
43 changes: 30 additions & 13 deletions cmd/config/bind_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 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 !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
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading