mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
330 lines
11 KiB
Go
330 lines
11 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package core
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"unicode/utf8"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/i18n"
|
|
"github.com/larksuite/cli/internal/keychain"
|
|
"github.com/larksuite/cli/internal/recovery"
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"github.com/larksuite/cli/internal/vfs"
|
|
)
|
|
|
|
// Identity represents the caller identity for API requests.
|
|
type Identity string
|
|
|
|
const (
|
|
AsUser Identity = "user"
|
|
AsBot Identity = "bot"
|
|
AsAuto Identity = "auto"
|
|
)
|
|
|
|
// IsBot returns true if the identity is bot.
|
|
func (id Identity) IsBot() bool { return id == AsBot }
|
|
|
|
// AppUser is a logged-in user record stored in config.
|
|
type AppUser struct {
|
|
UserOpenId string `json:"userOpenId"`
|
|
UserName string `json:"userName"`
|
|
}
|
|
|
|
// AppConfig is a per-app configuration entry (stored format — secrets may be unresolved).
|
|
type AppConfig struct {
|
|
Name string `json:"name,omitempty"`
|
|
AppId string `json:"appId"`
|
|
AppSecret SecretInput `json:"appSecret"`
|
|
Brand LarkBrand `json:"brand"`
|
|
Lang i18n.Lang `json:"lang,omitempty"`
|
|
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
|
|
StrictMode *StrictMode `json:"strictMode,omitempty"`
|
|
Users []AppUser `json:"users"`
|
|
}
|
|
|
|
// ProfileName returns the display name for this app config.
|
|
// If Name is set, returns Name; otherwise falls back to AppId.
|
|
func (a *AppConfig) ProfileName() string {
|
|
if a.Name != "" {
|
|
return a.Name
|
|
}
|
|
return a.AppId
|
|
}
|
|
|
|
// MultiAppConfig is the multi-app config file format.
|
|
type MultiAppConfig struct {
|
|
StrictMode StrictMode `json:"strictMode,omitempty"`
|
|
RiskControl *bool `json:"riskControl,omitempty"`
|
|
CurrentApp string `json:"currentApp,omitempty"`
|
|
PreviousApp string `json:"previousApp,omitempty"`
|
|
Apps []AppConfig `json:"apps"`
|
|
}
|
|
|
|
// RiskControlEnabled resolves the workspace policy. An omitted preference
|
|
// keeps the default-on account-protection behavior.
|
|
func (m *MultiAppConfig) RiskControlEnabled() bool {
|
|
return m != nil && (m.RiskControl == nil || *m.RiskControl)
|
|
}
|
|
|
|
// CurrentAppConfig returns the currently active app config.
|
|
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
|
|
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {
|
|
if profileOverride != "" {
|
|
if app := m.FindApp(profileOverride); app != nil {
|
|
return app
|
|
}
|
|
return nil
|
|
}
|
|
if m.CurrentApp != "" {
|
|
if app := m.FindApp(m.CurrentApp); app != nil {
|
|
return app
|
|
}
|
|
return nil // explicit currentApp not found; don't silently fallback
|
|
}
|
|
if len(m.Apps) > 0 {
|
|
return &m.Apps[0]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EffectiveProfile resolves which profile this invocation actually uses and
|
|
// which channel decided that. It is the single definition of "effective" for
|
|
// status output (config show, profile list, profile use warnings); the
|
|
// persisted CurrentApp answers a different question — which profile applies
|
|
// when no selector is present — and must not be conflated with it.
|
|
//
|
|
// A nil app with a non-empty selector means the selector is dangling; the
|
|
// caller decides between an error (RequireAppConfig) and a warning (listing
|
|
// commands, which are the user's recovery surface).
|
|
func (m *MultiAppConfig) EffectiveProfile(profile string, source ProfileSource) (*AppConfig, ProfileSource) {
|
|
app := m.CurrentAppConfig(profile)
|
|
if profile != "" {
|
|
return app, source
|
|
}
|
|
// An empty selector value (including an explicit --profile=) defers to
|
|
// the persisted state, so the persisted channel is what decided.
|
|
return app, ProfileFromConfig
|
|
}
|
|
|
|
// FindApp looks up an app by name, then by appId. Returns nil if not found.
|
|
// Name match takes priority: if profile A has Name "X" and profile B has AppId "X",
|
|
// FindApp("X") returns profile A.
|
|
func (m *MultiAppConfig) FindApp(name string) *AppConfig {
|
|
// First pass: match by Name
|
|
for i := range m.Apps {
|
|
if m.Apps[i].Name != "" && m.Apps[i].Name == name {
|
|
return &m.Apps[i]
|
|
}
|
|
}
|
|
// Second pass: match by AppId
|
|
for i := range m.Apps {
|
|
if m.Apps[i].AppId == name {
|
|
return &m.Apps[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindAppIndex looks up an app index by name, then by appId. Returns -1 if not found.
|
|
func (m *MultiAppConfig) FindAppIndex(name string) int {
|
|
for i := range m.Apps {
|
|
if m.Apps[i].Name != "" && m.Apps[i].Name == name {
|
|
return i
|
|
}
|
|
}
|
|
for i := range m.Apps {
|
|
if m.Apps[i].AppId == name {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// ProfileNames returns all profile names (Name if set, otherwise AppId).
|
|
func (m *MultiAppConfig) ProfileNames() []string {
|
|
names := make([]string, len(m.Apps))
|
|
for i := range m.Apps {
|
|
names[i] = m.Apps[i].ProfileName()
|
|
}
|
|
return names
|
|
}
|
|
|
|
// ValidateProfileName checks that a profile name is valid.
|
|
// Rejects empty names, whitespace, control characters, and shell-problematic characters,
|
|
// but allows Unicode letters (e.g. Chinese, Japanese) for localized profile names.
|
|
func ValidateProfileName(name string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("profile name cannot be empty")
|
|
}
|
|
if utf8.RuneCountInString(name) > 64 {
|
|
return fmt.Errorf("profile name %q is too long (max 64 characters)", name)
|
|
}
|
|
for _, r := range name {
|
|
if r <= 0x1F || r == 0x7F { // control characters
|
|
return fmt.Errorf("invalid profile name %q: contains control characters", name)
|
|
}
|
|
switch r {
|
|
case ' ', '\t', '/', '\\', '"', '\'', '`', '$', '#', '!', '&', '|', ';', '(', ')', '{', '}', '[', ']', '<', '>', '?', '*', '~':
|
|
return fmt.Errorf("invalid profile name %q: contains invalid character %q", name, r)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CliConfig is the resolved single-app config used by downstream code.
|
|
type CliConfig struct {
|
|
ProfileName string
|
|
AppID string
|
|
AppSecret string
|
|
Brand LarkBrand
|
|
DefaultAs Identity // AsUser | AsBot | AsAuto | "" (from config file)
|
|
UserOpenId string
|
|
UserName string
|
|
Lang i18n.Lang
|
|
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
|
|
}
|
|
|
|
// identityBotBit is the bit flag for bot identity in SupportedIdentities.
|
|
// Must match extension/credential.SupportsBot.
|
|
const identityBotBit uint8 = 1 << 1
|
|
|
|
// CanBot reports whether the current credential context supports bot identity.
|
|
// Returns true when SupportedIdentities is unset (0, unknown) or includes the bot bit.
|
|
func (c *CliConfig) CanBot() bool {
|
|
return c.SupportedIdentities == 0 || c.SupportedIdentities&identityBotBit != 0
|
|
}
|
|
|
|
// GetConfigDir returns the config directory path for the current workspace.
|
|
// When workspace is local (default), this returns the same path as before
|
|
// (LARKSUITE_CLI_CONFIG_DIR or ~/.lark-cli) — fully backward-compatible.
|
|
// When workspace is openclaw/hermes, returns base/openclaw or base/hermes.
|
|
func GetConfigDir() string {
|
|
return GetRuntimeDir()
|
|
}
|
|
|
|
// GetConfigPath returns the config file path for the current workspace.
|
|
func GetConfigPath() string {
|
|
return filepath.Join(GetConfigDir(), "config.json")
|
|
}
|
|
|
|
// ErrMalformedConfig marks a config-load failure caused by malformed file
|
|
// content (unparseable JSON, structurally empty) rather than a missing or
|
|
// unreadable file. Callers classify with errors.Is rather than sniffing the
|
|
// message text.
|
|
var ErrMalformedConfig = errors.New("malformed config")
|
|
|
|
// LoadMultiAppConfig loads multi-app config from disk.
|
|
func LoadMultiAppConfig() (*MultiAppConfig, error) {
|
|
data, err := vfs.ReadFile(GetConfigPath())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var multi MultiAppConfig
|
|
if err := json.Unmarshal(data, &multi); err != nil {
|
|
return nil, fmt.Errorf("invalid config format: %w: %w", ErrMalformedConfig, err)
|
|
}
|
|
if len(multi.Apps) == 0 {
|
|
return nil, fmt.Errorf("invalid config format: no apps: %w", ErrMalformedConfig)
|
|
}
|
|
return &multi, nil
|
|
}
|
|
|
|
// SaveMultiAppConfig saves config to disk.
|
|
func SaveMultiAppConfig(config *MultiAppConfig) error {
|
|
dir := GetConfigDir()
|
|
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(config, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return validate.AtomicWrite(GetConfigPath(), append(data, '\n'), 0600)
|
|
}
|
|
|
|
// RequireConfig loads the single-app config using the default profile resolution.
|
|
func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
|
|
return RequireConfigForProfile(kc, "")
|
|
}
|
|
|
|
// RequireConfigForProfile loads the single-app config for a specific profile.
|
|
// Resolution priority: profileOverride > config.CurrentApp > Apps[0].
|
|
func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
|
|
raw, err := LoadMultiAppConfig()
|
|
if err != nil || raw == nil || len(raw.Apps) == 0 {
|
|
return nil, NotConfiguredError()
|
|
}
|
|
// This legacy wrapper has no channel information for its override; the
|
|
// flag wording is the safe default (it never misattributes to the env).
|
|
return ResolveConfigFromMulti(raw, kc, profileOverride, ProfileFromFlag)
|
|
}
|
|
|
|
// ResolveConfigFromMulti resolves a single-app config from an already-loaded MultiAppConfig.
|
|
// This avoids re-reading the config file when the caller has already loaded it.
|
|
// source records which channel selected profileOverride so a resolution
|
|
// failure can name the input the user must fix.
|
|
func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, profileOverride string, source ProfileSource) (*CliConfig, error) {
|
|
app, err := raw.RequireAppConfig(profileOverride, source)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
|
|
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
|
|
WithHint("%s", err.Error()).
|
|
WithCause(err)
|
|
}
|
|
|
|
secret, err := ResolveSecretInput(app.AppSecret, kc)
|
|
if err != nil {
|
|
if errs.IsTyped(err) {
|
|
return nil, err
|
|
}
|
|
subtype := errs.SubtypeNotConfigured
|
|
if isMalformedConfigError(err) {
|
|
subtype = errs.SubtypeInvalidConfig
|
|
}
|
|
return nil, errs.NewConfigError(subtype, "%s", err.Error()).WithCause(err)
|
|
}
|
|
cfg := &CliConfig{
|
|
ProfileName: app.ProfileName(),
|
|
AppID: app.AppId,
|
|
AppSecret: secret,
|
|
Brand: ParseBrand(string(app.Brand)),
|
|
Lang: app.Lang,
|
|
DefaultAs: app.DefaultAs,
|
|
}
|
|
if len(app.Users) > 0 {
|
|
cfg.UserOpenId = app.Users[0].UserOpenId
|
|
cfg.UserName = app.Users[0].UserName
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// RequireAuth loads config and ensures a user is logged in.
|
|
func RequireAuth(kc keychain.KeychainAccess) (*CliConfig, error) {
|
|
return RequireAuthForProfile(kc, "")
|
|
}
|
|
|
|
// RequireAuthForProfile loads config for a profile and ensures a user is logged in.
|
|
func RequireAuthForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
|
|
cfg, err := RequireConfigForProfile(kc, profileOverride)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.UserOpenId == "" {
|
|
return nil, recovery.Attach(
|
|
errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in"),
|
|
recovery.UserAuthorization(),
|
|
)
|
|
}
|
|
return cfg, nil
|
|
}
|