mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
236 lines
8.0 KiB
Go
236 lines
8.0 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmdutil
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"strings"
|
|
|
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
extcred "github.com/larksuite/cli/extension/credential"
|
|
"github.com/larksuite/cli/extension/fileio"
|
|
"github.com/larksuite/cli/internal/client"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/credential"
|
|
"github.com/larksuite/cli/internal/keychain"
|
|
)
|
|
|
|
// Factory holds shared dependencies injected into every command.
|
|
// All function fields are lazily initialized and cached after first call.
|
|
// In tests, replace any field to stub out external dependencies.
|
|
type InvocationContext struct {
|
|
Profile string
|
|
}
|
|
|
|
type Factory struct {
|
|
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
|
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
|
|
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
|
|
IOStreams *IOStreams // stdin/stdout/stderr streams
|
|
|
|
Invocation InvocationContext // Immutable call context; do not mutate after Factory construction.
|
|
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
|
|
IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected
|
|
ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call
|
|
CurrentCommand *cobra.Command // last matched command being executed; set during PersistentPreRun
|
|
|
|
Credential *credential.CredentialProvider
|
|
|
|
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
|
|
|
|
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
|
}
|
|
|
|
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
|
// The provider controls whether the returned instance is fresh or cached.
|
|
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
|
if f == nil || f.FileIOProvider == nil {
|
|
return nil
|
|
}
|
|
return f.FileIOProvider.ResolveFileIO(ctx)
|
|
}
|
|
|
|
// ResolveAs returns the effective identity type.
|
|
// If the user explicitly passed --as, use that value; otherwise use the configured default.
|
|
// When the value is "auto" (or unset), auto-detect based on credential hints.
|
|
func (f *Factory) ResolveAs(ctx context.Context, cmd *cobra.Command, flagAs core.Identity) core.Identity {
|
|
f.IdentityAutoDetected = false
|
|
|
|
if cmd != nil && cmd.Flags().Changed("as") {
|
|
if flagAs != core.AsAuto {
|
|
f.ResolvedIdentity = flagAs
|
|
return flagAs
|
|
}
|
|
// --as auto: fall through to auto-detect
|
|
}
|
|
|
|
mode := f.ResolveStrictMode(ctx)
|
|
// Strict mode forces implicit identity choices. Explicit --as user/bot is
|
|
// preserved above so CheckStrictMode can reject incompatible requests.
|
|
if forced := mode.ForcedIdentity(); forced != "" {
|
|
f.ResolvedIdentity = forced
|
|
return forced
|
|
}
|
|
|
|
hint := f.resolveIdentityHint(ctx)
|
|
if cmd == nil || !cmd.Flags().Changed("as") {
|
|
if defaultAs := resolveDefaultAsFromHint(hint); defaultAs != "" && defaultAs != core.AsAuto {
|
|
f.ResolvedIdentity = defaultAs
|
|
return f.ResolvedIdentity
|
|
}
|
|
}
|
|
|
|
// Auto-detect based on credential hint
|
|
f.IdentityAutoDetected = true
|
|
result := autoDetectIdentityFromHint(hint)
|
|
f.ResolvedIdentity = result
|
|
return result
|
|
}
|
|
|
|
func resolveDefaultAsFromHint(hint *credential.IdentityHint) core.Identity {
|
|
if hint != nil {
|
|
return hint.DefaultAs
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func autoDetectIdentityFromHint(hint *credential.IdentityHint) core.Identity {
|
|
if hint != nil && hint.AutoAs != "" {
|
|
return hint.AutoAs
|
|
}
|
|
return core.AsBot
|
|
}
|
|
|
|
func (f *Factory) resolveIdentityHint(ctx context.Context) *credential.IdentityHint {
|
|
if f.Credential == nil {
|
|
return nil
|
|
}
|
|
hint, err := f.Credential.ResolveIdentityHint(ctx)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return hint
|
|
}
|
|
|
|
// CheckIdentity verifies the resolved identity is in the supported list.
|
|
// On success, sets f.ResolvedIdentity. On failure, returns an error
|
|
// tailored to whether the identity was explicit (--as) or auto-detected.
|
|
func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
|
|
for _, t := range supported {
|
|
if string(as) == t {
|
|
f.ResolvedIdentity = as
|
|
return nil
|
|
}
|
|
}
|
|
list := strings.Join(supported, ", ")
|
|
if f.IdentityAutoDetected {
|
|
base := errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s",
|
|
as, list).
|
|
WithParam("--as")
|
|
if len(supported) > 0 {
|
|
return base.WithHint("use --as %s", supported[0])
|
|
}
|
|
return base
|
|
}
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"--as %s is not supported, this command only supports: %s", as, list).
|
|
WithParam("--as")
|
|
}
|
|
|
|
// ResolveStrictMode returns the effective strict mode by reading
|
|
// Account.SupportedIdentities from the credential provider chain.
|
|
func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
|
|
if f.Credential == nil {
|
|
return core.StrictModeOff
|
|
}
|
|
acct, err := f.Credential.ResolveAccount(ctx)
|
|
if err != nil || acct == nil {
|
|
return core.StrictModeOff
|
|
}
|
|
ids := extcred.IdentitySupport(acct.SupportedIdentities)
|
|
switch {
|
|
case ids.BotOnly():
|
|
return core.StrictModeBot
|
|
case ids.UserOnly():
|
|
return core.StrictModeUser
|
|
default:
|
|
return core.StrictModeOff
|
|
}
|
|
}
|
|
|
|
// CheckStrictMode returns an error if strict mode is active and identity is not allowed.
|
|
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
|
|
mode := f.ResolveStrictMode(ctx)
|
|
if mode.IsActive() && !mode.AllowsIdentity(as) {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()).
|
|
WithHint("if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NewAPIClient creates an APIClient using the Factory's base Config (app credentials only).
|
|
// For user-mode calls where the correct user profile matters, use NewAPIClientWithConfig instead.
|
|
func (f *Factory) NewAPIClient() (*client.APIClient, error) {
|
|
cfg, err := f.Config()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return f.NewAPIClientWithConfig(cfg)
|
|
}
|
|
|
|
// NewAPIClientWithConfig creates an APIClient with an explicit config.
|
|
// Use this when the caller has already resolved the correct config.
|
|
func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient, error) {
|
|
sdk, err := f.LarkClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpClient, err := f.HttpClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
errOut := io.Discard
|
|
if f.IOStreams != nil {
|
|
errOut = f.IOStreams.ErrOut
|
|
}
|
|
return &client.APIClient{
|
|
Config: cfg,
|
|
SDK: sdk,
|
|
HTTP: httpClient,
|
|
ErrOut: errOut,
|
|
Credential: f.Credential,
|
|
}, nil
|
|
}
|
|
|
|
// RequireBuiltinCredentialProvider returns a typed validation error when an
|
|
// extension provider is actively managing credentials. Intended for use as
|
|
// PersistentPreRunE on the auth and config parent commands.
|
|
//
|
|
// Returns nil when:
|
|
// - f.Credential is nil (test environments without credential setup)
|
|
// - No extension provider is active (built-in keychain/config path is used)
|
|
func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command string) error {
|
|
if f.Credential == nil {
|
|
return nil
|
|
}
|
|
provName, err := f.Credential.ActiveExtensionProviderName(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if provName == "" {
|
|
return nil
|
|
}
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"%q is not supported: credentials are provided externally and do not support interactive management", command).
|
|
WithHint("If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.")
|
|
}
|