返回 DeepSeek-Reasonix
main.go
1 // Command reasonix-legacy-migrator is the one-shot flat→versioned install
2 // migrator used by v1.20+ packaging. In compatibility payloads it is still
3 // named reasonix-guard(.exe) so 1.18–1.19.1 updaters can hand off; the source
4 // and behavior are intentionally separate from the old Guard recovery product.
5 //
6 // It only: acquires a migration lock, validates the flat release unit, creates
7 // versions/<version>, writes current.json, rewrites entry points, starts the
8 // thin launcher, and self-deletes when possible. It never chooses safe mode,
9 // counts crashes, or auto-rolls back.
10 package main
11
12 import (
13 "encoding/json"
14 "fmt"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "runtime"
19 "strings"
20 "time"
21
22 "reasonix/internal/config"
23 "reasonix/internal/desktopinstance"
24 "reasonix/internal/desktoplauncher"
25 "reasonix/internal/fileutil"
26 "reasonix/internal/installlayout"
27 "reasonix/internal/repair"
28 )
29
30 var version = "dev"
31
32 const migrationLockName = ".reasonix-layout-migrate.lock"
33
34 func main() {
35 if runningAsLauncher() {
36 os.Exit(desktoplauncher.Run(os.Args[1:], version))
37 }
38 os.Exit(run(os.Args[1:]))
39 }
40
41 func run(args []string) int {
42 if len(args) == 1 {
43 switch args[0] {
44 case "version", "--version", "-v":
45 fmt.Println("reasonix-legacy-migrator", version)
46 return 0
47 case "help", "--help", "-h":
48 fmt.Println("usage: reasonix-legacy-migrator [--install-root PATH] [--version VERSION] [--activate-staging PATH]")
49 return 0
50 }
51 }
52
53 installRoot := ""
54 activeVersion := strings.TrimSpace(version)
55 activateStaging := ""
56 relaunch := true
57 interactive := false
58 for i := 0; i < len(args); i++ {
59 switch args[i] {
60 case "--interactive-recovery":
61 interactive = true
62 case "--install-root":
63 if i+1 >= len(args) {
64 fmt.Fprintln(os.Stderr, "error: --install-root requires a path")
65 return 2
66 }
67 i++
68 installRoot = args[i]
69 case "--version":
70 if i+1 >= len(args) {
71 fmt.Fprintln(os.Stderr, "error: --version requires a value")
72 return 2
73 }
74 i++
75 activeVersion = args[i]
76 case "--activate-staging":
77 if i+1 >= len(args) {
78 fmt.Fprintln(os.Stderr, "error: --activate-staging requires a path")
79 return 2
80 }
81 i++
82 activateStaging = args[i]
83 case "launch", "--detach", "--safe-mode":
84 // Accept legacy argv from old shortcuts; no product behavior.
85 continue
86 case "--no-relaunch":
87 relaunch = false
88 continue
89 case "--app":
90 if i+1 < len(args) {
91 i++
92 }
93 continue
94 }
95 }
96 if installRoot == "" {
97 exe, err := os.Executable()
98 if err != nil {
99 fmt.Fprintln(os.Stderr, "error:", err)
100 return 1
101 }
102 installRoot = filepath.Dir(exe)
103 }
104 installRoot = filepath.Clean(installRoot)
105 if activateStaging != "" {
106 activeVersion, err := normalizeActiveVersion(activeVersion)
107 if err != nil {
108 fmt.Fprintln(os.Stderr, "error:", err)
109 return 1
110 }
111 if err := activateInstallerStagingWithRecovery(installRoot, activeVersion, activateStaging, interactive); err != nil {
112 fmt.Fprintln(os.Stderr, "error:", err)
113 return desktopinstance.ExitCode(err)
114 }
115 if relaunch {
116 _ = startLauncher(installRoot)
117 }
118 return 0
119 }
120
121 if err := migrateWithRelaunch(installRoot, activeVersion, relaunch); err != nil {
122 fmt.Fprintln(os.Stderr, "error:", err)
123 return 1
124 }
125 return 0
126 }
127
128 func migrateWithRelaunch(installRoot, activeVersion string, relaunch bool) error {
129 var err error
130 activeVersion, err = normalizeActiveVersion(activeVersion)
131 if err != nil {
132 return err
133 }
134
135 unlock, err := acquireMigrationLock(installRoot)
136 if err != nil {
137 return err
138 }
139 defer unlock()
140
141 // Pointer already committed: only cleanup, never overwrite the active version.
142 // A present but corrupt pointer is fatal; treating it like an absent pointer
143 // could overwrite evidence of a partial activation and migrate stale files.
144 currentPath := filepath.Join(installRoot, installlayout.CurrentFileName)
145 if _, statErr := os.Lstat(currentPath); statErr == nil {
146 ptr, err := installlayout.ReadCurrent(installRoot)
147 if err != nil {
148 return err
149 }
150 if err := finalizeLegacyPendingUpdate(installRoot, ptr.ActiveVersion); err != nil {
151 return err
152 }
153 _ = cleanupLegacyFlatFiles(installRoot)
154 _ = archiveInstallRootLegacyMarkers(installRoot)
155 if relaunch {
156 _ = startLauncher(installRoot) // best-effort; layout already committed
157 selfDelete()
158 }
159 return nil
160 } else if !os.IsNotExist(statErr) {
161 return fmt.Errorf("migrate: inspect current.json: %w", statErr)
162 }
163
164 desktopName := installlayout.DesktopBinaryName()
165 cliName := installlayout.CLIBinaryName()
166 flatCLIName := installlayout.FlatCLIBinaryName()
167 helperName := installlayout.UpdateHelperBinaryName()
168 flatDesktop := filepath.Join(installRoot, desktopName)
169 if _, err := os.Lstat(flatDesktop); err != nil {
170 return fmt.Errorf("migrate: flat desktop binary missing: %w", err)
171 }
172
173 members := []installlayout.Member{
174 {Name: desktopName, Path: flatDesktop},
175 }
176 // The flat root ships the CLI under its portable name; the version
177 // directory whitelist only admits the versioned member name.
178 if p := optionalRegular(filepath.Join(installRoot, flatCLIName)); p != "" {
179 members = append(members, installlayout.Member{Name: cliName, Path: p})
180 } else {
181 return fmt.Errorf("migrate: flat CLI binary %s is required", flatCLIName)
182 }
183 requiredNames := []string{desktopName, cliName}
184 if runtime.GOOS == "windows" {
185 requiredNames = append(requiredNames, helperName)
186 if p := optionalRegular(filepath.Join(installRoot, helperName)); p != "" {
187 members = append(members, installlayout.Member{Name: helperName, Path: p})
188 } else {
189 return fmt.Errorf("migrate: flat update helper %s is required", helperName)
190 }
191 }
192 shellMembers, err := installlayout.ShellMembers(installRoot, runtime.GOOS)
193 if err != nil {
194 return fmt.Errorf("migrate shell: %w", err)
195 }
196 for _, member := range shellMembers {
197 members = append(members, member)
198 requiredNames = append(requiredNames, member.Name)
199 }
200
201 // Old Linux updaters only publish desktop, CLI, and the compatibility
202 // migrator. On Unix the migrator is also a thin-launcher multicall binary,
203 // so it can create the permanent entry point without another payload member.
204 if err := ensureLauncherEntry(installRoot); err != nil {
205 return err
206 }
207 // v1.18-v1.19 helpers leave an installed transaction awaiting Guard health.
208 // The flat release unit is still intact here, so commit that exact verified
209 // transaction before moving its files into the version directory.
210 if err := finalizeLegacyPendingUpdate(installRoot, activeVersion); err != nil {
211 return err
212 }
213
214 if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
215 InstallRoot: installRoot,
216 Version: activeVersion,
217 RequestID: "legacy-migrate",
218 Members: members,
219 RequiredNames: requiredNames,
220 }); err != nil {
221 return fmt.Errorf("migrate: activate versioned layout: %w", err)
222 }
223
224 _ = cleanupLegacyFlatFiles(installRoot)
225 _ = archiveInstallRootLegacyMarkers(installRoot)
226 if relaunch {
227 // Launcher start is best-effort: layout activation is the commit point.
228 if err := startLauncher(installRoot); err != nil {
229 fmt.Fprintln(os.Stderr, "warning: start launcher after migrate:", err)
230 }
231 // Unix can unlink the running migrator. On Windows the parent thin launcher
232 // removes it after this process exits.
233 selfDelete()
234 }
235 return nil
236 }
237
238 func normalizeActiveVersion(activeVersion string) (string, error) {
239 if err := installlayout.ValidateVersionName(activeVersion); err != nil {
240 // Accept bare "dev" builds in development only.
241 if activeVersion == "" || activeVersion == "dev" {
242 activeVersion = "v0.0.0-dev"
243 if err := installlayout.ValidateVersionName(activeVersion); err != nil {
244 return "", err
245 }
246 } else if !strings.HasPrefix(activeVersion, "v") {
247 activeVersion = "v" + activeVersion
248 if err := installlayout.ValidateVersionName(activeVersion); err != nil {
249 return "", err
250 }
251 } else {
252 return "", err
253 }
254 }
255 return activeVersion, nil
256 }
257
258 func activateInstallerStaging(installRoot, activeVersion, stagingRoot string) error {
259 return activateInstallerStagingWithRecovery(installRoot, activeVersion, stagingRoot, false)
260 }
261
262 func activateInstallerStagingWithRecovery(installRoot, activeVersion, stagingRoot string, interactive bool) error {
263 installRoot = filepath.Clean(strings.TrimSpace(installRoot))
264 stagingRoot = filepath.Clean(strings.TrimSpace(stagingRoot))
265 if !pathWithinInstallRoot(installRoot, stagingRoot) || stagingRoot == installRoot {
266 return fmt.Errorf("installer staging must be inside the install root")
267 }
268 info, err := os.Lstat(stagingRoot)
269 if err != nil {
270 return fmt.Errorf("inspect installer staging: %w", err)
271 }
272 if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
273 return fmt.Errorf("installer staging must be a real directory")
274 }
275
276 desktopName := installlayout.DesktopBinaryName()
277 cliName := installlayout.CLIBinaryName()
278 requiredNames := []string{desktopName, cliName}
279 if runtime.GOOS == "windows" {
280 requiredNames = append(requiredNames, installlayout.UpdateHelperBinaryName())
281 }
282 members := make([]installlayout.Member, 0, len(requiredNames))
283 for _, name := range requiredNames {
284 members = append(members, installlayout.Member{Name: name, Path: filepath.Join(stagingRoot, name)})
285 }
286 shellMembers, err := installlayout.ShellMembers(stagingRoot, runtime.GOOS)
287 if err != nil {
288 return fmt.Errorf("installer shell: %w", err)
289 }
290 if len(shellMembers) == 0 {
291 return fmt.Errorf("installer shell is missing; use the complete installer")
292 }
293 for _, member := range shellMembers {
294 members = append(members, member)
295 requiredNames = append(requiredNames, member.Name)
296 }
297
298 launcherName := installlayout.LauncherBinaryName()
299 launcherSource := filepath.Join(stagingRoot, launcherName)
300 cliEntrySource := filepath.Join(stagingRoot, "app", "resources", "bin", "reasonix-cli-launcher.exe")
301 if info, entryErr := os.Lstat(cliEntrySource); entryErr != nil {
302 if !os.IsNotExist(entryErr) {
303 return fmt.Errorf("inspect CLI entry: %w", entryErr)
304 }
305 cliEntrySource = filepath.Join(stagingRoot, cliName)
306 } else if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
307 return fmt.Errorf("CLI entry is not a regular file")
308 }
309 rootMembers := []installlayout.Member{
310 {Name: launcherName, Path: launcherSource},
311 {Name: cliName, Path: cliEntrySource},
312 }
313 requiredRootNames := []string{launcherName, cliName}
314
315 home := config.ReasonixHomeDir()
316 release, err := desktopinstance.PrepareInstall(installRoot, home, interactive)
317 if err != nil {
318 return err
319 }
320 defer release()
321 // The installer only shows an exit code; the recovery log keeps the reason.
322 finish := desktopinstance.AttemptLog(home, "activate-staging", installRoot)
323 request := installlayout.ActivationRequest{
324 InstallRoot: installRoot,
325 Version: activeVersion,
326 RequestID: "signed-installer-" + activeVersion,
327 CheckProcesses: func() error { return desktopinstance.CheckInstallVacant(installRoot, home) },
328 Members: members,
329 RequiredNames: requiredNames,
330 RootMembers: rootMembers,
331 RequiredRootNames: requiredRootNames,
332 }
333 if runtime.GOOS == "windows" {
334 request.RootMembers, request.RequiredRootNames = nil, nil
335 request.WindowsRootEntries = &installlayout.WindowsRootEntrySources{
336 LauncherPath: launcherSource,
337 CLIEntryPath: cliEntrySource,
338 }
339 }
340 err = installlayout.ActivateVersion(request)
341 finish(err)
342 if err != nil {
343 return fmt.Errorf("activate signed installer staging: %w", err)
344 }
345 return nil
346 }
347
348 func runningAsLauncher() bool {
349 exe, err := os.Executable()
350 if err != nil {
351 return false
352 }
353 return strings.EqualFold(filepath.Base(exe), installlayout.LauncherBinaryName()) ||
354 strings.EqualFold(filepath.Base(exe), installlayout.CanonicalLauncherBinaryName())
355 }
356
357 func optionalRegular(path string) string {
358 info, err := os.Lstat(path)
359 if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
360 return ""
361 }
362 return path
363 }
364
365 func acquireMigrationLock(installRoot string) (func(), error) {
366 if err := os.MkdirAll(installRoot, 0o755); err != nil {
367 return nil, err
368 }
369 path := filepath.Join(installRoot, migrationLockName)
370 f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
371 if err != nil {
372 if os.IsExist(err) {
373 // Stale lock from a dead migrator: if older than 10 minutes, steal it.
374 info, statErr := os.Stat(path)
375 if statErr == nil && time.Since(info.ModTime()) > 10*time.Minute {
376 _ = os.Remove(path)
377 f, err = os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
378 }
379 }
380 if err != nil {
381 return nil, fmt.Errorf("migrate: acquire lock: %w", err)
382 }
383 }
384 _, _ = fmt.Fprintf(f, "pid=%d\nversion=%s\n", os.Getpid(), version)
385 _ = f.Close()
386 return func() { _ = os.Remove(path) }, nil
387 }
388
389 func ensureLauncherEntry(installRoot string) error {
390 if runtime.GOOS == "windows" {
391 for _, name := range []string{installlayout.CanonicalLauncherBinaryName(), installlayout.LauncherBinaryName()} {
392 info, err := os.Lstat(filepath.Join(installRoot, name))
393 if os.IsNotExist(err) {
394 continue
395 }
396 if err != nil {
397 return fmt.Errorf("migrate: inspect launcher %s: %w", name, err)
398 }
399 if !info.Mode().IsRegular() {
400 return fmt.Errorf("migrate: thin launcher %s is not a regular file", name)
401 }
402 return nil
403 }
404 return fmt.Errorf("migrate: thin launcher is missing; install a signed package")
405 }
406 launcher := installlayout.LauncherBinaryName()
407 path := filepath.Join(installRoot, launcher)
408 if info, err := os.Lstat(path); err == nil {
409 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
410 return fmt.Errorf("migrate: thin launcher %s is not a regular file", launcher)
411 }
412 return nil
413 }
414 // Legacy Linux archives cannot deliver a fourth member through the old
415 // updater. Copy this signed multicall migrator as the permanent thin launcher.
416 exe, err := os.Executable()
417 if err != nil {
418 return fmt.Errorf("migrate: resolve migrator executable: %w", err)
419 }
420 info, err := os.Lstat(exe)
421 if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
422 return fmt.Errorf("migrate: migrator executable is not a regular file")
423 }
424 body, err := os.ReadFile(exe)
425 if err != nil {
426 return fmt.Errorf("migrate: read launcher source: %w", err)
427 }
428 if err := fileutil.AtomicWriteFile(path, body, 0o755); err != nil {
429 return fmt.Errorf("migrate: install thin launcher: %w", err)
430 }
431 return nil
432 }
433
434 func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
435 tx, err := repair.ReadPendingUpdate()
436 if os.IsNotExist(err) {
437 return nil
438 }
439 if err != nil {
440 return fmt.Errorf("migrate: read legacy pending update: %w", err)
441 }
442 if tx.TargetKind != "file" {
443 return fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
444 }
445 if strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(activeVersion) {
446 return fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
447 }
448 for _, target := range append([]repair.UpdateTransactionFile{{TargetPath: tx.TargetPath}}, tx.Files...) {
449 if !pathWithinInstallRoot(installRoot, target.TargetPath) {
450 return fmt.Errorf("migrate: pending update target escapes install root")
451 }
452 }
453 id := repair.UpdateTransactionID(tx)
454 if err := repair.MarkUpdateHealthyExact(activeVersion, tx.CreatedAt, id); err != nil {
455 return fmt.Errorf("migrate: commit legacy pending update: %w", err)
456 }
457 if current, err := repair.ReadPendingUpdate(); err == nil {
458 if repair.UpdateTransactionID(current) == id {
459 return fmt.Errorf("migrate: legacy pending update remained after commit")
460 }
461 return fmt.Errorf("migrate: pending update changed during commit")
462 } else if !os.IsNotExist(err) {
463 return fmt.Errorf("migrate: verify legacy pending update commit: %w", err)
464 }
465 return nil
466 }
467
468 func pathWithinInstallRoot(installRoot, target string) bool {
469 root := filepath.Clean(strings.TrimSpace(installRoot))
470 target = filepath.Clean(strings.TrimSpace(target))
471 if root == "" || target == "" || !filepath.IsAbs(root) || !filepath.IsAbs(target) {
472 return false
473 }
474 rel, err := filepath.Rel(root, target)
475 if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
476 return false
477 }
478 return true
479 }
480
481 func cleanupLegacyFlatFiles(installRoot string) error {
482 // Remove flat desktop/CLI/helper/guard sidecars after the version tree is
483 // active. Never delete the thin launcher or current.json.
484 names := []string{
485 installlayout.DesktopBinaryName(),
486 installlayout.FlatCLIBinaryName(),
487 installlayout.UpdateHelperBinaryName(),
488 }
489 if runtime.GOOS == "windows" {
490 names = append(names, "reasonix-guard.exe")
491 } else {
492 names = append(names, "reasonix-guard")
493 }
494 for _, name := range names {
495 path := filepath.Join(installRoot, name)
496 info, err := os.Lstat(path)
497 if err != nil || !info.Mode().IsRegular() {
498 continue
499 }
500 _ = os.Remove(path)
501 }
502 return nil
503 }
504
505 func archiveInstallRootLegacyMarkers(installRoot string) error {
506 // Very old portable builds wrote markers beside the binary. Current repair
507 // state under Reasonix home is finalized through repair transaction APIs.
508 markers := []string{
509 "pending-update.json",
510 "startup-state.json",
511 }
512 ts := time.Now().UTC().Format("20060102T150405Z")
513 destRoot := filepath.Join(installRoot, "repair", "legacy-v1", ts)
514 moved := false
515 for _, name := range markers {
516 src := filepath.Join(installRoot, name)
517 if _, err := os.Lstat(src); err != nil {
518 continue
519 }
520 if err := os.MkdirAll(destRoot, 0o755); err != nil {
521 return err
522 }
523 dest := filepath.Join(destRoot, name)
524 if err := os.Rename(src, dest); err != nil {
525 // Cross-device: copy+remove best effort.
526 data, readErr := os.ReadFile(src)
527 if readErr != nil {
528 continue
529 }
530 if writeErr := fileutil.AtomicWriteFile(dest, data, 0o600); writeErr == nil {
531 _ = os.Remove(src)
532 }
533 }
534 moved = true
535 }
536 if moved {
537 meta, _ := json.MarshalIndent(map[string]any{
538 "migratedAt": time.Now().UTC().Format(time.RFC3339),
539 "from": "flat-v1",
540 "to": installlayout.InstallLayoutVersionedV1,
541 }, "", " ")
542 _ = fileutil.AtomicWriteFile(filepath.Join(destRoot, "migration.json"), append(meta, '\n'), 0o644)
543 }
544 return nil
545 }
546
547 func startLauncher(installRoot string) error {
548 path := ""
549 for _, name := range []string{installlayout.CanonicalLauncherBinaryName(), installlayout.LauncherBinaryName()} {
550 if candidate := optionalRegular(filepath.Join(installRoot, name)); candidate != "" {
551 path = candidate
552 break
553 }
554 }
555 if path == "" {
556 // Migration succeeded; user can start manually.
557 return nil
558 }
559 cmd := exec.Command(path)
560 cmd.Dir = installRoot
561 cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
562 if runtime.GOOS == "windows" {
563 return cmd.Start()
564 }
565 return cmd.Start()
566 }
567
568 func selfDelete() {
569 exe, err := os.Executable()
570 if err != nil {
571 return
572 }
573 // Do not delete if we are not named like a compatibility guard payload.
574 base := strings.ToLower(filepath.Base(exe))
575 if base != "reasonix-guard" && base != "reasonix-guard.exe" {
576 return
577 }
578 _ = os.Remove(exe)
579 }
580
580 lines GO