724 lines
19 KiB
Go
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
|
|
}
|