返回 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 return locateWithCommand(ctx, conn, LocateCommand(uploaded), minVersion)
76 }
77
78 func locateUploaded(ctx context.Context, conn Conn, uploaded, minVersion string) (bin, version string) {
79 return locateWithCommand(ctx, conn, LocateUploadedCommand(uploaded), minVersion)
80 }
81
82 func locateNPMGlobal(ctx context.Context, conn Conn, minVersion string) (bin, version string) {
83 return locateWithCommand(ctx, conn, LocateNPMGlobalCommand(), minVersion)
84 }
85
86 func locateWithCommand(ctx context.Context, conn Conn, command, minVersion string) (bin, version string) {
87 _ = minVersion
88 res, err := conn.Exec(ctx, command)
89 if err != nil {
90 return "", ""
91 }
92 lines := strings.Split(strings.TrimRight(string(res.Stdout), "\n"), "\n")
93 path := strings.TrimSpace(lines[0])
94 if path == "" {
95 return "", ""
96 }
97 supportsPortFile, supportsSessionEvents, supportsDetachedHeal, supportsCaps := false, false, false, false
98 for _, ln := range lines[1:] {
99 ln = strings.TrimSpace(ln)
100 if ln == "portfile:yes" {
101 supportsPortFile = true
102 } else if ln == "portfile:no" {
103 supportsPortFile = false
104 } else if ln == "sessionevents:yes" {
105 supportsSessionEvents = true
106 } else if ln == "sessionevents:no" {
107 supportsSessionEvents = false
108 } else if ln == "detachedheal:yes" {
109 supportsDetachedHeal = true
110 } else if ln == "detachedheal:no" {
111 supportsDetachedHeal = false
112 } else if ln == "caps:yes" {
113 supportsCaps = true
114 } else if ln == "caps:no" {
115 supportsCaps = false
116 } else if v, verr := ParseVersion(ln); verr == nil {
117 version = v
118 }
119 }
120 if !supportsPortFile || !supportsSessionEvents || !supportsDetachedHeal || !supportsCaps {
121 // Missing a required Serve contract: treat as unusable so it is upgraded.
122 return "", ""
123 }
124 return path, version
125 }
126
127 func installViaNPM(ctx context.Context, conn Conn, minVersion string) (bin, version string, err error) {
128 res, err := conn.Exec(ctx, "npm i -g reasonix 2>&1")
129 if err != nil {
130 return "", "", fmt.Errorf("bootstrap: npm install: %w", err)
131 }
132 if res.ExitCode != 0 {
133 return "", "", fmt.Errorf("bootstrap: npm install failed: %s", tail(res.Stdout, 400))
134 }
135 // npm may install outside the login PATH; probe npm prefix explicitly.
136 loc, ver := locateNPMGlobal(ctx, conn, minVersion)
137 if loc == "" {
138 return "", "", fmt.Errorf("bootstrap: reasonix not found after npm install (check remote PATH / npm prefix)")
139 }
140 return loc, ver, nil
141 }
142
143 // installViaUpload uploads the local reasonix binary when the remote platform
144 // matches the local one. Cross-platform release download is a documented V1
145 // limitation: use serve_install = npm for a differing remote platform.
146 func installViaUpload(ctx context.Context, conn Conn, fs *sftpfs.FS, opts Options, home, goos, goarch, uploaded string) (bin, version string, err error) {
147 if opts.LocalBinary == "" {
148 return "", "", fmt.Errorf("bootstrap: upload strategy needs the local reasonix binary path")
149 }
150 if opts.LocalGOOS != goos || opts.LocalGOARCH != goarch {
151 return "", "", fmt.Errorf("bootstrap: cannot upload: local binary is %s/%s but remote is %s/%s; use serve_install = npm",
152 opts.LocalGOOS, opts.LocalGOARCH, goos, goarch)
153 }
154 data, rerr := os.ReadFile(opts.LocalBinary)
155 if rerr != nil {
156 return "", "", fmt.Errorf("bootstrap: read local binary: %w", rerr)
157 }
158 return installBinaryBytes(ctx, conn, fs, data, opts.MinVersion, home, uploaded)
159 }
160
161 func installBinaryBytes(ctx context.Context, conn Conn, fs *sftpfs.FS, data []byte, minVersion, home, uploaded string) (bin, version string, err error) {
162 if len(data) == 0 {
163 return "", "", fmt.Errorf("bootstrap: downloaded binary is empty")
164 }
165 if err := fs.MkdirAll(ctx, dirOf(uploaded)); err != nil {
166 return "", "", err
167 }
168 if err := fs.WriteFileAtomic(ctx, uploaded, data, 0o755); err != nil {
169 return "", "", fmt.Errorf("bootstrap: upload binary: %w", err)
170 }
171 loc, ver := locateUploaded(ctx, conn, uploaded, minVersion)
172 if loc == "" {
173 return "", "", fmt.Errorf("bootstrap: uploaded binary not runnable on remote")
174 }
175 return loc, ver, nil
176 }
177
178 func dirOf(p string) string {
179 if i := strings.LastIndex(p, "/"); i >= 0 {
180 return p[:i]
181 }
182 return "."
183 }
184
185 func tail(b []byte, n int) string {
186 s := strings.TrimSpace(string(b))
187 if len(s) > n {
188 return "..." + s[len(s)-n:]
189 }
190 return s
191 }
192
192 lines GO