返回 DeepSeek-Reasonix
remote_connect.go
根目录 / internal / cli / remote_connect.go
1 package cli
2
3 import (
4 "context"
5 "errors"
6 "flag"
7 "fmt"
8 "os"
9 "os/exec"
10 "os/signal"
11 "path"
12 "path/filepath"
13 "runtime"
14 "strings"
15 "syscall"
16 "time"
17
18 "golang.org/x/term"
19
20 "reasonix/internal/config"
21 "reasonix/internal/i18n"
22 "reasonix/internal/netclient"
23 "reasonix/internal/releaseasset"
24 "reasonix/internal/remote"
25 "reasonix/internal/remote/bootstrap"
26 "reasonix/internal/remote/forward"
27 )
28
29 func newFlagSet(name string) *flag.FlagSet {
30 fs := flag.NewFlagSet(name, flag.ContinueOnError)
31 return fs
32 }
33
34 // buildRemoteClient resolves nameOrTarget against config + ~/.ssh/config and
35 // returns a not-yet-started remote.Client with terminal-based secret and
36 // host-key prompts. cleanup releases transient resources.
37 func buildRemoteClient(nameOrTarget string) (*remote.Client, func(), error) {
38 cfg, err := config.Load()
39 if err != nil {
40 return nil, nil, err
41 }
42 sshCfg, err := remote.LoadUserSSHConfig()
43 if err != nil {
44 return nil, nil, fmt.Errorf("load SSH config: %w", err)
45 }
46 host, err := remote.ResolveHost(cfg, nameOrTarget, sshCfg)
47 if err != nil {
48 return nil, nil, err
49 }
50
51 auth := remoteAuthForHost(host, terminalSecretPrompt)
52 resolvedJumps, err := remote.ResolveJumpHosts(cfg, host.ProxyJump, sshCfg)
53 if err != nil {
54 return nil, nil, err
55 }
56 jumpHosts := make([]remote.JumpHostOptions, 0, len(resolvedJumps))
57 for _, jump := range resolvedJumps {
58 jumpHosts = append(jumpHosts, remote.JumpHostOptions{
59 Host: jump,
60 Auth: remoteAuthForHost(jump, terminalSecretPrompt),
61 })
62 }
63
64 policy := &remote.HostKeyPolicy{Prompt: terminalHostKeyPrompt}
65
66 // A misconfigured proxy is surfaced, not silently bypassed: a proxy is often
67 // a policy requirement, and quietly dialing direct could exfiltrate the
68 // connection around it.
69 dialer, derr := netclient.NewStreamDialer(cfg.NetworkProxySpec())
70 if derr != nil {
71 return nil, nil, fmt.Errorf("remote: network proxy is misconfigured: %w", derr)
72 }
73 client, err := remote.New(remote.Options{
74 Host: host,
75 Auth: auth,
76 JumpHosts: jumpHosts,
77 HostKeys: policy,
78 Dialer: dialer,
79 })
80 if err != nil {
81 return nil, nil, err
82 }
83 return client, func() {}, nil
84 }
85
86 func remoteAuthForHost(host remote.ResolvedHost, prompt remote.SecretPrompt) remote.AuthOptions {
87 auth := remote.AuthOptions{SecretPrompt: prompt}
88 if host.PassphraseEnv != "" {
89 env := host.PassphraseEnv
90 auth.Passphrase = func() (string, error) { return config.ResolveCredential(env).Value, nil }
91 }
92 if host.PasswordEnv != "" {
93 env := host.PasswordEnv
94 auth.Password = func() (string, error) { return config.ResolveCredential(env).Value, nil }
95 }
96 return auth
97 }
98
99 func terminalSecretPrompt(_ context.Context, kind remote.SecretKind, host, identityFile string) (string, error) {
100 var label string
101 switch kind {
102 case remote.SecretPassword:
103 label = fmt.Sprintf(i18n.M.RemotePasswordPromptFmt, host)
104 default:
105 label = fmt.Sprintf(i18n.M.RemotePassphrasePromptFmt, host)
106 if identityFile != "" {
107 label += " (" + filepath.Base(identityFile) + ")"
108 }
109 }
110 fmt.Fprint(os.Stderr, label+" ")
111 if !term.IsTerminal(int(os.Stdin.Fd())) {
112 return "", fmt.Errorf("cannot prompt for %s: not a terminal", kind)
113 }
114 b, err := term.ReadPassword(int(os.Stdin.Fd()))
115 fmt.Fprintln(os.Stderr)
116 if err != nil {
117 return "", err
118 }
119 return string(b), nil
120 }
121
122 func terminalHostKeyPrompt(_ context.Context, q remote.HostKeyQuestion) (bool, error) {
123 fmt.Fprintf(os.Stderr, i18n.M.RemoteHostKeyPromptFmt+"\n", q.Host, q.KeyType, q.Fingerprint)
124 fmt.Fprint(os.Stderr, "Accept and continue? [y/N] ")
125 var answer string
126 _, _ = fmt.Fscanln(os.Stdin, &answer)
127 answer = strings.ToLower(strings.TrimSpace(answer))
128 return answer == "y" || answer == "yes", nil
129 }
130
131 // remoteConnectSyntax is the parsed form of `reasonix remote connect|open …`.
132 type remoteConnectSyntax struct {
133 name string
134 workspace string
135 localPort int
136 noServe bool
137 open bool
138 forwardOnly bool
139 }
140
141 const remoteConnectUsage = "usage: reasonix remote connect <name> [flags]"
142
143 // parseRemoteConnectSyntax accepts both documented orders:
144 //
145 // reasonix remote connect <name> [flags]
146 // reasonix remote connect [flags] <name>
147 //
148 // Go's flag package stops at the first positional, so `<name> --open` used to
149 // fail even though help/GUIDE show that form. We keep stdlib flags (so `-open`
150 // still works) and only special-case a leading host name.
151 func parseRemoteConnectSyntax(args []string, openAlias bool) (remoteConnectSyntax, error) {
152 fs := newFlagSet("remote connect")
153 workspace := fs.String("workspace", "", "remote workspace directory")
154 localPort := fs.Int("local-port", 0, "local port to bind for the serve tunnel (0 = auto)")
155 noServe := fs.Bool("no-serve", false, "only establish forwards; do not bootstrap serve")
156 open := fs.Bool("open", openAlias, "print/open the serve URL")
157 forwardOnly := fs.Bool("forward-only", false, "apply configured forwards only; no serve")
158
159 var name string
160 flagArgs := args
161 if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
162 name = args[0]
163 flagArgs = args[1:]
164 }
165 if err := parseCommandFlagSet(fs, flagArgs); err != nil {
166 return remoteConnectSyntax{}, err
167 }
168 rest := fs.Args()
169 switch {
170 case name != "" && len(rest) == 0:
171 // name-first: connect <name> [flags]
172 case name == "" && len(rest) == 1:
173 // flags-first: connect [flags] <name>
174 name = rest[0]
175 default:
176 return remoteConnectSyntax{}, errors.New(remoteConnectUsage)
177 }
178 return remoteConnectSyntax{
179 name: name,
180 workspace: *workspace,
181 localPort: *localPort,
182 noServe: *noServe,
183 open: *open,
184 forwardOnly: *forwardOnly,
185 }, nil
186 }
187
188 // remoteConnectCLI runs a foreground supervisor: connect, bootstrap serve,
189 // forward the serve port and configured forwards, and hold the tunnel until
190 // Ctrl-C. The remote serve keeps running after disconnect.
191 func remoteConnectCLI(args []string, version string) int {
192 // args[0] is "connect" or "open".
193 openAlias := args[0] == "open"
194 syntax, err := parseRemoteConnectSyntax(args[1:], openAlias)
195 if err != nil {
196 if code, ok := reportCommandFlagError(err); ok {
197 return code
198 }
199 fmt.Fprintln(os.Stderr, err)
200 return 2
201 }
202 name := syntax.name
203
204 cfg, err := config.Load()
205 if err != nil {
206 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
207 return 1
208 }
209 entry, _ := cfg.RemoteHost(name)
210 ws := syntax.workspace
211 if ws == "" {
212 ws = entry.Workspace
213 }
214
215 client, cleanup, err := buildRemoteClient(name)
216 if err != nil {
217 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
218 return 1
219 }
220 defer cleanup()
221
222 // Live status line.
223 client.Subscribe(func(ev remote.StatusEvent) {
224 switch ev.Status {
225 case remote.StatusConnecting:
226 fmt.Fprintf(os.Stderr, i18n.M.RemoteConnectingFmt+"\n", name)
227 case remote.StatusConnected:
228 fmt.Fprintf(os.Stderr, i18n.M.RemoteConnectedFmt+"\n", name)
229 case remote.StatusReconnecting:
230 fmt.Fprintf(os.Stderr, i18n.M.RemoteReconnectingFmt+"\n", name, ev.Attempt)
231 case remote.StatusDegraded:
232 fmt.Fprintf(os.Stderr, i18n.M.RemoteDegradedFmt+"\n", name)
233 case remote.StatusStopped:
234 if ev.Err != nil {
235 fmt.Fprintf(os.Stderr, "%s %v\n", i18n.M.ErrorPrefix, ev.Err)
236 }
237 }
238 })
239
240 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
241 defer stop()
242 if err := client.Start(ctx); err != nil {
243 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
244 return 1
245 }
246 defer client.Close()
247
248 // Apply configured forwards.
249 if err := applyConfiguredForwards(client, entry); err != nil {
250 fmt.Fprintf(os.Stderr, "%s %v\n", i18n.M.ErrorPrefix, err)
251 }
252
253 if !syntax.noServe && !syntax.forwardOnly {
254 res, err := bootstrap.EnsureServe(ctx, client, bootstrap.Options{
255 Workspace: ws,
256 Install: entry.ServeInstallMode(),
257 LocalBinary: currentExecutable(),
258 LocalGOOS: runtime.GOOS,
259 LocalGOARCH: runtime.GOARCH,
260 ProductVersion: version,
261 FetchBinary: fetchRemoteCLIBinary,
262 MinVersion: bootstrap.MinServeVersion,
263 Progress: func(step, detail string) {
264 fmt.Fprintf(os.Stderr, i18n.M.RemoteBootstrapStepFmt+"\n", step, detail)
265 },
266 })
267 if err != nil {
268 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
269 return 1
270 }
271 localURL, ferr := forwardServe(client, res.State.Addr, syntax.localPort, res.Token)
272 if ferr != nil {
273 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, ferr)
274 return 1
275 }
276 fmt.Printf(i18n.M.RemoteServeReadyFmt+"\n", localURL)
277 if syntax.open {
278 _ = openInBrowser(localURL)
279 }
280 }
281
282 fmt.Fprintln(os.Stderr, "Press Ctrl-C to disconnect (remote serve keeps running).")
283 <-ctx.Done()
284 fmt.Fprintln(os.Stderr, i18n.M.RemoteDisconnected)
285 return 0
286 }
287
288 func applyConfiguredForwards(client *remote.Client, entry config.RemoteHostEntry) error {
289 set := client.Forwards()
290 var firstErr error
291 for _, f := range entry.Forwards {
292 dir := forward.Local
293 if strings.EqualFold(f.Type, "remote") {
294 dir = forward.Remote
295 }
296 spec := forward.Spec{Direction: dir, BindAddr: normalizeBind(f.Bind), TargetAddr: f.Target}
297 if _, err := set.Add(spec); err != nil && firstErr == nil {
298 firstErr = err
299 }
300 }
301 return firstErr
302 }
303
304 // forwardServe adds the reserved "serve" local forward to the remote serve
305 // address and returns the local URL (with token).
306 func forwardServe(client *remote.Client, remoteAddr string, localPort int, token string) (string, error) {
307 bind := "127.0.0.1:0"
308 if localPort > 0 {
309 bind = fmt.Sprintf("127.0.0.1:%d", localPort)
310 }
311 bound, err := client.Forwards().Add(forward.Spec{
312 Name: "serve",
313 Direction: forward.Local,
314 BindAddr: bind,
315 TargetAddr: remoteAddr,
316 })
317 if err != nil {
318 return "", err
319 }
320 return fmt.Sprintf("http://%s/?token=%s", bound, token), nil
321 }
322
323 func normalizeBind(bind string) string {
324 if !strings.Contains(bind, ":") {
325 return "127.0.0.1:" + bind
326 }
327 return bind
328 }
329
330 func currentExecutable() string {
331 if p, err := os.Executable(); err == nil {
332 return p
333 }
334 return ""
335 }
336
337 func fetchRemoteCLIBinary(ctx context.Context, version, goos, goarch string) ([]byte, error) {
338 cfg, err := config.Load()
339 if err != nil {
340 return nil, err
341 }
342 client, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{
343 ResponseHeaderTimeout: 30 * time.Second,
344 })
345 if err != nil {
346 return nil, err
347 }
348 client.Timeout = 2 * time.Minute
349 return releaseasset.DownloadCLI(ctx, client, version, goos, goarch)
350 }
351
352 const remoteServeUsage = "usage: reasonix remote serve start|stop|status|logs <name> [--workspace PATH] [-n N]"
353
354 // remoteServeCLI: serve start|stop|status|logs <name>.
355 func remoteServeCLI(args []string, version string) int {
356 if commandHelpRequested(args, 2) {
357 fmt.Fprintln(os.Stdout, remoteServeUsage)
358 return 0
359 }
360 if len(args) < 2 {
361 fmt.Fprintln(os.Stderr, remoteServeUsage)
362 return 2
363 }
364 action := args[0]
365 fs := newFlagSet("remote serve")
366 workspace := fs.String("workspace", "", "remote workspace directory")
367 n := fs.Int("n", 200, "log lines to show (logs)")
368 if code, ok := parseCommandFlags(fs, args[2:]); !ok {
369 return code
370 }
371 name := args[1]
372 cfg, err := config.Load()
373 if err != nil {
374 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
375 return 1
376 }
377 entry, _ := cfg.RemoteHost(name)
378 ws := *workspace
379 if ws == "" {
380 ws = entry.Workspace
381 }
382
383 client, cleanup, err := buildRemoteClient(name)
384 if err != nil {
385 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
386 return 1
387 }
388 defer cleanup()
389 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
390 defer cancel()
391 if err := client.Start(ctx); err != nil {
392 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
393 return 1
394 }
395 defer client.Close()
396
397 switch action {
398 case "start":
399 res, err := bootstrap.EnsureServe(ctx, client, bootstrap.Options{
400 Workspace: ws, Install: entry.ServeInstallMode(),
401 LocalBinary: currentExecutable(), LocalGOOS: runtime.GOOS, LocalGOARCH: runtime.GOARCH,
402 ProductVersion: version, FetchBinary: fetchRemoteCLIBinary, MinVersion: bootstrap.MinServeVersion,
403 })
404 if err != nil {
405 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
406 return 1
407 }
408 fmt.Printf("serve running on remote %s (pid %d)\n", res.State.Addr, res.State.PID)
409 return 0
410 case "stop":
411 if err := bootstrap.Stop(ctx, client, ws); err != nil {
412 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
413 return 1
414 }
415 fmt.Println("serve stopped")
416 return 0
417 case "status":
418 st, alive, err := bootstrap.Status(ctx, client, ws)
419 if err != nil {
420 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
421 return 1
422 }
423 if st.PID == 0 {
424 fmt.Println("no serve recorded for this workspace")
425 return 0
426 }
427 fmt.Printf("pid=%d addr=%s alive=%v workspace=%s\n", st.PID, st.Addr, alive, st.Workspace)
428 return 0
429 case "logs":
430 if err := bootstrap.Logs(ctx, client, ws, *n, os.Stdout); err != nil {
431 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
432 return 1
433 }
434 return 0
435 default:
436 fmt.Fprintf(os.Stderr, "unknown serve action %q\n", action)
437 return 2
438 }
439 }
440
441 // remoteStatusCLI prints configured host summaries; with a name it also does a
442 // brief liveness probe.
443 func remoteStatusCLI(args []string) int {
444 cfg, err := config.Load()
445 if err != nil {
446 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
447 return 1
448 }
449 if len(args) == 0 {
450 return remoteListCLI()
451 }
452 name := args[0]
453 entry, ok := cfg.RemoteHost(name)
454 if !ok {
455 fmt.Fprintf(os.Stderr, "no remote host named %q\n", name)
456 return 1
457 }
458 fmt.Printf("host %s: %s@%s workspace=%s\n", entry.Name, entry.User, entry.Host, entry.Workspace)
459 return 0
460 }
461
462 // remoteForwardCLI manages persisted forward rules (applied on next connect).
463 func remoteForwardCLI(args []string) int {
464 if len(args) < 2 {
465 fmt.Fprintln(os.Stderr, "usage: reasonix remote forward add <host> (-L|-R) <spec> | rm <host> <name> | ls <host>")
466 return 2
467 }
468 switch args[0] {
469 case "ls":
470 return remoteForwardLs(args[1])
471 case "add":
472 return remoteForwardAdd(args[1:])
473 case "rm":
474 return remoteForwardRm(args[1:])
475 default:
476 fmt.Fprintf(os.Stderr, "unknown forward action %q\n", args[0])
477 return 2
478 }
479 }
480
481 func remoteForwardLs(host string) int {
482 cfg, err := config.Load()
483 if err != nil {
484 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
485 return 1
486 }
487 entry, ok := cfg.RemoteHost(host)
488 if !ok {
489 fmt.Fprintf(os.Stderr, "no remote host named %q\n", host)
490 return 1
491 }
492 if len(entry.Forwards) == 0 {
493 fmt.Println("no forwards configured")
494 return 0
495 }
496 for _, f := range entry.Forwards {
497 fmt.Printf("%s\t%s -> %s\n", f.Type, f.Bind, f.Target)
498 }
499 return 0
500 }
501
502 func remoteForwardAdd(args []string) int {
503 if len(args) != 3 {
504 fmt.Fprintln(os.Stderr, "usage: reasonix remote forward add <host> (-L|-R) <spec>")
505 return 2
506 }
507 host := args[0]
508 dir, err := forward.ParseDirection(args[1])
509 if err != nil {
510 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
511 return 2
512 }
513 spec, err := forward.ParseShorthand(dir, args[2])
514 if err != nil {
515 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
516 return 2
517 }
518 if spec.NonLoopbackBind() {
519 fmt.Fprintln(os.Stderr, "warning: bind address is not loopback; the forward will be reachable off-machine")
520 }
521 ftype := "local"
522 if dir == forward.Remote {
523 ftype = "remote"
524 }
525 found := false
526 err = editUserConfig(func(c *config.Config) error {
527 entry, ok := c.RemoteHost(host)
528 if !ok {
529 return fmt.Errorf("no remote host named %q", host)
530 }
531 entry.Forwards = append(entry.Forwards, config.RemoteForwardEntry{Type: ftype, Bind: spec.BindAddr, Target: spec.TargetAddr})
532 found = true
533 return c.UpsertRemoteHost(entry)
534 })
535 if err != nil {
536 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
537 return 1
538 }
539 _ = found
540 fmt.Printf("added %s forward %s -> %s to %q (takes effect on next connect)\n", ftype, spec.BindAddr, spec.TargetAddr, host)
541 return 0
542 }
543
544 func remoteForwardRm(args []string) int {
545 if len(args) != 2 {
546 fmt.Fprintln(os.Stderr, "usage: reasonix remote forward rm <host> <bind>")
547 return 2
548 }
549 host, bind := args[0], args[1]
550 removed := false
551 err := editUserConfig(func(c *config.Config) error {
552 entry, ok := c.RemoteHost(host)
553 if !ok {
554 return fmt.Errorf("no remote host named %q", host)
555 }
556 kept := entry.Forwards[:0]
557 for _, f := range entry.Forwards {
558 if f.Bind == bind || normalizeBind(f.Bind) == normalizeBind(bind) {
559 removed = true
560 continue
561 }
562 kept = append(kept, f)
563 }
564 entry.Forwards = kept
565 return c.UpsertRemoteHost(entry)
566 })
567 if err != nil {
568 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
569 return 1
570 }
571 if !removed {
572 fmt.Fprintf(os.Stderr, "no forward bound to %q on %q\n", bind, host)
573 return 1
574 }
575 fmt.Printf("removed forward %s from %q\n", bind, host)
576 return 0
577 }
578
579 // remoteFSCLI: fs ls|get|put with <name>:<path> operands.
580 func remoteFSCLI(args []string) int {
581 if len(args) < 2 {
582 fmt.Fprintln(os.Stderr, "usage: reasonix remote fs ls <name>:<path> | get <name>:<remote> [local] | put <local> <name>:<remote>")
583 return 2
584 }
585 switch args[0] {
586 case "ls":
587 return remoteFSLs(args[1])
588 case "get":
589 return remoteFSGet(args[1:])
590 case "put":
591 return remoteFSPut(args[1:])
592 default:
593 fmt.Fprintf(os.Stderr, "unknown fs action %q\n", args[0])
594 return 2
595 }
596 }
597
598 func splitHostPath(s string) (host, p string, ok bool) {
599 i := strings.Index(s, ":")
600 if i <= 0 || i == len(s)-1 {
601 return "", "", false
602 }
603 return s[:i], s[i+1:], true
604 }
605
606 func withRemoteFS(name string, fn func(ctx context.Context, client *remote.Client) int) int {
607 client, cleanup, err := buildRemoteClient(name)
608 if err != nil {
609 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
610 return 1
611 }
612 defer cleanup()
613 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
614 defer cancel()
615 if err := client.Start(ctx); err != nil {
616 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
617 return 1
618 }
619 defer client.Close()
620 return fn(ctx, client)
621 }
622
623 func remoteFSLs(operand string) int {
624 host, p, ok := splitHostPath(operand)
625 if !ok {
626 fmt.Fprintln(os.Stderr, "usage: reasonix remote fs ls <name>:<path>")
627 return 2
628 }
629 return withRemoteFS(host, func(ctx context.Context, client *remote.Client) int {
630 fsys, err := client.SFTP()
631 if err != nil {
632 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
633 return 1
634 }
635 entries, err := fsys.List(ctx, p)
636 if err != nil {
637 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
638 return 1
639 }
640 for _, e := range entries {
641 suffix := ""
642 if e.IsDir {
643 suffix = "/"
644 }
645 fmt.Printf("%s%s\n", e.Name, suffix)
646 }
647 return 0
648 })
649 }
650
651 func remoteFSGet(args []string) int {
652 if len(args) < 1 || len(args) > 2 {
653 fmt.Fprintln(os.Stderr, "usage: reasonix remote fs get <name>:<remote> [local]")
654 return 2
655 }
656 host, remotePath, ok := splitHostPath(args[0])
657 if !ok {
658 fmt.Fprintln(os.Stderr, "usage: reasonix remote fs get <name>:<remote> [local]")
659 return 2
660 }
661 localPath := path.Base(remotePath)
662 if len(args) >= 2 {
663 localPath = args[1]
664 }
665 return withRemoteFS(host, func(ctx context.Context, client *remote.Client) int {
666 fsys, err := client.SFTP()
667 if err != nil {
668 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
669 return 1
670 }
671 // Stream the full file to disk — never the capped preview reader, which
672 // would silently truncate large downloads.
673 out, err := os.Create(localPath)
674 if err != nil {
675 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
676 return 1
677 }
678 n, err := fsys.Download(ctx, remotePath, out)
679 if cerr := out.Close(); cerr != nil && err == nil {
680 err = cerr
681 }
682 if err != nil {
683 _ = os.Remove(localPath)
684 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
685 return 1
686 }
687 fmt.Printf("wrote %s (%d bytes)\n", localPath, n)
688 return 0
689 })
690 }
691
692 func remoteFSPut(args []string) int {
693 if len(args) != 2 {
694 fmt.Fprintln(os.Stderr, "usage: reasonix remote fs put <local> <name>:<remote>")
695 return 2
696 }
697 localPath := args[0]
698 host, remotePath, ok := splitHostPath(args[1])
699 if !ok {
700 fmt.Fprintln(os.Stderr, "usage: reasonix remote fs put <local> <name>:<remote>")
701 return 2
702 }
703 in, err := os.Open(localPath)
704 if err != nil {
705 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
706 return 1
707 }
708 defer in.Close()
709 return withRemoteFS(host, func(ctx context.Context, client *remote.Client) int {
710 fsys, err := client.SFTP()
711 if err != nil {
712 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
713 return 1
714 }
715 n, err := fsys.UploadAtomic(ctx, remotePath, in, 0o644)
716 if err != nil {
717 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
718 return 1
719 }
720 fmt.Printf("uploaded %s -> %s (%d bytes)\n", localPath, remotePath, n)
721 return 0
722 })
723 }
724
725 func openInBrowser(url string) error {
726 var cmd string
727 var args []string
728 switch runtime.GOOS {
729 case "darwin":
730 cmd, args = "open", []string{url}
731 case "windows":
732 cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
733 default:
734 cmd, args = "xdg-open", []string{url}
735 }
736 c := exec.Command(cmd, args...)
737 return c.Start()
738 }
739
739 lines GO