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