mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
205 lines
9.0 KiB
Go
205 lines
9.0 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package core
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/envvars"
|
|
"github.com/larksuite/cli/internal/recovery"
|
|
)
|
|
|
|
// isMalformedConfigError reports whether a config load failure indicates a
|
|
// malformed file (unparseable / structurally empty) rather than an absent or
|
|
// inaccessible one. Malformed files map to the invalid_config subtype so the
|
|
// user is told to fix the file instead of re-running init. Detection is by
|
|
// ErrMalformedConfig sentinel, not message text.
|
|
func isMalformedConfigError(err error) bool {
|
|
return errors.Is(err, ErrMalformedConfig)
|
|
}
|
|
|
|
// LoadOrNotConfigured wraps LoadMultiAppConfig with the standard "not yet
|
|
// configured vs. couldn't read" disambiguation that every config-required
|
|
// command should use:
|
|
//
|
|
// - file missing → workspace-aware NotConfiguredError (init / bind hint)
|
|
// - parse error / permission error → real load failure with the original
|
|
// cause preserved, so the user can actually fix the broken file
|
|
//
|
|
// Without this, every call site that did `if err != nil { return
|
|
// NotConfiguredError() }` silently coerced corrupt-config into "run init",
|
|
// which sent users in circles when their config.json was just malformed.
|
|
func LoadOrNotConfigured() (*MultiAppConfig, error) {
|
|
multi, err := LoadMultiAppConfig()
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, NotConfiguredError()
|
|
}
|
|
// Surface the real cause (parse error, permission denied, etc.)
|
|
// so the user can fix the broken file. A malformed file is
|
|
// invalid_config; anything else (permission denied, etc.) is
|
|
// not_configured. Both stay on the typed structured-envelope path
|
|
// at the root command's error sink.
|
|
subtype := errs.SubtypeNotConfigured
|
|
if isMalformedConfigError(err) {
|
|
subtype = errs.SubtypeInvalidConfig
|
|
}
|
|
return nil, errs.NewConfigError(subtype, "failed to load config: %v", err).WithCause(err)
|
|
}
|
|
if multi == nil || len(multi.Apps) == 0 {
|
|
return nil, NotConfiguredError()
|
|
}
|
|
return multi, nil
|
|
}
|
|
|
|
const (
|
|
// localInitHint is the canonical "you're in a regular terminal, run
|
|
// init" guidance — shared by NotConfiguredError and NoActiveProfileError
|
|
// so the same session can't show two different recommended commands.
|
|
localInitHint = "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."
|
|
|
|
// agentBindHint is the canonical "you're in an Agent workspace, see
|
|
// the binding workflow" guidance. Always points at --help (never a
|
|
// ready-to-run bind command) so the AI reads the confirmation
|
|
// discipline (identity preset, user opt-in) before acting.
|
|
agentBindHint = "read `lark-cli config bind --help`, then ask the user to confirm intent and identity preset (bot-only or user-default); only after both are confirmed, run `lark-cli config bind`"
|
|
)
|
|
|
|
// NotConfiguredError returns the canonical "not configured" error, with a
|
|
// hint that depends on the active workspace:
|
|
//
|
|
// - WorkspaceLocal → suggest `config init --new` (creates a new app).
|
|
// - WorkspaceOpenClaw / WorkspaceHermes → point at `config bind --help`
|
|
// rather than a ready-to-run command, because binding is policy-laden:
|
|
// the user must pick an identity preset (bot-only vs user-default),
|
|
// and re-binding may overwrite an existing one. The help text walks
|
|
// the AI through the confirmation flow.
|
|
//
|
|
// All "config not loaded yet" call sites should use this helper rather than
|
|
// hand-rolling a hint, so AI agents always get a workspace-correct next step.
|
|
func NotConfiguredError() error {
|
|
ws := CurrentWorkspace()
|
|
if ws.IsLocal() {
|
|
hint := recovery.Join("", recovery.Command(recovery.TargetConfigInit, localInitHint)).
|
|
WithFallback("configure this distribution before retrying")
|
|
return recovery.Annotate(
|
|
errs.NewConfigError(errs.SubtypeNotConfigured, "not configured").
|
|
WithHint("%s", hint.String()),
|
|
hint,
|
|
)
|
|
}
|
|
// Agent workspace: the workspace name appears only in the message, never
|
|
// in the wire subtype, which stays not_configured.
|
|
hint := recovery.Join("", recovery.Command(recovery.TargetConfigBind, agentBindHint)).
|
|
WithFallback("bind this agent workspace through the distribution's supported setup flow")
|
|
return recovery.Annotate(
|
|
errs.NewConfigError(errs.SubtypeNotConfigured,
|
|
"%s context detected but lark-cli is not bound to it", ws.Display()).
|
|
WithHint("%s", hint.String()),
|
|
hint,
|
|
)
|
|
}
|
|
|
|
// reconfigureHint returns the workspace-aware "fix it from scratch" hint
|
|
// used by error paths that aren't full ConfigErrors (e.g. plain fmt.Errorf
|
|
// strings from keychain / secret validation). Local → `config init`;
|
|
// Agent → `config bind --help` so the AI reads the binding workflow and
|
|
// confirms identity preset with the user before running the actual command.
|
|
func reconfigureHint() string {
|
|
if CurrentWorkspace().IsLocal() {
|
|
return "please run `lark-cli config init` to reconfigure"
|
|
}
|
|
return agentBindHint
|
|
}
|
|
|
|
// RequireAppConfig resolves the profile selection to an app entry, or returns
|
|
// a typed error naming the input that must change. Use it wherever
|
|
// CurrentAppConfig's nil is a user-visible failure: the bare nil folds three
|
|
// distinct causes — a bad explicit selector, a dangling persisted currentApp,
|
|
// and a genuinely empty config — that need three different recoveries.
|
|
func (m *MultiAppConfig) RequireAppConfig(profile string, source ProfileSource) (*AppConfig, error) {
|
|
if app := m.CurrentAppConfig(profile); app != nil {
|
|
return app, nil
|
|
}
|
|
return nil, m.ProfileNotFoundError(profile, source)
|
|
}
|
|
|
|
// ProfileNotFoundError is the typed error behind RequireAppConfig, exported
|
|
// separately for call sites that keep their own nil tolerance (e.g. a
|
|
// --global branch that proceeds without a profile).
|
|
//
|
|
// The selector split matters most for the environment case: the variable may
|
|
// have been exported long before the failing command, so the error must name
|
|
// LARKSUITE_CLI_PROFILE explicitly — nothing in what the user just typed
|
|
// points at it. config init is never suggested here; the remaining profiles
|
|
// are intact and re-initializing would be destructive misdirection.
|
|
func (m *MultiAppConfig) ProfileNotFoundError(profile string, source ProfileSource) error {
|
|
if profile == "" && m.CurrentApp == "" {
|
|
// Nothing was selected and nothing is selectable: genuinely unconfigured.
|
|
return NotConfiguredError()
|
|
}
|
|
available := strings.Join(m.ProfileNames(), ", ")
|
|
if profile == "" {
|
|
// No explicit selector involved: the persisted default itself is a
|
|
// dangling reference, so recovery is re-pointing the persisted state.
|
|
hint := recovery.Join("",
|
|
recovery.Command(recovery.TargetProfileList,
|
|
fmt.Sprintf("config.json currentApp %q matches no profile; run `lark-cli profile list`, then switch with `lark-cli profile use <name>` (available: %s)", m.CurrentApp, available))).
|
|
WithFallback("the persisted default profile no longer exists; select an available profile through this distribution")
|
|
return recovery.Annotate(
|
|
errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found", m.CurrentApp).
|
|
WithField("currentApp").
|
|
WithHint("%s", hint.String()),
|
|
hint,
|
|
)
|
|
}
|
|
selector := source.SelectorLabel()
|
|
if selector == "" {
|
|
selector = "--profile" // defensive: an explicit value implies a selector channel
|
|
}
|
|
action := fmt.Sprintf("pass one of: %s", available)
|
|
if source == ProfileFromEnvironment {
|
|
action = fmt.Sprintf("unset %s or set it to one of: %s", envvars.CliProfile, available)
|
|
}
|
|
hint := recovery.Join("",
|
|
recovery.Command(recovery.TargetProfileList,
|
|
fmt.Sprintf("%s selected profile %q, which does not exist; %s (run `lark-cli profile list` for details)", selector, profile, action))).
|
|
WithFallback(fmt.Sprintf("%s selected profile %q, which does not exist; %s", selector, profile, action))
|
|
return recovery.Annotate(
|
|
errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found", profile).
|
|
WithField(selector).
|
|
WithHint("%s", hint.String()),
|
|
hint,
|
|
)
|
|
}
|
|
|
|
// NoActiveProfileError mirrors NotConfiguredError for the related
|
|
// "config exists but the requested profile cannot be resolved" case. In agent
|
|
// workspaces a missing profile typically means the binding was wiped while
|
|
// the workspace marker remained — re-binding is the correct fix, not init.
|
|
func NoActiveProfileError() error {
|
|
ws := CurrentWorkspace()
|
|
if ws.IsLocal() {
|
|
hint := recovery.Join("", recovery.Command(recovery.TargetConfigInit, localInitHint)).
|
|
WithFallback("configure this distribution before retrying")
|
|
return recovery.Annotate(
|
|
errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").
|
|
WithHint("%s", hint.String()),
|
|
hint,
|
|
)
|
|
}
|
|
hint := recovery.Join("", recovery.Command(recovery.TargetConfigBind, agentBindHint)).
|
|
WithFallback("bind this agent workspace through the distribution's supported setup flow")
|
|
return recovery.Annotate(
|
|
errs.NewConfigError(errs.SubtypeNotConfigured,
|
|
"no active profile in %s workspace", ws.Display()).
|
|
WithHint("%s", hint.String()),
|
|
hint,
|
|
)
|
|
}
|