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
43 changes: 4 additions & 39 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"errors"
"fmt"
"io"
"net/url"
"os"
"sort"
"strconv"
Expand Down Expand Up @@ -389,8 +388,8 @@ 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
}
Expand All @@ -401,21 +400,10 @@ 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
exitErr.Detail.Detail = nil
Expand Down Expand Up @@ -452,26 +440,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
}
82 changes: 82 additions & 0 deletions internal/registry/scope_hint.go
Original file line number Diff line number Diff line change
@@ -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]

Check warning on line 62 in internal/registry/scope_hint.go

View check run for this annotation

Codecov / codecov/patch

internal/registry/scope_hint.go#L62

Added line #L62 was not covered by tests
}

// 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),
)
}
104 changes: 104 additions & 0 deletions internal/registry/scope_hint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package registry

import (
"strings"
"testing"

"github.com/larksuite/cli/internal/core"
)

func TestExtractRequiredScopes_HappyPath(t *testing.T) {
detail := map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:permission.member:create"},
map[string]interface{}{"subject": "docs:doc"},
map[string]interface{}{"subject": ""}, // empty subject filtered
"not-a-map", // ignored
},
}
got := ExtractRequiredScopes(detail)
want := []string{"docs:permission.member:create", "docs:doc"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("ExtractRequiredScopes mismatch: got %v, want %v", got, want)
}
}

func TestExtractRequiredScopes_NilOrMalformed(t *testing.T) {
cases := []interface{}{
nil,
"plain string",
map[string]interface{}{},
map[string]interface{}{"permission_violations": "not-a-list"},
map[string]interface{}{"permission_violations": []interface{}{}},
map[string]interface{}{"permission_violations": []interface{}{
map[string]interface{}{"subject": ""},
}},
}
for i, in := range cases {
if got := ExtractRequiredScopes(in); got != nil {
t.Errorf("case %d: expected nil, got %v", i, got)
}
}
}

func TestBuildConsoleScopeURL_BrandSpecificHost(t *testing.T) {
got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", "docs:permission.member:create")
if !strings.Contains(got, "open.feishu.cn") {
t.Errorf("feishu brand should use open.feishu.cn host, got %s", got)
}
if !strings.Contains(got, "clientID=cli_xxx") {
t.Errorf("missing app id in url: %s", got)
}
if !strings.Contains(got, "scopes=docs%3Apermission.member%3Acreate") {
t.Errorf("scope not URL-escaped: %s", got)
}

got = BuildConsoleScopeURL(core.BrandLark, "cli_yyy", "drive:drive")
if !strings.Contains(got, "open.larksuite.com") {
t.Errorf("lark brand should use open.larksuite.com host, got %s", got)
}
}

func TestBuildConsoleScopeURL_EmptyInput(t *testing.T) {
if got := BuildConsoleScopeURL(core.BrandFeishu, "", "docs:doc"); got != "" {
t.Errorf("empty appID should yield empty url, got %s", got)
}
if got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", ""); got != "" {
t.Errorf("empty scope should yield empty url, got %s", got)
}
}

func TestSelectRecommendedScopeFromStrings_FallsBackToFirst(t *testing.T) {
ensureFreshRegistry(t)
// Unknown scopes (not in priority table) → fallback to first
got := SelectRecommendedScopeFromStrings([]string{"unknown:foo", "unknown:bar"}, "tenant")
if got != "unknown:foo" {
t.Errorf("expected fallback to first, got %s", got)
}
}

// When at least one scope is recognized by the priority table, the
// recommended scope wins over the fallback (first input).
func TestSelectRecommendedScopeFromStrings_PicksKnownScopeOverFallback(t *testing.T) {
ensureFreshRegistry(t)
// docs:permission.member:create is recommended (recommend=true) in
// scope_priorities.json. Putting an unknown scope first would otherwise
// win via the fallback path; this ensures the priority table is consulted
// before falling back.
got := SelectRecommendedScopeFromStrings([]string{"unknown:foo", "docs:permission.member:create"}, "tenant")
if got != "docs:permission.member:create" {
t.Errorf("expected priority-table winner, got %s", got)
}
}

func TestSelectRecommendedScopeFromStrings_Empty(t *testing.T) {
if got := SelectRecommendedScopeFromStrings(nil, "tenant"); got != "" {
t.Errorf("nil slice should return empty, got %s", got)
}
if got := SelectRecommendedScopeFromStrings([]string{}, "tenant"); got != "" {
t.Errorf("empty slice should return empty, got %s", got)
}
}
60 changes: 60 additions & 0 deletions shortcuts/common/permission_grant.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
package common

import (
"errors"
"fmt"
"strings"

"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/validate"
)

Expand Down Expand Up @@ -81,6 +84,12 @@
fmt.Sprintf("Resource was created, but granting current user %s failed: %s. You can retry later or continue using bot identity.", permissionGrantPermMessage(), errMsg),
fmt.Sprintf("Auto-grant failed: %s. The app may lack the required scope or the resource restricts permission changes.", errMsg),
)
// Best-effort: when the underlying error is a structured permission
// ExitError (lark code 99991672/99991679), surface lark_code,
// required_scope and console_url so agents can guide users straight
// to the dev console. Overrides the generic hint with a more
// actionable one when console_url is available.
annotateGrantPermissionError(runtime, result, err)
fmt.Fprintf(runtime.IO().ErrOut, "Warning: resource was created, but auto-grant failed: %s. Retry later or grant permission manually.\n", errMsg)
return result
}
Expand Down Expand Up @@ -151,3 +160,54 @@
}
return strings.Join(strings.Fields(err.Error()), " ")
}

// annotateGrantPermissionError enriches a failed permission_grant result with
// structured fields (lark_code / required_scope / console_url) when the
// underlying error is a permission-class *output.ExitError. The CLI's main
// permission-error path (cmd/root.go::enrichPermissionError) handles the same
// case for top-level failures; this helper covers best-effort sub-calls whose
// error is folded into a result map instead of propagated as ExitError.
//
// When console_url is available, the existing generic hint is overridden with
// a more actionable one pointing at the developer console — that's the
// concrete next step a user can take.
func annotateGrantPermissionError(runtime *RuntimeContext, result map[string]interface{}, err error) {
if runtime == nil || result == nil || err == nil {
return
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return
}
if exitErr.Detail.Type != "permission" {
return
}
if exitErr.Detail.Code != 0 {
result["lark_code"] = exitErr.Detail.Code
}

scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
if len(scopes) == 0 {
return
}
recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant")
if recommended == "" {
return

Check warning on line 195 in shortcuts/common/permission_grant.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/permission_grant.go#L195

Added line #L195 was not covered by tests
}
result["required_scope"] = recommended

if runtime.Config == nil || runtime.Config.AppID == "" {
return
}
consoleURL := registry.BuildConsoleScopeURL(runtime.Config.Brand, runtime.Config.AppID, recommended)
if consoleURL == "" {
return

Check warning on line 204 in shortcuts/common/permission_grant.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/common/permission_grant.go#L204

Added line #L204 was not covered by tests
}
result["console_url"] = consoleURL
// Override the generic hint: pointing at the dev console is more actionable
// than the generic "retry later" fallback set by buildPermissionGrantResult.
result["hint"] = fmt.Sprintf(
"App is missing the %q scope; enable it in the developer console (see console_url), then retry.",
recommended,
)
}
Loading
Loading