返回 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(ctx, 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(ctx context.Context, 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 remoteServeBrowserURL(ctx, 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 if action == "start" && entry.CredentialProxyEnabled() {
379 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "credential mode local-proxy requires the Reasonix desktop")
380 return 1
381 }
382 ws := *workspace
383 if ws == "" {
384 ws = entry.Workspace
385 }
386
387 client, cleanup, err := buildRemoteClient(name)
388 if err != nil {
389 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
390 return 1
391 }
392 defer cleanup()
393 connectCtx, connectCancel := context.WithTimeout(context.Background(), 60*time.Second)
394 if err := client.Start(connectCtx); err != nil {
395 connectCancel()
396 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
397 return 1
398 }
399 connectCancel()
400 defer client.Close()
401 operationTimeout := 60 * time.Second
402 if action == "start" {
403 operationTimeout = 10 * time.Minute // same-platform binary upload
404 }
405 ctx, cancel := context.WithTimeout(context.Background(), operationTimeout)
406 defer cancel()
407
408 switch action {
409 case "start":
410 res, err := bootstrap.EnsureServe(ctx, client, bootstrap.Options{
411 Workspace: ws, Install: entry.ServeInstallMode(),
412 LocalBinary: currentExecutable(), LocalGOOS: runtime.GOOS, LocalGOARCH: runtime.GOARCH,
413 ProductVersion: version, FetchBinary: fetchRemoteCLIBinary, MinVersion: bootstrap.MinServeVersion,
414 })
415 if err != nil {
416 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
417 return 1
418 }
419 fmt.Printf("serve running on remote %s (pid %d)\n", res.State.Addr, res.State.PID)
420 return 0
421 case "stop":
422 if err := bootstrap.Stop(ctx, client, ws); err != nil {
423 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
424 return 1
425 }
426 fmt.Println("serve stopped")
427 return 0
428 case "status":
429 st, alive, err := bootstrap.Status(ctx, client, ws)
430 if err != nil {
431 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
432 return 1
433 }
434 if st.PID == 0 {
435 fmt.Println("no serve recorded for this workspace")
436 return 0
437 }
438 fmt.Printf("pid=%d addr=%s alive=%v workspace=%s\n", st.PID, st.Addr, alive, st.Workspace)
439 return 0
440 case "logs":
441 if err := bootstrap.Logs(ctx, client, ws, *n, os.Stdout); err != nil {
442 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
443 return 1
444 }
445 return 0
446 default:
447 fmt.Fprintf(os.Stderr, "unknown serve action %q\n", action)
448 return 2
449 }
450 }
451
452 // remoteStatusCLI prints configured host summaries; with a name it also does a
453 // brief liveness probe.
454 func remoteStatusCLI(args []string) int {
455 cfg, err := config.Load()
456 if err != nil {
457 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
458 return 1
459 }
460 if len(args) == 0 {
461 return remoteListCLI()
462 }
463 name := args[0]
464 entry, ok := cfg.RemoteHost(name)
465 if !ok {
466 fmt.Fprintf(os.Stderr, "no remote host named %q\n", name)
467 return 1
468 }
469 fmt.Printf("host %s: %s@%s workspace=%s\n", entry.Name, entry.User, entry.Host, entry.Workspace)
470 return 0
471 }
472
473 // remoteForwardCLI manages persisted forward rules (applied on next connect).
474 func remoteForwardCLI(args []string) int {
475 if len(args) < 2 {
476 fmt.Fprintln(os.Stderr, "usage: reasonix remote forward add <host> (-L|-R) <spec> | rm <host> <name> | ls <host>")
477 return 2
478 }
479 switch args[0] {
480 case "ls":
481 return remoteForwardLs(args[1])
482 case "add":
483 return remoteForwardAdd(args[1:])
484 case "rm":
485 return remoteForwardRm(args[1:])
486 default:
487 fmt.Fprintf(os.Stderr, "unknown forward action %q\n", args[0])
488 return 2
489 }
490 }
491
492 func remoteForwardLs(host string) int {
493 cfg, err := config.Load()
494 if err != nil {
495 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
496 return 1
497 }
498 entry, ok := cfg.RemoteHost(host)
499 if !ok {
500 fmt.Fprintf(os.Stderr, "no remote host named %q\n", host)
501 return 1
502 }
503 if len(entry.Forwards) == 0 {
504 fmt.Println("no forwards configured")
505 return 0
506 }
507 for _, f := range entry.Forwards {
508 fmt.Printf("%s\t%s -> %s\n", f.Type, f.Bind, f.Target)
509 }
510 return 0
511 }
512
513 func remoteForwardAdd(args []string) int {
514 if len(args) != 3 {
515 fmt.Fprintln(os.Stderr, "usage: reasonix remote forward add <host> (-L|-R) <spec>")
516 return 2
517 }
518 host := args[0]
519 dir, err := forward.ParseDirection(args[1])
520 if err != nil {
521 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
522 return 2
523 }
524 spec, err := forward.ParseShorthand(dir, args[2])
525 if err != nil {
526 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
527 return 2
528 }
529 if spec.NonLoopbackBind() {
530 fmt.Fprintln(os.Stderr, "warning: bind address is not loopback; the forward will be reachable off-machine")
531 }
532 ftype := "local"
533 if dir == forward.Remote {
534 ftype = "remote"
535 }
536 found := false
537 err = editUserConfig(func(c *config.Config) error {
538 entry, ok := c.RemoteHost(host)
539 if !ok {
540 return fmt.Errorf("no remote host named %q", host)
541 }
542 entry.Forwards = append(entry.Forwards, config.RemoteForwardEntry{Type: ftype, Bind: spec.BindAddr, Target: spec.TargetAddr})
543 found = true
544 return c.UpsertRemoteHost(entry)
545 })
546 if err != nil {
547 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
548 return 1
549 }
550 _ = found
551 fmt.Printf("added %s forward %s -> %s to %q (takes effect on next connect)\n", ftype, spec.BindAddr, spec.TargetAddr, host)
552 return 0
553 }
554
555 func remoteForwardRm(args []string) int {
556 if len(args) != 2 {
557 fmt.Fprintln(os.Stderr, "usage: reasonix remote forward rm <host> <bind>")
558 return 2
559 }
560 host, bind := args[0], args[1]
561 removed := false
562 err := editUserConfig(func(c *config.Config) error {
563 entry, ok := c.RemoteHost(host)
564 if !ok {
565 return fmt.Errorf("no remote host named %q", host)
566 }
567 kept := entry.Forwards[:0]
568 for _, f := range entry.Forwards {
569 if f.Bind == bind || normalizeBind(f.Bind) == normalizeBind(bind) {
570 removed = true
571 continue
572 }
573 kept = append(kept, f)
574 }
575 entry.Forwards = kept
576 return c.UpsertRemoteHost(entry)
577 })
578 if err != nil {
579 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
580 return 1
581 }
582 if !removed {
583 fmt.Fprintf(os.Stderr, "no forward bound to %q on %q\n", bind, host)
584 return 1
585 }
586 fmt.Printf("removed forward %s from %q\n", bind, host)
587 return 0
588 }
589
590 // remoteFSCLI: fs ls|get|put with <name>:<path> operands.
591 func remoteFSCLI(args []string) int {
592 if len(args) < 2 {
593 fmt.Fprintln(os.Stderr, "usage: reasonix remote fs ls <name>:<path> | get <name>:<remote> [local] | put <local> <name>:<remote>")
594 return 2
595 }
596 switch args[0] {
597 case "ls":
598 return remoteFSLs(args[1])
599 case "get":
600 return remoteFSGet(args[1:])
601 case "put":
602 return remoteFSPut(args[1:])
603 default:
604 fmt.Fprintf(os.Stderr, "unknown fs action %q\n", args[0])
605 return 2
606 }
607 }
608
609 func splitHostPath(s string) (host, p string, ok bool) {
610 i := strings.Index(s, ":")
611 if i <= 0 || i == len(s)-1 {
612 return "", "", false
613 }
614 return s[:i], s[i+1:], true
615 }
616
617 func withRemoteFS(name string, fn func(ctx context.Context, client *remote.Client) int) int {
618 client, cleanup, err := buildRemoteClient(name)
619 if err != nil {
620 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
621 return 1
622 }
623 defer cleanup()
624 connectCtx, connectCancel := context.WithTimeout(context.Background(), 60*time.Second)
625 if err := client.Start(connectCtx); err != nil {
626 connectCancel()
627 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
628 return 1
629 }
630 connectCancel()
631 defer client.Close()
632 // fs put may transfer arbitrary files over a slow link.
633 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
634 defer cancel()
635 return fn(ctx, client)
636 }
637
638 func remoteFSLs(operand string) int {
639 host, p, ok := splitHostPath(operand)
640 if !ok {
641 fmt.Fprintln(os.Stderr, "usage: reasonix remote fs ls <name>:<path>")
642 return 2
643 }
644 return withRemoteFS(host, func(ctx context.Context, client *remote.Client) int {
645 fsys, err := client.SFTP()
646 if err != nil {
647 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
648 return 1
649 }
650 entries, err := fsys.List(ctx, p)
651 if err != nil {
652 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
653 return 1
654 }
655 for _, e := range entries {
656 suffix := ""
657 if e.IsDir {
658 suffix = "/"
659 }
660 fmt.Printf("%s%s\n", e.Name, suffix)
661 }
662 return 0
663 })
664 }
665
666 func remoteFSGet(args []string) int {
667 if len(args) < 1 || len(args) > 2 {
668 fmt.Fprintln(os.Stderr, "usage: reasonix remote fs get <name>:<remote> [local]")
669 return 2
670 }
671 host, remotePath, ok := splitHostPath(args[0])
672 if !ok {
673 fmt.Fprintln(os.Stderr, "usage: reasonix remote fs get <name>:<remote> [local]")
674 return 2
675 }
676 localPath := path.Base(remotePath)
677 if len(args) >= 2 {
678 localPath = args[1]
679 }
680 return withRemoteFS(host, func(ctx context.Context, client *remote.Client) int {
681 fsys, err := client.SFTP()
682 if err != nil {
683 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
684 return 1
685 }
686 // Stream the full file to disk — never the capped preview reader, which
687 // would silently truncate large downloads.
688 out, err := os.Create(localPath)
689 if err != nil {
690 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
691 return 1
692 }
693 n, err := fsys.Download(ctx, remotePath, out)
694 if cerr := out.Close(); cerr != nil && err == nil {
695 err = cerr
696 }
697 if err != nil {
698 _ = os.Remove(localPath)
699 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
700 return 1
701 }
702 fmt.Printf("wrote %s (%d bytes)\n", localPath, n)
703 return 0
704 })
705 }
706
707 func remoteFSPut(args []string) int {
708 if len(args) != 2 {
709 fmt.Fprintln(os.Stderr, "usage: reasonix remote fs put <local> <name>:<remote>")
710 return 2
711 }
712 localPath := args[0]
713 host, remotePath, ok := splitHostPath(args[1])
714 if !ok {
715 fmt.Fprintln(os.Stderr, "usage: reasonix remote fs put <local> <name>:<remote>")
716 return 2
717 }
718 in, err := os.Open(localPath)
719 if err != nil {
720 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
721 return 1
722 }
723 defer in.Close()
724 return withRemoteFS(host, func(ctx context.Context, client *remote.Client) int {
725 fsys, err := client.SFTP()
726 if err != nil {
727 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
728 return 1
729 }
730 n, err := fsys.UploadAtomic(ctx, remotePath, in, 0o644)
731 if err != nil {
732 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
733 return 1
734 }
735 fmt.Printf("uploaded %s -> %s (%d bytes)\n", localPath, remotePath, n)
736 return 0
737 })
738 }
739
740 func openInBrowser(url string) error {
741 var cmd string
742 var args []string
743 switch runtime.GOOS {
744 case "darwin":
745 cmd, args = "open", []string{url}
746 case "windows":
747 cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
748 default:
749 cmd, args = "xdg-open", []string{url}
750 }
751 c := exec.Command(cmd, args...)
752 if err := c.Start(); err != nil {
753 return err
754 }
755 // Browser launchers normally exit immediately after handing the URL to the
756 // desktop session. Reap that helper asynchronously so a long-lived web or
757 // remote process does not retain its process resources.
758 go func() { _ = c.Wait() }()
759 return nil
760 }
761
761 lines GO