mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat: add whoami command showing effective identity (#1666)
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/cmd/skill"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/cmd/whoami"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -194,6 +195,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
|
||||
167
cmd/whoami/whoami.go
Normal file
167
cmd/whoami/whoami.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whoami
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// whoamiResult is the structured output of `lark-cli whoami`.
|
||||
type whoamiResult struct {
|
||||
Profile string `json:"profile"`
|
||||
AppID string `json:"appId"`
|
||||
Brand core.LarkBrand `json:"brand"`
|
||||
DefaultAs string `json:"defaultAs"`
|
||||
Identity string `json:"identity"`
|
||||
IdentitySource string `json:"identitySource"`
|
||||
Available bool `json:"available"`
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
OpenID string `json:"openId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// Options holds inputs for the whoami command.
|
||||
type Options struct {
|
||||
Factory *cmdutil.Factory
|
||||
As string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdWhoami creates the top-level whoami command. It reports the identity
|
||||
// that the next API call would actually use (resolved via Factory.ResolveAs),
|
||||
// together with the active profile, app, and token status. It is local-only:
|
||||
// no network calls are made.
|
||||
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show the current effective identity, app, profile, and token status",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return whoamiRun(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
f := opts.Factory
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
flagAs := core.Identity(opts.As)
|
||||
as := f.ResolveAs(ctx, cmd, flagAs)
|
||||
// Reject an explicit --as that does not resolve to a usable identity, so a
|
||||
// typo like `--as admin` fails clearly instead of echoing back a bogus
|
||||
// identity. Keeps the §5.1 invariant (identity is always user or bot) and
|
||||
// matches how api/service/shortcut commands validate the resolved identity.
|
||||
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
|
||||
return err
|
||||
}
|
||||
source := resolveSource(
|
||||
cmd.Flags().Changed("as"),
|
||||
flagAs,
|
||||
f.IdentityAutoDetected,
|
||||
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
formatPretty(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSource derives how the effective identity became effective.
|
||||
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
|
||||
// auto-detected result means auto-detect; otherwise a strict-mode forced
|
||||
// identity means strict-mode; otherwise it came from configured default-as.
|
||||
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
|
||||
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
|
||||
return "flag"
|
||||
}
|
||||
if autoDetected {
|
||||
return "auto-detect"
|
||||
}
|
||||
if strictForced != "" {
|
||||
return "strict-mode"
|
||||
}
|
||||
return "default-as"
|
||||
}
|
||||
|
||||
// buildResult maps the resolved identity and local diagnostics into the output.
|
||||
// ResolveAs only ever returns user or bot, so the default branch handles user.
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
|
||||
defaultAs := cfg.DefaultAs
|
||||
if defaultAs == "" {
|
||||
defaultAs = core.AsAuto
|
||||
}
|
||||
res := &whoamiResult{
|
||||
Profile: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: string(defaultAs),
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
}
|
||||
switch as {
|
||||
case core.AsBot:
|
||||
res.Available = diag.Bot.Available
|
||||
res.TokenStatus = diag.Bot.Status
|
||||
if !diag.Bot.Available {
|
||||
res.Hint = "Bot identity not configured. Set app secret or bot token (see `lark-cli config --help`)."
|
||||
}
|
||||
default: // user
|
||||
res.Available = diag.User.Available
|
||||
res.OpenID = diag.User.OpenID
|
||||
res.UserName = diag.User.UserName
|
||||
res.TokenStatus = diag.User.TokenStatus
|
||||
if res.TokenStatus == "" {
|
||||
res.TokenStatus = "missing"
|
||||
}
|
||||
if !diag.User.Available {
|
||||
res.Hint = "No usable user token. Run `lark-cli auth login`."
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// formatPretty writes the human-readable one-glance summary.
|
||||
func formatPretty(w io.Writer, r *whoamiResult) {
|
||||
fmt.Fprintf(w, "Profile: %s (%s, %s)\n", r.Profile, r.AppID, r.Brand)
|
||||
fmt.Fprintf(w, "Identity: %s (%s)\n", r.Identity, r.IdentitySource)
|
||||
if r.Identity == string(core.AsUser) && r.UserName != "" {
|
||||
if r.OpenID != "" {
|
||||
fmt.Fprintf(w, "User: %s (%s)\n", r.UserName, r.OpenID)
|
||||
} else {
|
||||
fmt.Fprintf(w, "User: %s\n", r.UserName)
|
||||
}
|
||||
}
|
||||
token := r.TokenStatus
|
||||
if !r.Available && r.Hint != "" {
|
||||
token = r.TokenStatus + " — " + r.Hint
|
||||
}
|
||||
// Write the label and value as separate %s args rather than one combined
|
||||
// literal. A single label-colon-value literal trips the public-content
|
||||
// credential scanner as a false-positive credential assignment; splitting
|
||||
// the args avoids it while producing identical output.
|
||||
fmt.Fprintf(w, "%s%s\n", "Token: ", token)
|
||||
}
|
||||
258
cmd/whoami/whoami_test.go
Normal file
258
cmd/whoami/whoami_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whoami
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
)
|
||||
|
||||
func TestResolveSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
changedAs bool
|
||||
flagAs core.Identity
|
||||
autoDetected bool
|
||||
strictForced core.Identity
|
||||
want string
|
||||
}{
|
||||
{"explicit flag user", true, core.AsUser, false, "", "flag"},
|
||||
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
|
||||
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto-detect"},
|
||||
{"auto detected", false, "", true, "", "auto-detect"},
|
||||
{"strict mode", false, "", false, core.AsBot, "strict-mode"},
|
||||
{"default-as", false, "", false, "", "default-as"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveSource() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_UserValid(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
|
||||
|
||||
if r.Identity != "user" || r.IdentitySource != "auto-detect" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
if !r.Available || r.TokenStatus != "valid" {
|
||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||
}
|
||||
if r.OpenID != "ou_x" || r.UserName != "Alice" {
|
||||
t.Fatalf("openId/userName = %q/%q", r.OpenID, r.UserName)
|
||||
}
|
||||
if r.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||
}
|
||||
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark {
|
||||
t.Fatalf("app context = %#v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_UserMissingToken(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: false, TokenStatus: ""}, // never logged in
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
}
|
||||
if r.TokenStatus != "missing" {
|
||||
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint empty, want guidance")
|
||||
}
|
||||
if r.DefaultAs != "auto" {
|
||||
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_BotReady(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot}
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: true, Status: "ready"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "default-as", diag)
|
||||
|
||||
if r.Identity != "bot" || r.IdentitySource != "default-as" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
if !r.Available || r.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||
}
|
||||
if r.OpenID != "" || r.UserName != "" {
|
||||
t.Fatalf("bot must not carry openId/userName: %#v", r)
|
||||
}
|
||||
if r.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_BotNotConfigured(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: false, Status: "not_configured"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "auto-detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
}
|
||||
if r.TokenStatus != "not_configured" {
|
||||
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint empty, want guidance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_User(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "my-app", AppID: "cli_x", Brand: core.BrandLark,
|
||||
Identity: "user", IdentitySource: "auto-detect",
|
||||
Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice",
|
||||
})
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"Profile: my-app (cli_x, lark)",
|
||||
"Identity: user (auto-detect)",
|
||||
"User: Alice (ou_x)",
|
||||
"Token: valid",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\n--- got ---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_BotNoUserLine(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
||||
Identity: "bot", IdentitySource: "default-as",
|
||||
Available: true, TokenStatus: "ready",
|
||||
})
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "User:") {
|
||||
t.Errorf("bot output must not contain User: line\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Identity: bot (default-as)") || !strings.Contains(out, "Token: ready") {
|
||||
t.Errorf("unexpected bot output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_UnavailableShowsHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "p", AppID: "cli_x", Brand: core.BrandLark,
|
||||
Identity: "user", IdentitySource: "auto-detect",
|
||||
Available: false, TokenStatus: "missing",
|
||||
Hint: "No usable user token. Run `lark-cli auth login`.",
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Token: missing — No usable user token.") {
|
||||
t.Errorf("expected token line with hint, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_BotJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if !got.Available || got.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus)
|
||||
}
|
||||
if got.Profile != "test-profile" {
|
||||
t.Fatalf("profile = %q, want test-profile", got.Profile)
|
||||
}
|
||||
if got.IdentitySource == "" {
|
||||
t.Fatalf("identitySource empty")
|
||||
}
|
||||
if got.OpenID != "" {
|
||||
t.Fatalf("bot must not carry openId: %q", got.OpenID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_RejectsInvalidAs(t *testing.T) {
|
||||
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
|
||||
t.Run("as="+bad, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", bad})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() with --as %q = nil, want validation error", bad)
|
||||
}
|
||||
// Lock in the typed validation contract: an unsupported identity must
|
||||
// surface as a *errs.ValidationError on --as, not just any error.
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "--as" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--as")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
wantErr := fmt.Errorf("boom")
|
||||
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() error = nil, want propagated config error")
|
||||
}
|
||||
// The f.Config() failure must propagate unchanged, not be masked by a later
|
||||
// command-execution error.
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user