Files
larksuite__cli/internal/core/notconfigured.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,
)
}