mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
Every failure on the authentication, authorization, and configuration
path now surfaces as a typed structured error instead of an ad-hoc
envelope. Users and scripts that consume CLI output get:
- a fixed nine-category taxonomy on the wire, each mapped to a
stable shell exit code (authentication/authorization/config = 3,
network = 4, internal = 5, policy = 6, confirmation = 10)
- identity-aware detail fields (missing_scopes, requested_scopes,
granted_scopes, console_url, log_id, retryable, hint) carried
uniformly on the envelope
- a single canonical policy envelope at exit 6; the legacy
auth_error carve-out is retired
- per-subtype canonical message + hint that preserves Lark's
diagnostic phrasing and routes recovery to the right actor:
app developer (app_scope_not_applied), user (missing_scope,
token_scope_insufficient, user_unauthorized), or tenant admin
(app_unavailable, app_disabled)
- wrong app credentials classify as config/invalid_client whether
surfaced by the Open API endpoint (99991543) or the tenant
access-token mint endpoint (10003 / 10014), instead of
collapsing to a transport error or api/unknown
- local shortcut scope preflight emits the same
authorization/missing_scope envelope (identity + deterministic
missing-scope set) used by the post-call permission path, so AI
consumers read the same structured shape from precheck and from
server-returned permission denial
- streaming download/upload failures keep the same network subtype
split (timeout / TLS / DNS / transport) as the non-stream path
instead of collapsing every cause to a generic transport failure
- console_url is carried only on the bot-perspective
app_scope_not_applied envelope (where the recovery action is
"developer applies the scope at the developer console"); the
user-perspective missing_scope envelope drops the field, since
the only actionable user recovery is `lark-cli auth login --scope`
and pointing an end user at a console they cannot modify is
misleading
- bind workflows (Hermes / OpenClaw / lark-channel) flatten dynamic
Type tags to wire 'config' with the original module name kept
as a metric label
All 10 typed errors are cause-bearing, nil-safe on .Error() and
.Unwrap(), and defensively clone slice setter inputs. Four lint
rules (CheckNilSafeError / CheckBuilderImmutable / CheckUnwrapSymmetry
/ CheckBuildAPIErrorArms) lock these invariants on migrated paths.
233 lines
7.9 KiB
Go
233 lines
7.9 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmdutil
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"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)
|
|
}
|
|
|
|
// 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.")
|
|
}
|