返回 DeepSeek-Reasonix
migrate.go
根目录 / internal / config / migrate.go
1 package config
2
3 import (
4 "bytes"
5 "encoding/base64"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "maps"
11 "os"
12 "path/filepath"
13 "sort"
14 "strings"
15
16 fileencoding "reasonix/internal/fileutil/encoding"
17 )
18
19 // legacyConfig is the subset of the v0.x (~/.reasonix/config.json) schema this
20 // import carries forward. Fields absent here are dropped on purpose: desktop tab
21 // state is frontend-owned, and skills already live in the shared ~/.reasonix/skills
22 // root that v1+ also scans, so they need no migration.
23 type legacyConfig struct {
24 APIKey string `json:"apiKey"`
25 BaseURL string `json:"baseUrl"`
26 Model string `json:"model"`
27 Lang string `json:"lang"`
28 MCP []string `json:"mcp"` // pre-mcpServers `--mcp`-format strings
29 MCPServers map[string]legacyMCPServer `json:"mcpServers"`
30 MCPEnv map[string]map[string]string `json:"mcpEnv"`
31 MCPDisabled []string `json:"mcpDisabled"`
32 QQ legacyQQConfig `json:"qq"`
33 }
34
35 type legacyMCPServer struct {
36 Command string `json:"command"`
37 Args []string `json:"args"`
38 Env map[string]string `json:"env"`
39 Transport string `json:"transport"`
40 Type string `json:"type"`
41 URL string `json:"url"`
42 Headers map[string]string `json:"headers"`
43 Disabled bool `json:"disabled"`
44 }
45
46 type legacyQQConfig struct {
47 AppID string `json:"appId"`
48 AppSecret string `json:"appSecret"`
49 Sandbox bool `json:"sandbox"`
50 Enabled bool `json:"enabled"`
51 OwnerOpenID string `json:"ownerOpenId"`
52 Allowlist []string `json:"allowlist"`
53 }
54
55 // MigrationResult summarizes a one-time legacy import for the boot-time notice.
56 type MigrationResult struct {
57 From string
58 To string
59 KeyToEnv bool
60 Plugins int
61 Warnings []string
62 }
63
64 // MCPGlobalMigrationResult summarizes the v1.9.1 MCP backfill that lifts MCP
65 // servers from legacy and project-local sources into the user-global config.
66 type MCPGlobalMigrationResult struct {
67 To string
68 Added int
69 Sources int
70 }
71
72 func (r *MigrationResult) Notice() string {
73 var b strings.Builder
74 fmt.Fprintf(&b, "migrated your previous configuration: %s → %s", r.From, r.To)
75 if r.Plugins > 0 {
76 fmt.Fprintf(&b, " (%d MCP server(s))", r.Plugins)
77 }
78 if r.KeyToEnv {
79 b.WriteString("; API key saved to reasonix's credentials store")
80 }
81 b.WriteString(". The old files were left untouched.")
82 for _, w := range r.Warnings {
83 b.WriteString("\n note: " + w)
84 }
85 return b.String()
86 }
87
88 // MigrateLegacyIfNeeded performs a one-time, non-destructive import of older
89 // installs into the current user config when the latter does not exist yet. It
90 // checks v1-era TOML first, then v0.5/v0.x ~/.reasonix/config.json, and never
91 // modifies or deletes the legacy files. Returns nil when there is nothing to
92 // migrate, or when the current user config already exists.
93 func MigrateLegacyIfNeeded() (*MigrationResult, error) {
94 return MigrateLegacyIfNeededForRoot(".")
95 }
96
97 func MigrateLegacyIfNeededForRoot(root string) (*MigrationResult, error) {
98 if IsolatedHomeDir() != "" {
99 return nil, nil
100 }
101 credErr := migrateLegacyCredentialsIfNeededForRoot(root)
102 dest := userConfigPath()
103 if dest == "" {
104 return nil, credErr
105 }
106 unlock, err := LockConfigFileEdits(dest)
107 if err != nil {
108 return nil, errors.Join(credErr, err)
109 }
110 defer unlock()
111 if _, err := os.Stat(dest); err == nil {
112 return nil, credErr
113 }
114 home, err := os.UserHomeDir()
115 if err != nil {
116 return nil, credErr
117 }
118 if res, err := migrateLegacyTOMLIfNeeded(dest, home); res != nil || err != nil {
119 if err == nil {
120 err = credErr
121 }
122 return res, err
123 }
124 src := filepath.Join(home, ".reasonix", "config.json")
125 data, err := fileencoding.ReadFileUTF8(src)
126 if err != nil {
127 return nil, nil
128 }
129 var legacy legacyConfig
130 data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) // tolerate a UTF-8 BOM (some editors add one)
131 if err := json.Unmarshal(data, &legacy); err != nil {
132 return nil, fmt.Errorf("parse legacy config %s: %w", src, err)
133 }
134
135 cfg := Default()
136 res := &MigrationResult{From: src, To: dest}
137 if legacy.Lang != "" {
138 cfg.Language = legacy.Lang
139 _ = cfg.SetDesktopLanguage(legacy.Lang)
140 }
141 if legacy.Model != "" {
142 if entry, ok := cfg.ResolveModel(legacy.Model); ok {
143 cfg.DefaultModel = entry.Name + "/" + entry.Model
144 } else {
145 cfg.DefaultModel = legacy.Model
146 }
147 }
148 migrateLegacyBaseURL(cfg, legacy.BaseURL)
149 cfg.Plugins = legacyPlugins(legacy)
150 res.Plugins = len(cfg.Plugins)
151
152 var envLines []string
153 if key := strings.TrimSpace(legacy.APIKey); key != "" {
154 envLines = append(envLines, "DEEPSEEK_API_KEY="+key)
155 res.KeyToEnv = true
156 if base := strings.TrimSpace(legacy.BaseURL); base != "" && !strings.Contains(base, "deepseek.com") {
157 res.Warnings = append(res.Warnings, "your previous base_url was "+base+
158 " — it was applied to the built-in DeepSeek providers; verify models if this endpoint is not DeepSeek-compatible")
159 }
160 }
161 if qqSecret := strings.TrimSpace(legacy.QQ.AppSecret); qqSecret != "" {
162 envLines = append(envLines, "QQ_BOT_APP_SECRET="+qqSecret)
163 res.Warnings = append(res.Warnings, "your previous QQ Bot App Secret was saved to reasonix's credentials store")
164 }
165 migrateLegacyQQConfig(cfg, legacy.QQ)
166
167 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
168 return nil, fmt.Errorf("create config dir: %w", err)
169 }
170 if err := cfg.WriteFile(dest); err != nil {
171 return nil, fmt.Errorf("write %s: %w", dest, err)
172 }
173 if len(envLines) > 0 {
174 if err := writeCredentialsEnv(home, envLines); err != nil {
175 return res, fmt.Errorf("write credentials: %w", err)
176 }
177 }
178 return res, credErr
179 }
180
181 func MigrateLegacyCredentialsForRoot(root string) error {
182 if IsolatedHomeDir() != "" {
183 return nil
184 }
185 return migrateLegacyCredentialsIfNeededForRoot(root)
186 }
187
188 // MigrateMCPToUserConfigOnUpgrade runs a one-time best-effort backfill for the
189 // v1.9.1 desktop/CLI upgrade: MCP servers found in legacy TOML, known project
190 // roots, and legacy v0.x JSON are copied into the user-global config so the MCP
191 // settings page is stable across Global/project tabs. Existing global entries win
192 // on name collisions, and source files are left untouched.
193 func MigrateMCPToUserConfigOnUpgrade(projectRoots []string) (*MCPGlobalMigrationResult, error) {
194 dest := userConfigPath()
195 if dest == "" {
196 return nil, nil
197 }
198 unlock, err := LockConfigFileEdits(dest)
199 if err != nil {
200 return nil, err
201 }
202 defer unlock()
203
204 marker := mcpGlobalMigrationMarkerPath()
205 if marker == "" {
206 return nil, nil
207 }
208 if _, err := os.Stat(marker); err == nil {
209 return nil, nil
210 } else if err != nil && !os.IsNotExist(err) {
211 return nil, err
212 }
213
214 res, err := migrateMCPToUserConfig(projectRoots)
215 if err != nil {
216 return res, err
217 }
218 if res == nil {
219 return nil, nil
220 }
221 if err := os.MkdirAll(filepath.Dir(marker), 0o755); err != nil {
222 return res, err
223 }
224 if err := os.WriteFile(marker, []byte("v1\n"), 0o644); err != nil {
225 return res, err
226 }
227 return res, nil
228 }
229
230 func migrateMCPToUserConfig(projectRoots []string) (*MCPGlobalMigrationResult, error) {
231 dest := userConfigPath()
232 if dest == "" {
233 return nil, nil
234 }
235 userCfg, err := loadForEditStrict(dest, true, true)
236 if err != nil {
237 return nil, err
238 }
239 have := make(map[string]bool, len(userCfg.Plugins))
240 for _, p := range userCfg.Plugins {
241 if name := strings.TrimSpace(p.Name); name != "" {
242 have[name] = true
243 }
244 }
245
246 result := &MCPGlobalMigrationResult{To: dest}
247 addEntries := func(entries []PluginEntry) {
248 if len(entries) == 0 {
249 return
250 }
251 result.Sources++
252 for _, entry := range entries {
253 entry, _ = NormalizePluginCommandLine(entry)
254 name := strings.TrimSpace(entry.Name)
255 if name == "" || have[name] || validatePlugin(entry) != nil {
256 continue
257 }
258 entry.Source = MCPSourceUserConfig
259 userCfg.Plugins = append(userCfg.Plugins, entry)
260 have[name] = true
261 result.Added++
262 }
263 }
264
265 home, _ := os.UserHomeDir()
266 for _, path := range mcpMigrationLegacyTOMLPaths(dest, home) {
267 addEntries(loadPluginEntriesFromTOML(path))
268 }
269 for _, root := range normalizedMCPMigrationRoots(projectRoots) {
270 addEntries(loadPluginEntriesFromTOML(filepath.Join(root, "reasonix.toml")))
271 if entries, err := loadMCPJSON(filepath.Join(root, mcpJSONFile)); err == nil {
272 addEntries(entries)
273 }
274 }
275 addEntries(loadLegacyConfigPlugins(legacyConfigPath()))
276
277 if result.Sources == 0 {
278 return nil, nil
279 }
280 if result.Added > 0 {
281 if err := userCfg.SaveTo(dest); err != nil {
282 return result, err
283 }
284 }
285 return result, nil
286 }
287
288 func mcpGlobalMigrationMarkerPath() string {
289 dir := userSupportDir()
290 if dir == "" {
291 return ""
292 }
293 return filepath.Join(dir, "mcp-global-migration-v1")
294 }
295
296 func mcpGlobalMigrationComplete() bool {
297 marker := mcpGlobalMigrationMarkerPath()
298 if marker == "" {
299 return false
300 }
301 _, err := os.Stat(marker)
302 return err == nil
303 }
304
305 func mcpMigrationLegacyTOMLPaths(dest, home string) []string {
306 var paths []string
307 for _, path := range legacyTOMLPaths(dest, home) {
308 if path == "" || samePath(path, dest) {
309 continue
310 }
311 paths = append(paths, path)
312 }
313 return paths
314 }
315
316 func loadPluginEntriesFromTOML(path string) []PluginEntry {
317 path = strings.TrimSpace(path)
318 if path == "" {
319 return nil
320 }
321 if _, err := os.Stat(path); err != nil {
322 return nil
323 }
324 var cfg Config
325 if _, err := decodeTOMLFile(path, &cfg); err != nil {
326 return nil
327 }
328 out := make([]PluginEntry, 0, len(cfg.Plugins))
329 for _, p := range cfg.Plugins {
330 p, _ = NormalizePluginCommandLine(p)
331 out = append(out, p)
332 }
333 return out
334 }
335
336 func loadLegacyConfigPlugins(path string) []PluginEntry {
337 if strings.TrimSpace(path) == "" {
338 return nil
339 }
340 data, err := fileencoding.ReadFileUTF8(path)
341 if err != nil {
342 return nil
343 }
344 var legacy legacyConfig
345 data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
346 if err := json.Unmarshal(data, &legacy); err != nil {
347 return nil
348 }
349 return legacyPlugins(legacy)
350 }
351
352 func normalizedMCPMigrationRoots(roots []string) []string {
353 seen := map[string]bool{}
354 out := make([]string, 0, len(roots))
355 for _, root := range roots {
356 root = strings.TrimSpace(root)
357 if root == "" {
358 continue
359 }
360 if abs, err := filepath.Abs(root); err == nil {
361 root = abs
362 }
363 root = filepath.Clean(root)
364 if seen[root] {
365 continue
366 }
367 seen[root] = true
368 out = append(out, root)
369 }
370 return out
371 }
372
373 func migrateLegacyCredentialsIfNeededForRoot(root string) error {
374 missing := map[string]string{}
375 // File import ignores keyring markers: a marker only means "do not re-probe
376 // keyring for this env name", never "skip legacy credential files".
377 skipStore := func(key string) bool {
378 return credentialCurrentStoreHasKey(key) || credentialCurrentStoreClearedKey(key)
379 }
380 // Prefer legacy credential files first so a healthy file import does not
381 // depend on Secret Service / D-Bus (#7507).
382 for _, src := range legacyCredentialsPaths() {
383 if src == "" {
384 continue
385 }
386 data, err := fileencoding.ReadFileUTF8(src)
387 if err != nil {
388 continue
389 }
390 assignments := parseCredentialLines(strings.Split(string(data), "\n"))
391 for key, value := range assignments {
392 if _, exists := missing[key]; !exists && !skipStore(key) {
393 missing[key] = value
394 }
395 }
396 }
397 keys := credentialEnvNamesForRoot(root)
398 needKeyring := make([]string, 0, len(keys))
399 for _, key := range keys {
400 if skipStore(key) {
401 continue
402 }
403 if _, exists := missing[key]; exists {
404 continue
405 }
406 // Marker only filters keyring probes.
407 if legacyKeyringMigrationDone(key) {
408 continue
409 }
410 needKeyring = append(needKeyring, key)
411 }
412 if len(needKeyring) > 0 {
413 outcomes := lookupLegacyKeyringBatch(needKeyring, legacyKeyringLookupTimeout)
414 for _, key := range needKeyring {
415 o := outcomes[key]
416 switch o.Status {
417 case legacyKeyringFound:
418 // Secret was stored by the probe path (helper or in-process).
419 // Do not trust Value from the parent-visible outcome map.
420 case legacyKeyringAbsent:
421 // Confirmed empty probe only — never on error/timeout.
422 _ = markLegacyKeyringMigrationDone(key)
423 case legacyKeyringError, legacyKeyringTimeout:
424 // Leave unmarked so the next launch retries.
425 default:
426 // Unknown status: treat as timeout (no marker).
427 }
428 }
429 }
430 if len(missing) == 0 {
431 return nil
432 }
433 _, err := StoreCredentialLines(credentialLines(missing))
434 return err
435 }
436
437 func legacyKeyringMigrationMarkerPath(key string) string {
438 home := ReasonixHomeDir()
439 key = strings.TrimSpace(key)
440 if strings.TrimSpace(home) == "" || key == "" {
441 return ""
442 }
443 // Env var names are identifiers, not secrets. RawURL base64 is collision-free
444 // and filesystem-safe without hashing secret material.
445 name := base64.RawURLEncoding.EncodeToString([]byte(key))
446 return filepath.Join(home, "state", "legacy-keyring-checked", name)
447 }
448
449 func legacyKeyringMigrationDone(key string) bool {
450 path := legacyKeyringMigrationMarkerPath(key)
451 if path == "" {
452 return false
453 }
454 _, err := os.Stat(path)
455 return err == nil
456 }
457
458 func markLegacyKeyringMigrationDone(key string) error {
459 path := legacyKeyringMigrationMarkerPath(key)
460 if path == "" {
461 return nil
462 }
463 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
464 return err
465 }
466 return os.WriteFile(path, []byte("v1\n"), 0o644)
467 }
468
469 func credentialLines(assignments map[string]string) []string {
470 keys := make([]string, 0, len(assignments))
471 for key := range assignments {
472 keys = append(keys, key)
473 }
474 sort.Strings(keys)
475 lines := make([]string, 0, len(keys))
476 for _, key := range keys {
477 lines = append(lines, key+"="+assignments[key])
478 }
479 return lines
480 }
481
482 func migrateLegacyQQConfig(cfg *Config, legacy legacyQQConfig) {
483 if cfg == nil || !legacyQQConfigured(legacy) {
484 return
485 }
486 cfg.Bot.Enabled = cfg.Bot.Enabled || legacy.Enabled
487 cfg.Bot.QQ.Enabled = legacy.Enabled
488 cfg.Bot.QQ.AppID = strings.TrimSpace(legacy.AppID)
489 cfg.Bot.QQ.AppSecretEnv = "QQ_BOT_APP_SECRET"
490 cfg.Bot.QQ.Sandbox = legacy.Sandbox
491 cfg.Bot.Allowlist.Enabled = true
492 cfg.Bot.Allowlist.QQUsers = mergeUniqueTrimmed(cfg.Bot.Allowlist.QQUsers, legacy.OwnerOpenID)
493 cfg.Bot.Allowlist.QQUsers = mergeUniqueTrimmed(cfg.Bot.Allowlist.QQUsers, legacy.Allowlist...)
494 }
495
496 func legacyQQConfigured(legacy legacyQQConfig) bool {
497 return legacy.Enabled ||
498 strings.TrimSpace(legacy.AppID) != "" ||
499 strings.TrimSpace(legacy.AppSecret) != "" ||
500 strings.TrimSpace(legacy.OwnerOpenID) != "" ||
501 len(legacy.Allowlist) > 0 ||
502 legacy.Sandbox
503 }
504
505 func mergeUniqueTrimmed(base []string, values ...string) []string {
506 seen := make(map[string]bool, len(base)+len(values))
507 out := make([]string, 0, len(base)+len(values))
508 for _, value := range append(base, values...) {
509 value = strings.TrimSpace(value)
510 if value == "" || seen[value] {
511 continue
512 }
513 seen[value] = true
514 out = append(out, value)
515 }
516 return out
517 }
518
519 func migrateLegacyTOMLIfNeeded(dest, home string) (*MigrationResult, error) {
520 for _, src := range legacyTOMLPaths(dest, home) {
521 if src == "" || filepath.Clean(src) == filepath.Clean(dest) {
522 continue
523 }
524 if _, err := os.Stat(src); err != nil {
525 continue
526 }
527 cfg := Default()
528 if err := mergeFile(cfg, src); err != nil {
529 return nil, fmt.Errorf("parse legacy config %s: %w", src, err)
530 }
531 cfg.ConfigVersion = Default().ConfigVersion
532 if strings.TrimSpace(cfg.Desktop.CloseBehavior) == "" && strings.TrimSpace(cfg.UI.CloseBehavior) != "" {
533 cfg.Desktop.CloseBehavior = cfg.DesktopCloseBehavior()
534 }
535 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
536 return nil, fmt.Errorf("create config dir: %w", err)
537 }
538 if err := cfg.WriteFile(dest); err != nil {
539 return nil, fmt.Errorf("write %s: %w", dest, err)
540 }
541 res := &MigrationResult{From: src, To: dest, Plugins: len(cfg.Plugins)}
542 legacyDir := filepath.Dir(src)
543 newDir := filepath.Dir(dest)
544 if !samePath(legacyDir, newDir) {
545 if warnings := migrateSupportData(legacyDir, newDir); len(warnings) > 0 {
546 res.Warnings = append(res.Warnings, warnings...)
547 }
548 }
549 return res, nil
550 }
551 return nil, nil
552 }
553
554 func legacyTOMLPaths(dest, home string) []string {
555 seen := map[string]bool{}
556 var paths []string
557 add := func(path string) {
558 if path == "" {
559 return
560 }
561 path = filepath.Clean(path)
562 if seen[path] {
563 return
564 }
565 seen[path] = true
566 paths = append(paths, path)
567 }
568 if legacy := legacyUserConfigPath(); legacy != "" {
569 add(legacy)
570 }
571 for _, legacy := range legacyXDGConfigPaths() {
572 add(legacy)
573 add(filepath.Join(filepath.Dir(legacy), "reasonix.toml"))
574 }
575 add(filepath.Join(filepath.Dir(dest), "reasonix.toml"))
576 if home != "" {
577 add(filepath.Join(home, ".reasonix", "reasonix.toml"))
578 }
579 return paths
580 }
581
582 func migrateLegacyBaseURL(cfg *Config, baseURL string) {
583 baseURL = strings.TrimSpace(baseURL)
584 if cfg == nil || baseURL == "" {
585 return
586 }
587 officialDeepSeek := isOfficialDeepSeekOpenAIEndpoint(baseURL)
588 for i := range cfg.Providers {
589 p := &cfg.Providers[i]
590 if p.APIKeyEnv != "DEEPSEEK_API_KEY" {
591 continue
592 }
593 if officialDeepSeek {
594 // v0.x stored the official OpenAI-compatible root (or /v1). The
595 // current built-in provider is Anthropic, whose documented endpoint
596 // has a distinct /anthropic prefix.
597 p.Kind = "anthropic"
598 p.BaseURL = deepSeekAnthropicBaseURL
599 continue
600 }
601 // A non-official v0.x base URL was an OpenAI-compatible endpoint. Keep
602 // that wire protocol instead of applying the new Anthropic defaults to a
603 // custom gateway that may not implement Messages API.
604 p.Kind = "openai"
605 p.BaseURL = baseURL
606 p.Thinking = ""
607 p.WebSearch = nil
608 p.SupportedEfforts = nil
609 p.DefaultEffort = ""
610 p.ModelOverrides = nil
611 }
612 }
613
614 func legacyPlugins(legacy legacyConfig) []PluginEntry {
615 disabled := make(map[string]bool, len(legacy.MCPDisabled))
616 for _, n := range legacy.MCPDisabled {
617 disabled[n] = true
618 }
619 var out []PluginEntry
620 index := make(map[string]int)
621 add := func(pe PluginEntry, off bool) {
622 if off {
623 v := false
624 pe.AutoStart = &v
625 }
626 pe, _ = NormalizePluginCommandLine(pe)
627 if j, dup := index[pe.Name]; dup {
628 out[j] = pe // mcpServers overrides the `mcp` list on a name collision, matching v0.x
629 return
630 }
631 index[pe.Name] = len(out)
632 out = append(out, pe)
633 }
634 for i, raw := range legacy.MCP {
635 pe, ok := parseLegacyMCPSpec(raw)
636 if !ok {
637 continue
638 }
639 if pe.Name == "" {
640 pe.Name = anonymousMCPName(i)
641 } else if pe.Command != "" {
642 pe.Env = mergeEnv(nil, legacy.MCPEnv[pe.Name])
643 }
644 add(pe, disabled[pe.Name])
645 }
646 names := make([]string, 0, len(legacy.MCPServers))
647 for n := range legacy.MCPServers {
648 names = append(names, n)
649 }
650 sort.Strings(names)
651 for _, name := range names {
652 s := legacy.MCPServers[name]
653 pe := PluginEntry{
654 Name: name,
655 Type: normalizeTransport(firstNonEmpty(s.Type, s.Transport)),
656 Command: s.Command,
657 Args: s.Args,
658 Env: mergeEnv(s.Env, legacy.MCPEnv[name]),
659 URL: s.URL,
660 Headers: s.Headers,
661 }
662 add(pe, s.Disabled || disabled[name])
663 }
664 return out
665 }
666
667 // normalizeTransport maps the v0.x transport names to v1+ plugin types. stdio is
668 // the default, so it returns "" (RenderTOML then omits the field).
669 func normalizeTransport(t string) string {
670 switch strings.ToLower(strings.TrimSpace(t)) {
671 case "http", "streamable-http":
672 return "http"
673 case "sse":
674 return "sse"
675 default:
676 return ""
677 }
678 }
679
680 func firstNonEmpty(a, b string) string {
681 if strings.TrimSpace(a) != "" {
682 return a
683 }
684 return b
685 }
686
687 // mergeEnv overlays the per-server env map onto the spec's own env (overlay wins,
688 // matching v0.x mcpEnv precedence). Returns nil when both are empty.
689 func mergeEnv(base, overlay map[string]string) map[string]string {
690 if len(base) == 0 && len(overlay) == 0 {
691 return nil
692 }
693 out := make(map[string]string, len(base)+len(overlay))
694 maps.Copy(out, base)
695 maps.Copy(out, overlay)
696 return out
697 }
698
699 // writeCredentialsEnv merges lines into Reasonix's global .env
700 // and pins them into the current process env so the just-built session resolves
701 // the key without a restart. Falls back to ~/.env only when Reasonix home can't
702 // be resolved — never a project .env, so a migration keeps secrets out of the
703 // user's project tree.
704 func writeCredentialsEnv(home string, lines []string) error {
705 if _, err := StoreCredentialLines(lines); err != nil {
706 if UserCredentialsPath() == "" && home != "" {
707 return os.WriteFile(filepath.Join(home, ".env"), []byte(strings.Join(lines, "\n")+"\n"), 0o600)
708 }
709 return err
710 }
711 return nil
712 }
713
714 func migrateSupportData(legacyDir, newDir string) []string {
715 var warnings []string
716 // settings.json carries the global hooks; leaving it out silently emptied
717 // them for anyone whose home moved (#4652).
718 items := []string{"sessions", "projects", "skills", "archive", "hooks.json", "settings.json"}
719 for _, item := range items {
720 src := filepath.Join(legacyDir, item)
721 fi, err := os.Stat(src)
722 if err != nil {
723 if os.IsNotExist(err) {
724 continue
725 }
726 warnings = append(warnings, fmt.Sprintf("failed to read legacy item %s: %v", item, err))
727 continue
728 }
729 dst := filepath.Join(newDir, item)
730 if fi.IsDir() {
731 if err := copyDir(src, dst); err != nil {
732 warnings = append(warnings, fmt.Sprintf("failed to migrate directory %s: %v", item, err))
733 } else {
734 warnings = append(warnings, fmt.Sprintf("successfully migrated directory %s", item))
735 }
736 } else {
737 if _, err := os.Stat(dst); err == nil {
738 // A file already written at the destination is newer than the
739 // legacy copy; never overwrite user state during migration.
740 warnings = append(warnings, fmt.Sprintf("kept existing file %s", item))
741 continue
742 }
743 if err := copyFile(src, dst); err != nil {
744 warnings = append(warnings, fmt.Sprintf("failed to migrate file %s: %v", item, err))
745 } else {
746 warnings = append(warnings, fmt.Sprintf("successfully migrated file %s", item))
747 }
748 }
749 }
750 return warnings
751 }
752
753 func copyFile(src, dst string) error {
754 info, err := os.Stat(src)
755 if err != nil {
756 return err
757 }
758 in, err := os.Open(src)
759 if err != nil {
760 return err
761 }
762 defer in.Close()
763
764 parentMode := os.FileMode(0o755)
765 if info.Mode().Perm()&0o077 == 0 {
766 parentMode = 0o700
767 }
768 if err := os.MkdirAll(filepath.Dir(dst), parentMode); err != nil {
769 return err
770 }
771
772 perm := info.Mode().Perm()
773 if perm == 0 {
774 perm = 0o600
775 }
776 out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
777 if err != nil {
778 return err
779 }
780 defer out.Close()
781
782 if _, err = io.Copy(out, in); err != nil {
783 return err
784 }
785 if err := out.Sync(); err != nil {
786 return err
787 }
788 return os.Chmod(dst, perm)
789 }
790
791 func copyDir(src, dst string) error {
792 info, err := os.Stat(src)
793 if err != nil {
794 return err
795 }
796 entries, err := os.ReadDir(src)
797 if err != nil {
798 return err
799 }
800
801 perm := info.Mode().Perm()
802 if perm == 0 {
803 perm = 0o700
804 }
805 if err := os.MkdirAll(dst, perm); err != nil {
806 return err
807 }
808 if err := os.Chmod(dst, perm); err != nil {
809 return err
810 }
811
812 for _, entry := range entries {
813 srcPath := filepath.Join(src, entry.Name())
814 dstPath := filepath.Join(dst, entry.Name())
815
816 if entry.IsDir() {
817 if err := copyDir(srcPath, dstPath); err != nil {
818 return err
819 }
820 } else {
821 if err := copyFile(srcPath, dstPath); err != nil {
822 return err
823 }
824 }
825 }
826 return nil
827 }
828
828 lines GO