Files
2026-07-13 13:00:08 +08:00

724 lines
19 KiB
Go

package config
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
fileencoding "reasonix/internal/fileutil/encoding"
)
// legacyConfig is the subset of the v0.x (~/.reasonix/config.json) schema this
// import carries forward. Fields absent here are dropped on purpose: desktop tab
// state is frontend-owned, and skills already live in the shared ~/.reasonix/skills
// root that v1+ also scans, so they need no migration.
type legacyConfig struct {
APIKey string `json:"apiKey"`
BaseURL string `json:"baseUrl"`
Model string `json:"model"`
Lang string `json:"lang"`
MCP []string `json:"mcp"` // pre-mcpServers `--mcp`-format strings
MCPServers map[string]legacyMCPServer `json:"mcpServers"`
MCPEnv map[string]map[string]string `json:"mcpEnv"`
MCPDisabled []string `json:"mcpDisabled"`
QQ legacyQQConfig `json:"qq"`
}
type legacyMCPServer struct {
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
Transport string `json:"transport"`
Type string `json:"type"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Disabled bool `json:"disabled"`
}
type legacyQQConfig struct {
AppID string `json:"appId"`
AppSecret string `json:"appSecret"`
Sandbox bool `json:"sandbox"`
Enabled bool `json:"enabled"`
OwnerOpenID string `json:"ownerOpenId"`
Allowlist []string `json:"allowlist"`
}
// MigrationResult summarizes a one-time legacy import for the boot-time notice.
type MigrationResult struct {
From string
To string
KeyToEnv bool
Plugins int
Warnings []string
}
// MCPGlobalMigrationResult summarizes the v1.9.1 MCP backfill that lifts MCP
// servers from legacy and project-local sources into the user-global config.
type MCPGlobalMigrationResult struct {
To string
Added int
Sources int
}
func (r *MigrationResult) Notice() string {
var b strings.Builder
fmt.Fprintf(&b, "migrated your previous configuration: %s → %s", r.From, r.To)
if r.Plugins > 0 {
fmt.Fprintf(&b, " (%d MCP server(s))", r.Plugins)
}
if r.KeyToEnv {
b.WriteString("; API key saved to reasonix's credentials store")
}
b.WriteString(". The old files were left untouched.")
for _, w := range r.Warnings {
b.WriteString("\n note: " + w)
}
return b.String()
}
// MigrateLegacyIfNeeded performs a one-time, non-destructive import of older
// installs into the current user config when the latter does not exist yet. It
// checks v1-era TOML first, then v0.5/v0.x ~/.reasonix/config.json, and never
// modifies or deletes the legacy files. Returns nil when there is nothing to
// migrate, or when the current user config already exists.
func MigrateLegacyIfNeeded() (*MigrationResult, error) {
return MigrateLegacyIfNeededForRoot(".")
}
func MigrateLegacyIfNeededForRoot(root string) (*MigrationResult, error) {
if IsolatedHomeDir() != "" {
return nil, nil
}
credErr := migrateLegacyCredentialsIfNeededForRoot(root)
dest := userConfigPath()
if dest == "" {
return nil, credErr
}
if _, err := os.Stat(dest); err == nil {
return nil, credErr
}
home, err := os.UserHomeDir()
if err != nil {
return nil, credErr
}
if res, err := migrateLegacyTOMLIfNeeded(dest, home); res != nil || err != nil {
if err == nil {
err = credErr
}
return res, err
}
src := filepath.Join(home, ".reasonix", "config.json")
data, err := fileencoding.ReadFileUTF8(src)
if err != nil {
return nil, nil
}
var legacy legacyConfig
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) // tolerate a UTF-8 BOM (some editors add one)
if err := json.Unmarshal(data, &legacy); err != nil {
return nil, fmt.Errorf("parse legacy config %s: %w", src, err)
}
cfg := Default()
res := &MigrationResult{From: src, To: dest}
if legacy.Lang != "" {
cfg.Language = legacy.Lang
_ = cfg.SetDesktopLanguage(legacy.Lang)
}
if legacy.Model != "" {
if entry, ok := cfg.ResolveModel(legacy.Model); ok {
cfg.DefaultModel = entry.Name + "/" + entry.Model
} else {
cfg.DefaultModel = legacy.Model
}
}
migrateLegacyBaseURL(cfg, legacy.BaseURL)
cfg.Plugins = legacyPlugins(legacy)
res.Plugins = len(cfg.Plugins)
var envLines []string
if key := strings.TrimSpace(legacy.APIKey); key != "" {
envLines = append(envLines, "DEEPSEEK_API_KEY="+key)
res.KeyToEnv = true
if base := strings.TrimSpace(legacy.BaseURL); base != "" && !strings.Contains(base, "deepseek.com") {
res.Warnings = append(res.Warnings, "your previous base_url was "+base+
" — it was applied to the built-in DeepSeek providers; verify models if this endpoint is not DeepSeek-compatible")
}
}
if qqSecret := strings.TrimSpace(legacy.QQ.AppSecret); qqSecret != "" {
envLines = append(envLines, "QQ_BOT_APP_SECRET="+qqSecret)
res.Warnings = append(res.Warnings, "your previous QQ Bot App Secret was saved to reasonix's credentials store")
}
migrateLegacyQQConfig(cfg, legacy.QQ)
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return nil, fmt.Errorf("create config dir: %w", err)
}
if err := cfg.WriteFile(dest); err != nil {
return nil, fmt.Errorf("write %s: %w", dest, err)
}
if len(envLines) > 0 {
if err := writeCredentialsEnv(home, envLines); err != nil {
return res, fmt.Errorf("write credentials: %w", err)
}
}
return res, credErr
}
func MigrateLegacyCredentialsForRoot(root string) error {
if IsolatedHomeDir() != "" {
return nil
}
return migrateLegacyCredentialsIfNeededForRoot(root)
}
// MigrateMCPToUserConfigOnUpgrade runs a one-time best-effort backfill for the
// v1.9.1 desktop/CLI upgrade: MCP servers found in legacy TOML, known project
// roots, and legacy v0.x JSON are copied into the user-global config so the MCP
// settings page is stable across Global/project tabs. Existing global entries win
// on name collisions, and source files are left untouched.
func MigrateMCPToUserConfigOnUpgrade(projectRoots []string) (*MCPGlobalMigrationResult, error) {
marker := mcpGlobalMigrationMarkerPath()
if marker == "" {
return nil, nil
}
if _, err := os.Stat(marker); err == nil {
return nil, nil
} else if err != nil && !os.IsNotExist(err) {
return nil, err
}
res, err := migrateMCPToUserConfig(projectRoots)
if err != nil {
return res, err
}
if res == nil {
return nil, nil
}
if err := os.MkdirAll(filepath.Dir(marker), 0o755); err != nil {
return res, err
}
if err := os.WriteFile(marker, []byte("v1\n"), 0o644); err != nil {
return res, err
}
return res, nil
}
func migrateMCPToUserConfig(projectRoots []string) (*MCPGlobalMigrationResult, error) {
dest := userConfigPath()
if dest == "" {
return nil, nil
}
userCfg, err := loadForEditStrict(dest, true, true)
if err != nil {
return nil, err
}
have := make(map[string]bool, len(userCfg.Plugins))
for _, p := range userCfg.Plugins {
if name := strings.TrimSpace(p.Name); name != "" {
have[name] = true
}
}
result := &MCPGlobalMigrationResult{To: dest}
addEntries := func(entries []PluginEntry) {
if len(entries) == 0 {
return
}
result.Sources++
for _, entry := range entries {
entry, _ = NormalizePluginCommandLine(entry)
name := strings.TrimSpace(entry.Name)
if name == "" || have[name] || validatePlugin(entry) != nil {
continue
}
userCfg.Plugins = append(userCfg.Plugins, entry)
have[name] = true
result.Added++
}
}
home, _ := os.UserHomeDir()
for _, path := range mcpMigrationLegacyTOMLPaths(dest, home) {
addEntries(loadPluginEntriesFromTOML(path))
}
for _, root := range normalizedMCPMigrationRoots(projectRoots) {
addEntries(loadPluginEntriesFromTOML(filepath.Join(root, "reasonix.toml")))
if entries, err := loadMCPJSON(filepath.Join(root, mcpJSONFile)); err == nil {
addEntries(entries)
}
}
addEntries(loadLegacyConfigPlugins(legacyConfigPath()))
if result.Sources == 0 {
return nil, nil
}
if result.Added > 0 {
if err := userCfg.SaveTo(dest); err != nil {
return result, err
}
}
return result, nil
}
func mcpGlobalMigrationMarkerPath() string {
dir := userSupportDir()
if dir == "" {
return ""
}
return filepath.Join(dir, "mcp-global-migration-v1")
}
func mcpGlobalMigrationComplete() bool {
marker := mcpGlobalMigrationMarkerPath()
if marker == "" {
return false
}
_, err := os.Stat(marker)
return err == nil
}
func mcpMigrationLegacyTOMLPaths(dest, home string) []string {
var paths []string
for _, path := range legacyTOMLPaths(dest, home) {
if path == "" || samePath(path, dest) {
continue
}
paths = append(paths, path)
}
return paths
}
func loadPluginEntriesFromTOML(path string) []PluginEntry {
path = strings.TrimSpace(path)
if path == "" {
return nil
}
if _, err := os.Stat(path); err != nil {
return nil
}
var cfg Config
if _, err := decodeTOMLFile(path, &cfg); err != nil {
return nil
}
out := make([]PluginEntry, 0, len(cfg.Plugins))
for _, p := range cfg.Plugins {
p, _ = NormalizePluginCommandLine(p)
out = append(out, p)
}
return out
}
func loadLegacyConfigPlugins(path string) []PluginEntry {
if strings.TrimSpace(path) == "" {
return nil
}
data, err := fileencoding.ReadFileUTF8(path)
if err != nil {
return nil
}
var legacy legacyConfig
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
if err := json.Unmarshal(data, &legacy); err != nil {
return nil
}
return legacyPlugins(legacy)
}
func normalizedMCPMigrationRoots(roots []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(roots))
for _, root := range roots {
root = strings.TrimSpace(root)
if root == "" {
continue
}
if abs, err := filepath.Abs(root); err == nil {
root = abs
}
root = filepath.Clean(root)
if seen[root] {
continue
}
seen[root] = true
out = append(out, root)
}
return out
}
func migrateLegacyCredentialsIfNeededForRoot(root string) error {
missing := map[string]string{}
skip := func(key string) bool {
return credentialCurrentStoreHasKey(key) || credentialCurrentStoreClearedKey(key)
}
for _, key := range credentialEnvNamesForRoot(root) {
if skip(key) {
continue
}
if value, ok := legacyKeyringCredentialValueLookup(key); ok {
missing[key] = value
}
}
for _, src := range legacyCredentialsPaths() {
if src == "" {
continue
}
data, err := fileencoding.ReadFileUTF8(src)
if err != nil {
continue
}
assignments := parseCredentialLines(strings.Split(string(data), "\n"))
for key, value := range assignments {
if _, exists := missing[key]; !exists && !skip(key) {
missing[key] = value
}
}
}
if len(missing) == 0 {
return nil
}
_, err := StoreCredentialLines(credentialLines(missing))
return err
}
func credentialLines(assignments map[string]string) []string {
keys := make([]string, 0, len(assignments))
for key := range assignments {
keys = append(keys, key)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, key := range keys {
lines = append(lines, key+"="+assignments[key])
}
return lines
}
func migrateLegacyQQConfig(cfg *Config, legacy legacyQQConfig) {
if cfg == nil || !legacyQQConfigured(legacy) {
return
}
cfg.Bot.Enabled = cfg.Bot.Enabled || legacy.Enabled
cfg.Bot.QQ.Enabled = legacy.Enabled
cfg.Bot.QQ.AppID = strings.TrimSpace(legacy.AppID)
cfg.Bot.QQ.AppSecretEnv = "QQ_BOT_APP_SECRET"
cfg.Bot.QQ.Sandbox = legacy.Sandbox
cfg.Bot.Allowlist.Enabled = true
cfg.Bot.Allowlist.QQUsers = mergeUniqueTrimmed(cfg.Bot.Allowlist.QQUsers, legacy.OwnerOpenID)
cfg.Bot.Allowlist.QQUsers = mergeUniqueTrimmed(cfg.Bot.Allowlist.QQUsers, legacy.Allowlist...)
}
func legacyQQConfigured(legacy legacyQQConfig) bool {
return legacy.Enabled ||
strings.TrimSpace(legacy.AppID) != "" ||
strings.TrimSpace(legacy.AppSecret) != "" ||
strings.TrimSpace(legacy.OwnerOpenID) != "" ||
len(legacy.Allowlist) > 0 ||
legacy.Sandbox
}
func mergeUniqueTrimmed(base []string, values ...string) []string {
seen := make(map[string]bool, len(base)+len(values))
out := make([]string, 0, len(base)+len(values))
for _, value := range append(base, values...) {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
func migrateLegacyTOMLIfNeeded(dest, home string) (*MigrationResult, error) {
for _, src := range legacyTOMLPaths(dest, home) {
if src == "" || filepath.Clean(src) == filepath.Clean(dest) {
continue
}
if _, err := os.Stat(src); err != nil {
continue
}
cfg := Default()
if err := mergeFile(cfg, src); err != nil {
return nil, fmt.Errorf("parse legacy config %s: %w", src, err)
}
cfg.ConfigVersion = Default().ConfigVersion
if strings.TrimSpace(cfg.Desktop.CloseBehavior) == "" && strings.TrimSpace(cfg.UI.CloseBehavior) != "" {
cfg.Desktop.CloseBehavior = cfg.DesktopCloseBehavior()
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return nil, fmt.Errorf("create config dir: %w", err)
}
if err := cfg.WriteFile(dest); err != nil {
return nil, fmt.Errorf("write %s: %w", dest, err)
}
res := &MigrationResult{From: src, To: dest, Plugins: len(cfg.Plugins)}
legacyDir := filepath.Dir(src)
newDir := filepath.Dir(dest)
if !samePath(legacyDir, newDir) {
if warnings := migrateSupportData(legacyDir, newDir); len(warnings) > 0 {
res.Warnings = append(res.Warnings, warnings...)
}
}
return res, nil
}
return nil, nil
}
func legacyTOMLPaths(dest, home string) []string {
seen := map[string]bool{}
var paths []string
add := func(path string) {
if path == "" {
return
}
path = filepath.Clean(path)
if seen[path] {
return
}
seen[path] = true
paths = append(paths, path)
}
if legacy := legacyUserConfigPath(); legacy != "" {
add(legacy)
}
for _, legacy := range legacyXDGConfigPaths() {
add(legacy)
add(filepath.Join(filepath.Dir(legacy), "reasonix.toml"))
}
add(filepath.Join(filepath.Dir(dest), "reasonix.toml"))
if home != "" {
add(filepath.Join(home, ".reasonix", "reasonix.toml"))
}
return paths
}
func migrateLegacyBaseURL(cfg *Config, baseURL string) {
baseURL = strings.TrimSpace(baseURL)
if cfg == nil || baseURL == "" {
return
}
for i := range cfg.Providers {
if cfg.Providers[i].APIKeyEnv == "DEEPSEEK_API_KEY" {
cfg.Providers[i].BaseURL = baseURL
}
}
}
func legacyPlugins(legacy legacyConfig) []PluginEntry {
disabled := make(map[string]bool, len(legacy.MCPDisabled))
for _, n := range legacy.MCPDisabled {
disabled[n] = true
}
var out []PluginEntry
index := make(map[string]int)
add := func(pe PluginEntry, off bool) {
if off {
v := false
pe.AutoStart = &v
}
pe, _ = NormalizePluginCommandLine(pe)
if j, dup := index[pe.Name]; dup {
out[j] = pe // mcpServers overrides the `mcp` list on a name collision, matching v0.x
return
}
index[pe.Name] = len(out)
out = append(out, pe)
}
for i, raw := range legacy.MCP {
pe, ok := parseLegacyMCPSpec(raw)
if !ok {
continue
}
if pe.Name == "" {
pe.Name = anonymousMCPName(i)
} else if pe.Command != "" {
pe.Env = mergeEnv(nil, legacy.MCPEnv[pe.Name])
}
add(pe, disabled[pe.Name])
}
names := make([]string, 0, len(legacy.MCPServers))
for n := range legacy.MCPServers {
names = append(names, n)
}
sort.Strings(names)
for _, name := range names {
s := legacy.MCPServers[name]
pe := PluginEntry{
Name: name,
Type: normalizeTransport(firstNonEmpty(s.Type, s.Transport)),
Command: s.Command,
Args: s.Args,
Env: mergeEnv(s.Env, legacy.MCPEnv[name]),
URL: s.URL,
Headers: s.Headers,
}
add(pe, s.Disabled || disabled[name])
}
return out
}
// normalizeTransport maps the v0.x transport names to v1+ plugin types. stdio is
// the default, so it returns "" (RenderTOML then omits the field).
func normalizeTransport(t string) string {
switch strings.ToLower(strings.TrimSpace(t)) {
case "http", "streamable-http":
return "http"
case "sse":
return "sse"
default:
return ""
}
}
func firstNonEmpty(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
// mergeEnv overlays the per-server env map onto the spec's own env (overlay wins,
// matching v0.x mcpEnv precedence). Returns nil when both are empty.
func mergeEnv(base, overlay map[string]string) map[string]string {
if len(base) == 0 && len(overlay) == 0 {
return nil
}
out := make(map[string]string, len(base)+len(overlay))
for k, v := range base {
out[k] = v
}
for k, v := range overlay {
out[k] = v
}
return out
}
// writeCredentialsEnv merges lines into Reasonix's global .env
// and pins them into the current process env so the just-built session resolves
// the key without a restart. Falls back to ~/.env only when Reasonix home can't
// be resolved — never a project .env, so a migration keeps secrets out of the
// user's project tree.
func writeCredentialsEnv(home string, lines []string) error {
if _, err := StoreCredentialLines(lines); err != nil {
if UserCredentialsPath() == "" && home != "" {
return os.WriteFile(filepath.Join(home, ".env"), []byte(strings.Join(lines, "\n")+"\n"), 0o600)
}
return err
}
return nil
}
func migrateSupportData(legacyDir, newDir string) []string {
var warnings []string
items := []string{"sessions", "projects", "skills", "archive", "hooks.json"}
for _, item := range items {
src := filepath.Join(legacyDir, item)
fi, err := os.Stat(src)
if err != nil {
if os.IsNotExist(err) {
continue
}
warnings = append(warnings, fmt.Sprintf("failed to read legacy item %s: %v", item, err))
continue
}
dst := filepath.Join(newDir, item)
if fi.IsDir() {
if err := copyDir(src, dst); err != nil {
warnings = append(warnings, fmt.Sprintf("failed to migrate directory %s: %v", item, err))
} else {
warnings = append(warnings, fmt.Sprintf("successfully migrated directory %s", item))
}
} else {
if err := copyFile(src, dst); err != nil {
warnings = append(warnings, fmt.Sprintf("failed to migrate file %s: %v", item, err))
} else {
warnings = append(warnings, fmt.Sprintf("successfully migrated file %s", item))
}
}
}
return warnings
}
func copyFile(src, dst string) error {
info, err := os.Stat(src)
if err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
parentMode := os.FileMode(0o755)
if info.Mode().Perm()&0o077 == 0 {
parentMode = 0o700
}
if err := os.MkdirAll(filepath.Dir(dst), parentMode); err != nil {
return err
}
perm := info.Mode().Perm()
if perm == 0 {
perm = 0o600
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
if err != nil {
return err
}
defer out.Close()
if _, err = io.Copy(out, in); err != nil {
return err
}
if err := out.Sync(); err != nil {
return err
}
return os.Chmod(dst, perm)
}
func copyDir(src, dst string) error {
info, err := os.Stat(src)
if err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
perm := info.Mode().Perm()
if perm == 0 {
perm = 0o700
}
if err := os.MkdirAll(dst, perm); err != nil {
return err
}
if err := os.Chmod(dst, perm); err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name())
if entry.IsDir() {
if err := copyDir(srcPath, dstPath); err != nil {
return err
}
} else {
if err := copyFile(srcPath, dstPath); err != nil {
return err
}
}
}
return nil
}