Skip to content
Merged
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
1 change: 1 addition & 0 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
f.CurrentCommand = cmd

Check warning on line 112 in cmd/build.go

View check run for this annotation

Codecov / codecov/patch

cmd/build.go#L112

Added line #L112 was not covered by tests
}

rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
Expand Down
175 changes: 175 additions & 0 deletions cmd/error_auth_hint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package cmd

import (
"fmt"
"strings"

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/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.
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr == nil || exitErr.Detail == nil {
return

Check warning on line 25 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L25

Added line #L25 was not covered by tests
}
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
return
}

scopes := resolveDeclaredScopesForCurrentCommand(f)
if len(scopes) == 0 {
return

Check warning on line 33 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L33

Added line #L33 was not covered by tests
}

scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
if exitErr.Detail.Hint == "" {
exitErr.Detail.Hint = scopeHint
return
}
exitErr.Detail.Hint += "\n" + scopeHint
}

// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
// current command for the resolved identity, checking shortcuts first and then
// service methods from local registry metadata.
func resolveDeclaredScopesForCurrentCommand(f *cmdutil.Factory) []string {
if f == nil || f.CurrentCommand == nil {
return nil

Check warning on line 49 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L49

Added line #L49 was not covered by tests
}

identity := string(f.ResolvedIdentity)
if identity == "" {
identity = string(core.AsUser)

Check warning on line 54 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L54

Added line #L54 was not covered by tests
}
if identity != string(core.AsUser) && identity != string(core.AsBot) {
return nil

Check warning on line 57 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L57

Added line #L57 was not covered by tests
}

if scopes := resolveDeclaredShortcutScopes(f.CurrentCommand, identity); len(scopes) > 0 {
return scopes
}
return resolveDeclaredServiceMethodScopes(f.CurrentCommand, identity)
}

// resolveDeclaredShortcutScopes returns the scopes declared by a mounted
// shortcut command for the given identity.
func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string {
if cmd == nil || cmd.Parent() == nil || !strings.HasPrefix(cmd.Name(), "+") {
return nil
}

service := cmd.Parent().Name()
for _, sc := range shortcuts.AllShortcuts() {
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
continue
}
scopes := sc.ScopesForIdentity(identity)
if len(scopes) == 0 {
return nil

Check warning on line 80 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L80

Added line #L80 was not covered by tests
}
return append([]string(nil), scopes...)
}
return nil

Check warning on line 84 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L84

Added line #L84 was not covered by tests
}

// resolveDeclaredServiceMethodScopes returns the scopes declared by a
// service/resource/method command from the embedded from_meta registry.
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
// Service-method scope lookup only applies to commands mounted as
// root -> service -> resource -> method. Non-resource/method commands
// intentionally return no scopes here so auth-hint enrichment does not
// change runtime semantics for other command shapes.
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
return nil

Check warning on line 95 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L95

Added line #L95 was not covered by tests
}
if strings.HasPrefix(cmd.Name(), "+") {
return nil

Check warning on line 98 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L98

Added line #L98 was not covered by tests
}

service := cmd.Parent().Parent().Name()
resource := cmd.Parent().Name()
method := cmd.Name()

spec := registry.LoadFromMeta(service)
if spec == nil {
return nil

Check warning on line 107 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L107

Added line #L107 was not covered by tests
}
resources, _ := spec["resources"].(map[string]interface{})
resMap, _ := resources[resource].(map[string]interface{})
if resMap == nil {
return nil

Check warning on line 112 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L112

Added line #L112 was not covered by tests
}
methods, _ := resMap["methods"].(map[string]interface{})
methodMap, _ := methods[method].(map[string]interface{})
if methodMap == nil {
return nil

Check warning on line 117 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L117

Added line #L117 was not covered by tests
}
return declaredScopesForMethod(methodMap, identity)
}

// declaredScopesForMethod returns all requiredScopes when present; otherwise it
// resolves the single recommended scope from the method's scopes list.
func declaredScopesForMethod(method map[string]interface{}, identity string) []string {
if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 {
return interfaceStrings(requiredRaw)

Check warning on line 126 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L126

Added line #L126 was not covered by tests
}

rawScopes, _ := method["scopes"].([]interface{})
if len(rawScopes) == 0 {
return nil

Check warning on line 131 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L131

Added line #L131 was not covered by tests
}
recommended := registry.SelectRecommendedScope(rawScopes, identity)
if recommended == "" {
for _, raw := range rawScopes {
if scope, ok := raw.(string); ok && scope != "" {
recommended = scope
break

Check warning on line 138 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L135-L138

Added lines #L135 - L138 were not covered by tests
}
}
}
if recommended == "" {
return nil

Check warning on line 143 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L143

Added line #L143 was not covered by tests
}
return []string{recommended}
}

// interfaceStrings converts a []interface{} containing strings into a compact
// []string, skipping empty or non-string values.
func interfaceStrings(values []interface{}) []string {
scopes := make([]string, 0, len(values))
for _, value := range values {
scope, ok := value.(string)
if !ok || scope == "" {
continue

Check warning on line 155 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L150-L155

Added lines #L150 - L155 were not covered by tests
}
scopes = append(scopes, scope)

Check warning on line 157 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L157

Added line #L157 was not covered by tests
}
return scopes

Check warning on line 159 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L159

Added line #L159 was not covered by tests
}

// shortcutSupportsIdentity reports whether a shortcut supports the requested
// identity, applying the default user-only behavior when AuthTypes is empty.
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
authTypes := sc.AuthTypes
if len(authTypes) == 0 {
authTypes = []string{string(core.AsUser)}

Check warning on line 167 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L167

Added line #L167 was not covered by tests
}
for _, authType := range authTypes {
if authType == identity {
return true
}
}
return false

Check warning on line 174 in cmd/error_auth_hint.go

View check run for this annotation

Codecov / codecov/patch

cmd/error_auth_hint.go#L174

Added line #L174 was not covered by tests
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ func handleRootError(f *cmdutil.Factory, err error) int {
if !exitErr.Raw {
// Raw errors (e.g. from `api` command) preserve the original API
// error detail; skip enrichment which would clear it.
enrichMissingScopeError(f, exitErr)
enrichPermissionError(f, exitErr)
}
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
Expand Down
121 changes: 121 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import (
"github.com/larksuite/cli/cmd/auth"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/schema"
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
Expand Down Expand Up @@ -188,6 +191,124 @@ func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
}
}

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,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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_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)
Expand Down
24 changes: 23 additions & 1 deletion internal/auth/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@
package auth

import (
"errors"
"fmt"
"strings"

"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.
Expand All @@ -33,7 +36,26 @@

// Error returns the error message for NeedAuthorizationError.
func (e *NeedAuthorizationError) Error() string {
return fmt.Sprintf("need_user_authorization (user: %s)", e.UserOpenId)
return fmt.Sprintf("%s (user: %s)", needUserAuthorizationMarker, e.UserOpenId)
}

// IsNeedUserAuthorizationError reports whether err represents a missing-UAT
// failure, either as the original auth error or as a wrapped ExitError.
func IsNeedUserAuthorizationError(err error) bool {
if err == nil {
return false
}

var needAuthErr *NeedAuthorizationError
if errors.As(err, &needAuthErr) {
return true
}

var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return strings.Contains(exitErr.Detail.Message, needUserAuthorizationMarker)
}
return strings.Contains(err.Error(), needUserAuthorizationMarker)

Check warning on line 58 in internal/auth/errors.go

View check run for this annotation

Codecov / codecov/patch

internal/auth/errors.go#L58

Added line #L58 was not covered by tests
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// SecurityPolicyError is returned when a request is blocked by access control policies.
Expand Down
Loading
Loading