mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
* refactor: retire legacy error envelopes and enforce typed contract
Consolidate all command error reporting onto the typed errs.* contract, remove
the legacy error surface that predated it, and tighten the lint guards so the
contract holds across the whole repository going forward.
Every failure now reaches stderr as one envelope shape: a category, an
optional subtype, a human- and agent-readable message, and a recovery hint,
with invalid parameters listed under `params`. The legacy ExitError envelope,
its constructors, and the boundary bridge that promoted untyped config and
authorization errors are deleted, leaving a single path from error to wire.
Predicate commands keep their silent-exit behavior through a dedicated signal
that carries only an exit code.
Infrastructure paths that still emitted ad-hoc envelopes — flag parsing,
unknown commands and subcommands, plugin and policy guards, confirmation
prompts, and auth/config failures — now classify into the same taxonomy.
Business, API, auth, and config exit codes are preserved; the one behavioral
change is that Cobra usage failures (missing required flag, unknown command,
bad arguments) now emit the typed validation envelope and exit 2, matching the
explicit flag and subcommand guards, instead of Cobra's plain-text exit 1.
Enforcement is repo-wide rather than per-path:
- The errscontract guards run by default everywhere instead of through a
migration allowlist, so legacy envelopes cannot be reintroduced anywhere.
- errorlint runs across the whole repository: every error wrap must use %w and
every comparison must use errors.Is/errors.As, so interior wraps stay legal
but can no longer break the chain the typed boundary relies on.
- The errs-no-bare-wrap guard is keyed by structural prefix instead of an
explicit per-domain allowlist, so new shortcut domains are covered without
editing a list. It runs where forbidigo is enabled (the shortcut domains and
the auth/config/service command groups); repo-wide chain integrity for the
remaining command paths is carried by errorlint above.
* test: align cli_e2e success assertions to the ok envelope
The api and service success path now emits the {"ok":true} envelope, so the
cli_e2e workflow assertions that still expected the old {"code":0} shape via
AssertStdoutStatus(t, 0) fail once they run with live credentials. Switch those
workflow assertions to AssertStdoutStatus(t, true); the fake-payload helper test
in core_test.go keeps its code-shape assertion.
677 lines
25 KiB
Go
677 lines
25 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/huh"
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/i18n"
|
|
"github.com/larksuite/cli/internal/keychain"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"github.com/larksuite/cli/internal/vfs"
|
|
)
|
|
|
|
// BindOptions holds all inputs for config bind.
|
|
type BindOptions struct {
|
|
Factory *cmdutil.Factory
|
|
Source string
|
|
AppID string
|
|
// Identity selects one of two presets — "bot-only" or "user-default" —
|
|
// that expand to underlying StrictMode + DefaultAs in applyPreferences.
|
|
// Empty means "decide later": TUI prompts, flag mode defaults to bot-only
|
|
// (the safer choice — bot acts under its own identity, no impersonation
|
|
// risk; users can still opt into "user-default" via --identity).
|
|
Identity string
|
|
|
|
// Force opts in to an otherwise-blocked flag-mode transition — currently
|
|
// only the bot-only → user-default identity escalation. TUI mode ignores
|
|
// this flag because its own prompts already require human confirmation.
|
|
Force bool
|
|
|
|
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateBindFlags
|
|
langExplicit bool // true when --lang was explicitly passed
|
|
|
|
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
|
|
|
|
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
|
|
// the account being bound. Populated after resolveAccount; TUI stages
|
|
// that run before that (source / account selection) render brand-aware
|
|
// text with an empty value, which brandDisplay falls back to Feishu.
|
|
Brand string
|
|
|
|
// IsTUI is the resolved interactive-mode flag: true only when Source is
|
|
// empty and stdin is a terminal. Computed once at the top of
|
|
// configBindRun; downstream branches read this instead of rechecking
|
|
// IOStreams.IsTerminal. Do not set from outside — it is overwritten.
|
|
IsTUI bool
|
|
}
|
|
|
|
// NewCmdConfigBind creates the config bind subcommand.
|
|
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
|
|
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "bind",
|
|
Short: "Bind Agent config to a workspace (source / app-id / force)",
|
|
Long: `Bind an AI Agent's (OpenClaw / Hermes / Lark Channel) Feishu credentials to a lark-cli workspace.
|
|
|
|
--source is auto-detected from env (OPENCLAW_HOME / HERMES_HOME / LARK_CHANNEL); pass it only to override.
|
|
|
|
For AI agents — DO NOT bind without user confirmation. Binding may
|
|
overwrite an existing one and locks in an identity policy. Ask the user:
|
|
|
|
--identity bot-only bot only (safer default; no impersonation;
|
|
cannot access user resources like personal
|
|
calendar / mail / drive)
|
|
--identity user-default user identity allowed (impersonates the user;
|
|
needed for personal-resource access)
|
|
|
|
Default to bot-only if the user is unsure. Only run the command after
|
|
the user confirms both intent and identity preset.
|
|
|
|
If lark-cli is already bound and the user only wants to change identity
|
|
policy on the SAME app, use 'config strict-mode' — that's the policy
|
|
switch and does not require re-bind. Use 'config bind' only when the
|
|
underlying app itself changes.
|
|
|
|
Interactive terminal use: run with no flags to enter the TUI form.`,
|
|
Example: ` # AI flow: confirm intent + identity with user FIRST, then run:
|
|
lark-cli config bind --source openclaw --app-id <id> --identity bot-only
|
|
lark-cli config bind --source hermes --identity user-default
|
|
lark-cli config bind --source lark-channel
|
|
|
|
# Interactive (terminal user) — TUI prompts for everything:
|
|
lark-cli config bind`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
opts.langExplicit = cmd.Flags().Changed("lang")
|
|
if runF != nil {
|
|
return runF(opts)
|
|
}
|
|
return configBindRun(opts)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringVar(&opts.Source, "source", "", "Agent source to bind from (openclaw|hermes|lark-channel); auto-detected from env signals when omitted")
|
|
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
|
|
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
|
|
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
|
|
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
|
cmdutil.SetRisk(cmd, "write")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// configBindRun is the top-level orchestrator. Each step delegates to a named
|
|
// helper whose signature declares its contract; the body reads as the shape of
|
|
// the bind flow itself, not its mechanics.
|
|
func configBindRun(opts *BindOptions) error {
|
|
if err := validateBindFlags(opts); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Decide TUI-vs-flag mode exactly once; every downstream branch reads
|
|
// opts.IsTUI instead of re-checking IOStreams.IsTerminal.
|
|
opts.IsTUI = opts.Source == "" && opts.Factory.IOStreams.IsTerminal
|
|
|
|
source, err := finalizeSource(opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
core.SetCurrentWorkspace(core.Workspace(source))
|
|
targetConfigPath := core.GetConfigPath()
|
|
|
|
existing, err := reconcileExistingBinding(opts, source, targetConfigPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing.Cancelled {
|
|
return nil
|
|
}
|
|
|
|
appConfig, err := resolveAccount(opts, source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
opts.Brand = string(appConfig.Brand)
|
|
|
|
if err := resolveIdentity(opts); err != nil {
|
|
return err
|
|
}
|
|
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
|
|
return err
|
|
}
|
|
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
|
|
noticeUserDefaultRisk(opts)
|
|
|
|
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
|
}
|
|
|
|
// existingBinding is the outcome of checking whether a workspace was already
|
|
// bound. ConfigBytes is non-nil iff a previous binding existed (and the caller
|
|
// should pass it to commitBinding for stale-keychain cleanup after the new
|
|
// config is durably written). Cancelled is true iff the user declined to
|
|
// replace it in the TUI prompt; the caller should exit cleanly.
|
|
type existingBinding struct {
|
|
ConfigBytes []byte
|
|
Cancelled bool
|
|
}
|
|
|
|
// finalizeSource returns the validated bind source, reconciling three inputs:
|
|
// - opts.Source: the value of --source (may be empty)
|
|
// - env signals: OPENCLAW_* / HERMES_* detected via DetectWorkspaceFromEnv
|
|
// - TUI mode: can prompt the user if neither flag nor env yields a source
|
|
//
|
|
// Resolution (in order):
|
|
// 1. If --source is a non-empty invalid value → fail with ErrValidation.
|
|
// 2. If both --source and an env signal are present and disagree → fail
|
|
// loud; the user almost certainly ran the command in the wrong context.
|
|
// 3. TUI mode only: prompt for language first (so later prompts respect it).
|
|
// 4. --source wins if set. Otherwise use the env-detected source. Otherwise
|
|
// fall back to a TUI prompt (TUI mode) or an error (flag mode).
|
|
func finalizeSource(opts *BindOptions) (string, error) {
|
|
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
|
|
if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" {
|
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit).WithParam("--source")
|
|
}
|
|
|
|
var detected string
|
|
switch core.DetectWorkspaceFromEnv(os.Getenv) {
|
|
case core.WorkspaceOpenClaw:
|
|
detected = "openclaw"
|
|
case core.WorkspaceHermes:
|
|
detected = "hermes"
|
|
case core.WorkspaceLarkChannel:
|
|
detected = "lark-channel"
|
|
}
|
|
|
|
// Explicit and env detection must agree when both are present. Reject
|
|
// before any interactive prompts — running inside Hermes with
|
|
// --source openclaw (or vice versa) is almost always a mistake.
|
|
if explicit != "" && detected != "" && explicit != detected {
|
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"--source %q does not match detected Agent environment (%s)", explicit, detected).
|
|
WithHint("remove --source to auto-detect, or run this command in the correct Agent context").
|
|
WithParam("--source")
|
|
}
|
|
|
|
// TUI: prompt for language before any downstream prompts. The source
|
|
// selection itself may still be skipped entirely if --source or the
|
|
// env already pinned it. Picker offers 2 options (中文 / English) and
|
|
// drives BOTH opts.Lang (preference) and opts.UILang (TUI rendering).
|
|
if opts.IsTUI && !opts.langExplicit {
|
|
lang, err := promptLangSelection()
|
|
if err != nil {
|
|
return "", langSelectionError(err)
|
|
}
|
|
opts.Lang = string(lang)
|
|
opts.UILang = lang
|
|
}
|
|
|
|
if explicit != "" {
|
|
return explicit, nil
|
|
}
|
|
if detected != "" {
|
|
return detected, nil
|
|
}
|
|
if opts.IsTUI {
|
|
return tuiSelectSource(opts)
|
|
}
|
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"cannot determine Agent source: no --source flag and no Agent environment detected").
|
|
WithHint("pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context").
|
|
WithParam("--source")
|
|
}
|
|
|
|
// reconcileExistingBinding reads any existing config at configPath and decides
|
|
// how to proceed. In TUI mode the user is prompted to keep or replace. In flag
|
|
// mode the existing binding is silently overwritten — commitBinding will emit a
|
|
// notice on success so the caller still sees that a rebind happened.
|
|
// See existingBinding for the returned fields.
|
|
func reconcileExistingBinding(opts *BindOptions, source, configPath string) (existingBinding, error) {
|
|
oldConfigData, _ := vfs.ReadFile(configPath)
|
|
if oldConfigData == nil {
|
|
return existingBinding{}, nil
|
|
}
|
|
|
|
if opts.IsTUI {
|
|
action, err := tuiConflictPrompt(opts, source, configPath)
|
|
if err != nil {
|
|
return existingBinding{}, err
|
|
}
|
|
if action == "cancel" {
|
|
msg := getBindMsg(opts.UILang)
|
|
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
|
|
return existingBinding{Cancelled: true}, nil
|
|
}
|
|
return existingBinding{ConfigBytes: oldConfigData}, nil
|
|
}
|
|
|
|
return existingBinding{ConfigBytes: oldConfigData}, nil
|
|
}
|
|
|
|
// resolveAccount runs the source-agnostic bind flow: construct the binder,
|
|
// enumerate candidates, pick one via the shared decision layer, and build a
|
|
// ready-to-persist AppConfig. Adding a new bind source only requires
|
|
// implementing SourceBinder — none of the logic below needs to change.
|
|
func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) {
|
|
binder, err := newBinder(source, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
candidates, err := binder.ListCandidates()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
picked, err := selectCandidate(binder, candidates, opts.AppID, opts.IsTUI,
|
|
func(cs []Candidate) (*Candidate, error) { return tuiSelectApp(opts, source, cs) })
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return binder.Build(picked.AppID)
|
|
}
|
|
|
|
// resolveIdentity ensures opts.Identity is set before applyPreferences runs.
|
|
// TUI mode prompts when empty; flag mode defaults to "bot-only" — the safer
|
|
// preset (bot acts under its own identity, no impersonation). Users who
|
|
// want the broader capability set can pass --identity user-default.
|
|
func resolveIdentity(opts *BindOptions) error {
|
|
if opts.Identity != "" {
|
|
return nil
|
|
}
|
|
if opts.IsTUI {
|
|
id, err := tuiSelectIdentity(opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
opts.Identity = id
|
|
return nil
|
|
}
|
|
opts.Identity = "bot-only"
|
|
return nil
|
|
}
|
|
|
|
// hasStrictBotLock reports whether the given config bytes declare a
|
|
// bot-only lock on at least one app. Unparseable input returns false — it
|
|
// signals "no enforceable lock to honor", consistent with how the rest of
|
|
// the bind flow treats a corrupt previous config (commitBinding will
|
|
// overwrite it cleanly).
|
|
func hasStrictBotLock(data []byte) bool {
|
|
var multi core.MultiAppConfig
|
|
if err := json.Unmarshal(data, &multi); err != nil {
|
|
return false
|
|
}
|
|
for _, app := range multi.Apps {
|
|
if app.StrictMode != nil && *app.StrictMode == core.StrictModeBot {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// warnIdentityEscalation surfaces the risk of a flag-mode bot-only →
|
|
// user-default identity change. Without --force, the CLI refuses so an AI
|
|
// Agent has to relay the warning to the user and get explicit opt-in before
|
|
// retrying. TUI mode is exempt: tuiConflictPrompt + tuiSelectIdentity
|
|
// already require human confirmation in-flow.
|
|
func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error {
|
|
if opts.IsTUI || opts.Force || previousConfigBytes == nil {
|
|
return nil
|
|
}
|
|
if opts.Identity != "user-default" {
|
|
return nil
|
|
}
|
|
if !hasStrictBotLock(previousConfigBytes) {
|
|
return nil
|
|
}
|
|
msg := getBindMsg(opts.UILang)
|
|
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite,
|
|
"config bind --force", "%s", msg.IdentityEscalationMessage).
|
|
WithHint("%s", msg.IdentityEscalationHint)
|
|
}
|
|
|
|
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
|
|
// flag-mode bind that lands on user-default. The bot-only → user-default
|
|
// escalation is already covered by warnIdentityEscalation (errors out before
|
|
// applyPreferences runs), and the TUI flow shows IdentityUserDefaultDesc
|
|
// during identity selection — so this fires specifically for the case those
|
|
// two miss: a fresh flag-mode bind that goes directly to user-default with
|
|
// no previous bot lock to escalate from. Without this, AI agents finish such
|
|
// a bind with only a "配置成功" message and never relay to the user that the
|
|
// AI can now act under their identity.
|
|
func noticeUserDefaultRisk(opts *BindOptions) {
|
|
if opts.IsTUI || opts.Identity != "user-default" {
|
|
return
|
|
}
|
|
msg := getBindMsg(opts.UILang)
|
|
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
|
|
}
|
|
|
|
// applyPreferences expands the chosen identity preset into the underlying
|
|
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
|
|
// profile's intent survives later changes to global strict-mode settings.
|
|
// preferredLang resolves the language to persist: the requested value when set,
|
|
// otherwise the prior one — so an unset --lang never clears a stored preference.
|
|
func preferredLang(requested, prior i18n.Lang) i18n.Lang {
|
|
if requested != "" {
|
|
return requested
|
|
}
|
|
return prior
|
|
}
|
|
|
|
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) {
|
|
switch opts.Identity {
|
|
case "bot-only":
|
|
sm := core.StrictModeBot
|
|
appConfig.StrictMode = &sm
|
|
appConfig.DefaultAs = core.AsBot
|
|
case "user-default":
|
|
sm := core.StrictModeOff
|
|
appConfig.StrictMode = &sm
|
|
appConfig.DefaultAs = core.AsUser
|
|
}
|
|
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior)
|
|
}
|
|
|
|
// priorLang returns the language preference recorded in a previous config, or
|
|
// "" if there is none / the bytes don't parse. Reads from CurrentApp (or Apps[0]
|
|
// fallback) — scanning all apps for the first non-empty Lang would leak the
|
|
// wrong profile's preference into a re-bind when the workspace holds multiple
|
|
// named profiles and the active one disagrees with Apps[0].
|
|
func priorLang(previousConfigBytes []byte) i18n.Lang {
|
|
var multi core.MultiAppConfig
|
|
if json.Unmarshal(previousConfigBytes, &multi) != nil {
|
|
return ""
|
|
}
|
|
if app := multi.CurrentAppConfig(""); app != nil {
|
|
return app.Lang
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// commitBinding finalizes the bind: atomic write of the new workspace config,
|
|
// best-effort cleanup of stale keychain entries from the previous binding (if
|
|
// any), and a JSON success envelope. Cleanup runs only after the new config
|
|
// is durably written — if anything fails earlier, the old workspace stays
|
|
// usable.
|
|
func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
|
|
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
|
|
|
|
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
|
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
|
|
}
|
|
data, err := json.MarshalIndent(multi, "", " ")
|
|
if err != nil {
|
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err)
|
|
}
|
|
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
|
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err)
|
|
}
|
|
|
|
replaced := previousConfigBytes != nil
|
|
// uiMsg renders human-facing TUI text (stderr success banner). Follows
|
|
// opts.UILang — zh by default; picker can flip it to en. --lang does
|
|
// not influence the TUI language.
|
|
uiMsg := getBindMsg(opts.UILang)
|
|
display := sourceDisplayName(source)
|
|
|
|
if replaced {
|
|
cleanupKeychainFromData(opts.Factory.Keychain, previousConfigBytes, appConfig)
|
|
}
|
|
|
|
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
|
|
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice)
|
|
|
|
if opts.langExplicit && opts.Lang != "" {
|
|
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(uiMsg.LangPreferenceSet, opts.Lang))
|
|
}
|
|
|
|
// TUI mode is a human sitting at a terminal; the BindSuccess notice on
|
|
// stderr is enough and a machine-readable JSON dump on stdout is just
|
|
// noise. Flag mode (Agent orchestration, scripts, piped output) still
|
|
// gets the full envelope for programmatic consumption.
|
|
if opts.IsTUI {
|
|
return nil
|
|
}
|
|
|
|
envelope := map[string]interface{}{
|
|
"ok": true,
|
|
"workspace": source,
|
|
"app_id": appConfig.AppId,
|
|
"config_path": configPath,
|
|
"replaced": replaced,
|
|
"identity": opts.Identity,
|
|
}
|
|
// JSON "message" follows the effective preference on disk (appConfig.Lang),
|
|
// not the raw --lang value: when --lang is omitted on re-bind, preferredLang
|
|
// has already inherited the prior preference into appConfig.Lang, and the
|
|
// message should respect that inherited choice. stderr above follows UILang.
|
|
prefMsg := getBindMsg(appConfig.Lang)
|
|
brand := brandDisplay(string(appConfig.Brand), appConfig.Lang)
|
|
switch opts.Identity {
|
|
case "bot-only":
|
|
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
|
|
case "user-default":
|
|
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
|
|
}
|
|
|
|
resultJSON, _ := json.Marshal(envelope)
|
|
fmt.Fprintln(opts.Factory.IOStreams.Out, string(resultJSON))
|
|
return nil
|
|
}
|
|
|
|
// cleanupKeychainFromData removes keychain entries referenced by a previous
|
|
// config snapshot, skipping any entry whose keychain ID is still in use by
|
|
// the new app config. This prevents rebinding the same appId from deleting
|
|
// the secret that ForStorage just wrote (old and new secret share the same
|
|
// keychain key, derived from appId). Best-effort: errors are silently
|
|
// ignored (same contract as config init's cleanup).
|
|
func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core.AppConfig) {
|
|
var multi core.MultiAppConfig
|
|
if err := json.Unmarshal(data, &multi); err != nil {
|
|
return
|
|
}
|
|
keepID := ""
|
|
if keep != nil && keep.AppSecret.Ref != nil && keep.AppSecret.Ref.Source == "keychain" {
|
|
keepID = keep.AppSecret.Ref.ID
|
|
}
|
|
for _, app := range multi.Apps {
|
|
if keepID != "" && app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" && app.AppSecret.Ref.ID == keepID {
|
|
continue
|
|
}
|
|
core.RemoveSecretStore(app.AppSecret, kc)
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// TUI helpers (huh forms, matching config init interactive style)
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
// tuiSelectSource prompts user to choose bind source.
|
|
func tuiSelectSource(opts *BindOptions) (string, error) {
|
|
msg := getBindMsg(opts.UILang)
|
|
var source string
|
|
|
|
// Pre-select based on detected env signals
|
|
detected := core.DetectWorkspaceFromEnv(os.Getenv)
|
|
switch detected {
|
|
case core.WorkspaceOpenClaw:
|
|
source = "openclaw"
|
|
case core.WorkspaceHermes:
|
|
source = "hermes"
|
|
case core.WorkspaceLarkChannel:
|
|
source = "lark-channel"
|
|
default:
|
|
source = "openclaw" // default first option
|
|
}
|
|
|
|
// Resolve actual paths for display
|
|
openclawPath := resolveOpenClawConfigPath()
|
|
hermesEnvPath := resolveHermesEnvPath()
|
|
larkChannelPath := resolveLarkChannelConfigPath()
|
|
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewSelect[string]().
|
|
Title(msg.SelectSource).
|
|
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.UILang))).
|
|
Options(
|
|
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
|
|
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
|
|
huh.NewOption(fmt.Sprintf(msg.SourceLarkChannel, larkChannelPath), "lark-channel"),
|
|
).
|
|
Value(&source),
|
|
),
|
|
).WithTheme(cmdutil.ThemeFeishu())
|
|
|
|
if err := form.Run(); err != nil {
|
|
if err == huh.ErrUserAborted {
|
|
return "", output.ErrBare(1)
|
|
}
|
|
return "", err
|
|
}
|
|
return source, nil
|
|
}
|
|
|
|
// tuiSelectApp prompts the user to choose from multiple account candidates.
|
|
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
|
|
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
|
|
msg := getBindMsg(opts.UILang)
|
|
options := make([]huh.Option[int], 0, len(candidates))
|
|
for i, c := range candidates {
|
|
label := c.AppID
|
|
if c.Label != "" {
|
|
label = fmt.Sprintf("%s (%s)", c.Label, c.AppID)
|
|
}
|
|
options = append(options, huh.NewOption(label, i))
|
|
}
|
|
|
|
var selected int
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewSelect[int]().
|
|
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.UILang))).
|
|
Options(options...).
|
|
Value(&selected),
|
|
),
|
|
).WithTheme(cmdutil.ThemeFeishu())
|
|
|
|
if err := form.Run(); err != nil {
|
|
if err == huh.ErrUserAborted {
|
|
return nil, output.ErrBare(1)
|
|
}
|
|
return nil, err
|
|
}
|
|
return &candidates[selected], nil
|
|
}
|
|
|
|
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
|
|
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
|
|
msg := getBindMsg(opts.UILang)
|
|
|
|
// Build existing binding summary
|
|
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
|
|
if data, err := vfs.ReadFile(configPath); err == nil {
|
|
var multi core.MultiAppConfig
|
|
if json.Unmarshal(data, &multi) == nil && len(multi.Apps) > 0 {
|
|
app := multi.Apps[0]
|
|
existingSummary = fmt.Sprintf(msg.ConflictDesc,
|
|
source, app.AppId, app.Brand, configPath)
|
|
}
|
|
}
|
|
|
|
var action string
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewNote().
|
|
Title(msg.ConflictTitle).
|
|
Description(existingSummary),
|
|
huh.NewSelect[string]().
|
|
Options(
|
|
huh.NewOption(msg.ConflictForce, "force"),
|
|
huh.NewOption(msg.ConflictCancel, "cancel"),
|
|
).
|
|
Value(&action),
|
|
),
|
|
).WithTheme(cmdutil.ThemeFeishu())
|
|
|
|
if err := form.Run(); err != nil {
|
|
if err == huh.ErrUserAborted {
|
|
return "cancel", nil
|
|
}
|
|
return "", err
|
|
}
|
|
return action, nil
|
|
}
|
|
|
|
// indent prepends two spaces to every line of s. Used to visually nest
|
|
// multi-line option descriptions under their label in tuiSelectIdentity.
|
|
func indent(s string) string {
|
|
return " " + strings.ReplaceAll(s, "\n", "\n ")
|
|
}
|
|
|
|
// validateBindFlags validates enum flags early, before any side effects.
|
|
func validateBindFlags(opts *BindOptions) error {
|
|
if opts.Identity != "" {
|
|
switch opts.Identity {
|
|
case "bot-only", "user-default":
|
|
default:
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity")
|
|
}
|
|
}
|
|
lang, err := cmdutil.ParseLangFlag(opts.Lang)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
opts.Lang = string(lang)
|
|
return nil
|
|
}
|
|
|
|
// tuiSelectIdentity prompts user to pick one of two identity presets.
|
|
// bot-only is listed first so Enter on the default highlight maps to the
|
|
// flag-mode default for consistency across the two modes, and also because
|
|
// bot-only is the safer preset (no impersonation risk).
|
|
//
|
|
// Layout: each option's description is embedded under its label using a
|
|
// multi-line option value. huh styles the whole option block (label +
|
|
// indented description) as selected / unselected, giving a clear visual
|
|
// mapping between picker rows and their explanations — the dynamic
|
|
// DescriptionFunc approach breaks here because a longer description on
|
|
// hover pushes options out of the field's initial viewport.
|
|
func tuiSelectIdentity(opts *BindOptions) (string, error) {
|
|
msg := getBindMsg(opts.UILang)
|
|
brand := brandDisplay(opts.Brand, opts.UILang)
|
|
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
|
|
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
|
|
var value string
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewSelect[string]().
|
|
Title(msg.SelectIdentity).
|
|
Options(
|
|
huh.NewOption(botLabel, "bot-only"),
|
|
huh.NewOption(userLabel, "user-default"),
|
|
).
|
|
Value(&value),
|
|
),
|
|
).WithTheme(cmdutil.ThemeFeishu())
|
|
|
|
if err := form.Run(); err != nil {
|
|
if err == huh.ErrUserAborted {
|
|
return "", output.ErrBare(1)
|
|
}
|
|
return "", err
|
|
}
|
|
return value, nil
|
|
}
|