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
37 changes: 19 additions & 18 deletions cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,9 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
Use: "auth",
Short: "OAuth credentials and authorization management",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
// cobra stops at the first PersistentPreRun[E] walking up; root's SilenceUsage=true is skipped without this.
cmd.SilenceUsage = true
// cmd.Name() returns the subcommand name (e.g. "login"), not "auth".
// Pass "auth" as a literal so the error message reads
// `"auth" is not supported: ...`
// Pass "auth" literally; cmd.Name() is the subcommand.
return f.RequireBuiltinCredentialProvider(cmd.Context(), "auth")
},
}
Expand All @@ -45,6 +41,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdAuthList(f, nil))
cmd.AddCommand(NewCmdAuthCheck(f, nil))
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
cmd.AddCommand(NewCmdAuthUsers(f))
return cmd
}

Expand All @@ -53,38 +50,44 @@ type userInfoResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
OpenID string `json:"open_id"`
Name string `json:"name"`
OpenID string `json:"open_id"`
UnionID string `json:"union_id"`
Name string `json:"name"`
} `json:"data"`
}

// getUserInfo fetches the current user's OpenID and name using the given access token.
func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (openId, name string, err error) {
// getUserInfoFn is the test seam for getUserInfo (mirrors getAppInfoFn).
var getUserInfoFn = getUserInfo

// getUserInfo fetches the current user's OpenID, UnionID, and name.
// UnionID is captured for cross-app reconciliation in per-user state files;
// absence is non-fatal.
func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (openId, unionId, name string, err error) {
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: larkauth.PathUserInfoV1,
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser},
}, larkcore.WithUserAccessToken(accessToken))
if err != nil {
return "", "", err
return "", "", "", err
}

var resp userInfoResponse
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return "", "", fmt.Errorf("failed to parse user info: %w", err)
return "", "", "", fmt.Errorf("failed to parse user info: %w", err)
}
if resp.Code != 0 {
return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
return "", "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
}
if resp.Data.OpenID == "" {
return "", "", fmt.Errorf("failed to get user info: missing open_id in response")
return "", "", "", fmt.Errorf("failed to get user info: missing open_id in response")
}

name = resp.Data.Name
if name == "" {
name = "(unknown)"
}
return resp.Data.OpenID, name, nil
return resp.Data.OpenID, resp.Data.UnionID, name, nil
}

// appInfo contains application information (owner, scopes).
Expand All @@ -111,9 +114,7 @@ type appInfoResponse struct {
} `json:"data"`
}

// getAppInfoFn is the package-level seam used by callers (scopes.go) so tests
// can substitute a fake without standing up a full SDK + httpmock pipeline.
// Mirrors the pollDeviceToken pattern in login.go.
// getAppInfoFn is the test seam for getAppInfo.
var getAppInfoFn = getAppInfo

// getAppInfo queries app info from the Lark API.
Expand Down
44 changes: 31 additions & 13 deletions cmd/auth/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package auth
import (
"errors"
"fmt"
"io"
"os"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -42,20 +44,22 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
func authListRun(opts *ListOptions) error {
f := opts.Factory

multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
// auth list is a read-only probe; the "configured but no users"
// branch below already returns exit 0 with a stderr hint, so we
// keep the same contract here. We still want the hint to be
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *core.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
// auth list is a read-only probe — when the file is simply
// missing we keep exit 0 with a workspace-aware stderr hint.
// Anything else (R2 forward-incompat, parse error) must still
// surface with its envelope intact: a *core.ConfigError must
// reach the dispatcher with its upgrade Hint preserved.
if errors.Is(err, os.ErrNotExist) {
printNotConfiguredHint(f.IOStreams.ErrOut)
return nil
}
return core.PassThroughOrNotConfigured(err)
}
if multi == nil || len(multi.Apps) == 0 {
// Configured but empty Apps[] — read-only probe, exit 0 with hint.
printNotConfiguredHint(f.IOStreams.ErrOut)
return nil
}

Expand All @@ -82,3 +86,17 @@ func authListRun(opts *ListOptions) error {
output.PrintJson(f.IOStreams.Out, items)
return nil
}

// printNotConfiguredHint emits the workspace-aware "not configured /
// no users" hint to stderr without failing the read-only probe.
// Pulls Message+Hint from core.NotConfiguredError() so a single source
// of truth governs both the error path and the soft-success path.
func printNotConfiguredHint(errOut io.Writer) {
var cfgErr *core.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(errOut, cfgErr.Message)
if cfgErr.Hint != "" {
fmt.Fprintln(errOut, " hint: "+cfgErr.Hint)
}
}
}
86 changes: 86 additions & 0 deletions cmd/auth/list_r2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package auth

import (
"errors"
"os"
"path/filepath"
"strings"
"testing"

"github.com/zalando/go-keyring"

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

// Regression: a config.json written by a newer lark-cli (schemaVersion >
// CurrentSchemaVersion) must surface its R2 *core.ConfigError + upgrade
// Hint through `auth list` and `auth users list`. Pre-fix, both paths
// did `multi, _ := core.LoadMultiAppConfig()` and silently dropped the
// error — `auth list` continued on with multi==nil and printed the
// generic "not configured" hint, steering operators (and AI agents)
// toward `config init --new`, which would overwrite the fields the
// newer binary populated.
//
// Mirrors the contract pinned at
// internal/credential/default_provider_r2_test.go (ResolveAccount) and
// internal/errcompat/promote_r2_test.go (dispatcher promotion).
func TestAuthListRun_R2ForwardIncompat_PassesUpgradeHint(t *testing.T) {
keyring.MockInit()
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
t.Setenv("HOME", t.TempDir())

future := []byte(`{"schemaVersion":99,"apps":[{"appId":"cli_x","appSecret":"s","brand":"feishu","users":[]}]}` + "\n")
if err := os.WriteFile(filepath.Join(dir, "config.json"), future, 0600); err != nil {
t.Fatalf("seed config.json: %v", err)
}

f, _, _, _ := cmdutil.TestFactory(t, nil)
err := authListRun(&ListOptions{Factory: f})
if err == nil {
t.Fatal("auth list must surface R2 error from a future schema, got nil — would steer operator to config init --new")
}
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *core.ConfigError preserved by PassThroughOrNotConfigured, got %T: %v", err, err)
}
if !strings.Contains(cfgErr.Message, "newer lark-cli") {
t.Errorf("R2 message lost; got %q", cfgErr.Message)
}
if !strings.Contains(cfgErr.Hint, "upgrade lark-cli") {
t.Errorf("R2 upgrade hint lost; got %q", cfgErr.Hint)
}
}

// Same contract for `auth users list`. Same root cause pre-fix.
func TestAuthUsersListRun_R2ForwardIncompat_PassesUpgradeHint(t *testing.T) {
keyring.MockInit()
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
t.Setenv("HOME", t.TempDir())

future := []byte(`{"schemaVersion":99,"apps":[{"appId":"cli_x","appSecret":"s","brand":"feishu","users":[]}]}` + "\n")
if err := os.WriteFile(filepath.Join(dir, "config.json"), future, 0600); err != nil {
t.Fatalf("seed config.json: %v", err)
}

f, _, _, _ := cmdutil.TestFactory(t, nil)
err := authUsersListRun(&UsersListOptions{Factory: f})
if err == nil {
t.Fatal("auth users list must surface R2 error from a future schema, got nil")
}
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *core.ConfigError, got %T: %v", err, err)
}
if !strings.Contains(cfgErr.Message, "newer lark-cli") {
t.Errorf("R2 message lost; got %q", cfgErr.Message)
}
if !strings.Contains(cfgErr.Hint, "upgrade lark-cli") {
t.Errorf("R2 upgrade hint lost; got %q", cfgErr.Hint)
}
}
Loading
Loading