返回 DeepSeek-Reasonix
install.go
根目录 / internal / remote / bootstrap / install.go
1 package bootstrap
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "strings"
9
10 "reasonix/internal/remote/sftpfs"
11 )
12
13 // ensureBinary resolves a usable reasonix binary on the remote host per the
14 // install strategy, returning its path and version. A located binary older
15 // than MinVersion counts as missing (it lacks --port-file/--token-file).
16 func ensureBinary(ctx context.Context, conn Conn, fs *sftpfs.FS, opts Options, home, goos, goarch string, paths StatePaths) (bin, version string, err error) {
17 uploaded := uploadedBinPath(home)
18 bin, version = locate(ctx, conn, uploaded, opts.MinVersion)
19 if bin != "" {
20 return bin, version, nil
21 }
22
23 strategy := opts.Install
24 if strategy == "" {
25 strategy = InstallAuto
26 }
27 opts.progress("install", strategy)
28
29 switch strategy {
30 case InstallNever:
31 return "", "", fmt.Errorf("bootstrap: reasonix not found on remote and serve_install = never")
32 case InstallNPM:
33 return installViaNPM(ctx, conn, opts.MinVersion)
34 case InstallUpload:
35 return installViaUpload(ctx, conn, fs, opts, home, goos, goarch, uploaded)
36 default: // auto: try npm, packaged same-platform upload, then verified release upload
37 if b, v, nerr := installViaNPM(ctx, conn, opts.MinVersion); nerr == nil {
38 return b, v, nil
39 } else {
40 attempts := []error{nerr}
41 if opts.LocalBinary != "" && opts.LocalGOOS == goos && opts.LocalGOARCH == goarch {
42 if b, v, uploadErr := installViaUpload(ctx, conn, fs, opts, home, goos, goarch, uploaded); uploadErr == nil {
43 return b, v, nil
44 } else {
45 attempts = append(attempts, uploadErr)
46 }
47 } else if opts.LocalBinary == "" {
48 attempts = append(attempts, errors.New("bootstrap: no local Reasonix CLI is available for upload"))
49 } else {
50 attempts = append(attempts, fmt.Errorf("bootstrap: local binary is %s/%s but remote is %s/%s", opts.LocalGOOS, opts.LocalGOARCH, goos, goarch))
51 }
52 if opts.FetchBinary != nil {
53 binary, fetchErr := opts.FetchBinary(ctx, opts.ProductVersion, goos, goarch)
54 if fetchErr == nil {
55 if b, v, uploadErr := installBinaryBytes(ctx, conn, fs, binary, opts.MinVersion, home, uploaded); uploadErr == nil {
56 return b, v, nil
57 } else {
58 attempts = append(attempts, uploadErr)
59 }
60 } else {
61 attempts = append(attempts, fmt.Errorf("bootstrap: fetch official %s/%s CLI: %w", goos, goarch, fetchErr))
62 }
63 }
64 return "", "", fmt.Errorf("bootstrap: automatic install failed: %w", errors.Join(attempts...))
65 }
66 }
67 }
68
69 // locate finds an existing reasonix and returns it only if its serve command
70 // supports --port-file (the bootstrap contract). A binary that lacks the flag —
71 // including every currently-released version — is reported as missing so the
72 // install/upload path replaces it. minVersion is accepted for signature
73 // stability but the flag probe is authoritative.
74 func locate(ctx context.Context, conn Conn, uploaded, minVersion string) (bin, version string) {
75 _ = minVersion
76 res, err := conn.Exec(ctx, LocateCommand(uploaded))
77 if err != nil {
78 return "", ""
79 }
80 lines := strings.Split(strings.TrimRight(string(res.Stdout), "\n"), "\n")
81 path := strings.TrimSpace(lines[0])
82 if path == "" {
83 return "", ""
84 }
85 supportsPortFile := false
86 for _, ln := range lines[1:] {
87 ln = strings.TrimSpace(ln)
88 if ln == "portfile:yes" {
89 supportsPortFile = true
90 } else if ln == "portfile:no" {
91 supportsPortFile = false
92 } else if v, verr := ParseVersion(ln); verr == nil {
93 version = v
94 }
95 }
96 if !supportsPortFile {
97 // Missing the --port-file flag: treat as unusable so it is upgraded.
98 return "", ""
99 }
100 return path, version
101 }
102
103 func installViaNPM(ctx context.Context, conn Conn, minVersion string) (bin, version string, err error) {
104 res, err := conn.Exec(ctx, "npm i -g reasonix 2>&1")
105 if err != nil {
106 return "", "", fmt.Errorf("bootstrap: npm install: %w", err)
107 }
108 if res.ExitCode != 0 {
109 return "", "", fmt.Errorf("bootstrap: npm install failed: %s", tail(res.Stdout, 400))
110 }
111 // npm may install outside the login PATH; probe npm prefix explicitly.
112 loc, ver := locate(ctx, conn, "", minVersion)
113 if loc == "" {
114 return "", "", fmt.Errorf("bootstrap: reasonix not found after npm install (check remote PATH / npm prefix)")
115 }
116 return loc, ver, nil
117 }
118
119 // installViaUpload uploads the local reasonix binary when the remote platform
120 // matches the local one. Cross-platform release download is a documented V1
121 // limitation: use serve_install = npm for a differing remote platform.
122 func installViaUpload(ctx context.Context, conn Conn, fs *sftpfs.FS, opts Options, home, goos, goarch, uploaded string) (bin, version string, err error) {
123 if opts.LocalBinary == "" {
124 return "", "", fmt.Errorf("bootstrap: upload strategy needs the local reasonix binary path")
125 }
126 if opts.LocalGOOS != goos || opts.LocalGOARCH != goarch {
127 return "", "", fmt.Errorf("bootstrap: cannot upload: local binary is %s/%s but remote is %s/%s; use serve_install = npm",
128 opts.LocalGOOS, opts.LocalGOARCH, goos, goarch)
129 }
130 data, rerr := os.ReadFile(opts.LocalBinary)
131 if rerr != nil {
132 return "", "", fmt.Errorf("bootstrap: read local binary: %w", rerr)
133 }
134 return installBinaryBytes(ctx, conn, fs, data, opts.MinVersion, home, uploaded)
135 }
136
137 func installBinaryBytes(ctx context.Context, conn Conn, fs *sftpfs.FS, data []byte, minVersion, home, uploaded string) (bin, version string, err error) {
138 if len(data) == 0 {
139 return "", "", fmt.Errorf("bootstrap: downloaded binary is empty")
140 }
141 if err := fs.MkdirAll(ctx, dirOf(uploaded)); err != nil {
142 return "", "", err
143 }
144 if err := fs.WriteFileAtomic(ctx, uploaded, data, 0o755); err != nil {
145 return "", "", fmt.Errorf("bootstrap: upload binary: %w", err)
146 }
147 loc, ver := locate(ctx, conn, uploaded, minVersion)
148 if loc == "" {
149 return "", "", fmt.Errorf("bootstrap: uploaded binary not runnable on remote")
150 }
151 return loc, ver, nil
152 }
153
154 func dirOf(p string) string {
155 if i := strings.LastIndex(p, "/"); i >= 0 {
156 return p[:i]
157 }
158 return "."
159 }
160
161 func tail(b []byte, n int) string {
162 s := strings.TrimSpace(string(b))
163 if len(s) > n {
164 return "..." + s[len(s)-n:]
165 }
166 return s
167 }
168
168 lines GO