| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "net" |
| 8 | "os" |
| 9 | "os/signal" |
| 10 | "strings" |
| 11 | "syscall" |
| 12 | |
| 13 | "reasonix/internal/agent" |
| 14 | "reasonix/internal/config" |
| 15 | "reasonix/internal/control" |
| 16 | "reasonix/internal/i18n" |
| 17 | "reasonix/internal/serve" |
| 18 | ) |
| 19 | |
| 20 | // runServe exposes the controller's HTTP and SSE frontend. |
| 21 | func runServe(args []string) int { |
| 22 | return runServeWithOptions(args, serveRunOptions{command: "serve"}) |
| 23 | } |
| 24 | |
| 25 | // runWeb adds local browser lifecycle behavior to the Serve frontend. |
| 26 | func runWeb(args []string) int { |
| 27 | return runServeWithOptions(args, serveRunOptions{command: "web", openBrowser: true}) |
| 28 | } |
| 29 | |
| 30 | type serveRunOptions struct { |
| 31 | command string |
| 32 | openBrowser bool |
| 33 | } |
| 34 | |
| 35 | type serveFrontendOptions struct { |
| 36 | command string |
| 37 | address string |
| 38 | portFile string |
| 39 | tokenFile string |
| 40 | pidFile string |
| 41 | openBrowser bool |
| 42 | hasSession bool |
| 43 | // launchTokenPath names where the launch token is, so the |
| 44 | // terminal shows a path rather than the secret. |
| 45 | launchTokenPath string |
| 46 | } |
| 47 | |
| 48 | type serveFrontendResources struct { |
| 49 | listener net.Listener |
| 50 | displayAddr string |
| 51 | artifacts []string |
| 52 | registration *webInstanceRegistration |
| 53 | } |
| 54 | |
| 55 | func prepareServeFrontend(opts serveFrontendOptions) (_ *serveFrontendResources, err error) { |
| 56 | resources := &serveFrontendResources{displayAddr: opts.address} |
| 57 | defer func() { |
| 58 | if err != nil { |
| 59 | resources.release(true) |
| 60 | } |
| 61 | }() |
| 62 | |
| 63 | if opts.portFile != "" || opts.command == "web" || opts.openBrowser { |
| 64 | if opts.command == "web" { |
| 65 | resources.listener, err = listenWebWithPortRetry(opts.address) |
| 66 | } else { |
| 67 | resources.listener, err = net.Listen("tcp", opts.address) |
| 68 | } |
| 69 | if err != nil { |
| 70 | return nil, err |
| 71 | } |
| 72 | resources.displayAddr = resources.listener.Addr().String() |
| 73 | requested, selected := requestedPort(opts.address), requestedPort(resources.displayAddr) |
| 74 | if opts.command == "web" && requested != 0 && requested != selected { |
| 75 | fmt.Fprintf(os.Stderr, " port %d is in use; using %d instead\n", requested, selected) |
| 76 | } |
| 77 | if opts.portFile != "" { |
| 78 | if err = writeServeAddrFile(opts.portFile, resources.displayAddr); err != nil { |
| 79 | return nil, err |
| 80 | } |
| 81 | resources.artifacts = append(resources.artifacts, opts.portFile) |
| 82 | } |
| 83 | } |
| 84 | if opts.pidFile != "" { |
| 85 | if err = writeServePidFile(opts.pidFile); err != nil { |
| 86 | return nil, err |
| 87 | } |
| 88 | resources.artifacts = append(resources.artifacts, opts.pidFile) |
| 89 | } |
| 90 | if opts.command == "web" { |
| 91 | resources.registration, err = registerWebInstance(config.ReasonixHomeDir(), resources.displayAddr) |
| 92 | if err != nil { |
| 93 | return nil, err |
| 94 | } |
| 95 | } |
| 96 | return resources, nil |
| 97 | } |
| 98 | |
| 99 | func (r *serveFrontendResources) release(closeListener bool) { |
| 100 | if closeListener && r.listener != nil { |
| 101 | _ = r.listener.Close() |
| 102 | } |
| 103 | if r.registration != nil { |
| 104 | r.registration.Release() |
| 105 | } |
| 106 | for _, path := range r.artifacts { |
| 107 | _ = os.Remove(path) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | func runServeFrontend(ctrl *control.Controller, srv *serve.Server, cfg config.ServeConfig, opts serveFrontendOptions) int { |
| 112 | resources, err := prepareServeFrontend(opts) |
| 113 | if err != nil { |
| 114 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 115 | return 1 |
| 116 | } |
| 117 | defer resources.release(false) |
| 118 | srv.EnableProviderSetupForListener(resources.displayAddr) |
| 119 | if srv.AuthMode() == "none" || srv.AuthMode() == "token" { |
| 120 | if opts.launchTokenPath, err = launchTokenLocation(srv.AuthToken(), opts, resources); err != nil { |
| 121 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 122 | return 1 |
| 123 | } |
| 124 | } |
| 125 | reportServeFrontend(ctrl, srv, cfg, resources.displayAddr, opts) |
| 126 | startServeBalanceDiagnostics(ctrl) |
| 127 | return serveFrontendLoop(ctrl, srv, resources, opts) |
| 128 | } |
| 129 | |
| 130 | func reportServeFrontend(ctrl *control.Controller, srv *serve.Server, cfg config.ServeConfig, address string, opts serveFrontendOptions) { |
| 131 | fmt.Printf("reasonix %s — %s on http://%s\n", opts.command, ctrl.Label(), address) |
| 132 | reportServeAuth(os.Stdout, srv, address, opts) |
| 133 | if warning := serve.PlainHTTPAuthWarning(cfg, address); warning != "" { |
| 134 | fmt.Fprintf(os.Stderr, " %s\n", warning) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // reportServeAuth names the file holding the launch token, never the token: a |
| 139 | // sandboxed command can read back a terminal multiplexer's scrollback. |
| 140 | func reportServeAuth(w io.Writer, srv *serve.Server, address string, opts serveFrontendOptions) { |
| 141 | switch srv.AuthMode() { |
| 142 | case "token": |
| 143 | fmt.Fprintln(w, " auth: token") |
| 144 | fmt.Fprintf(w, " share: http://%s/#token=<token> (token in %s)\n", address, opts.launchTokenPath) |
| 145 | case "password": |
| 146 | fmt.Fprintf(w, " auth: password (login at http://%s/login)\n", address) |
| 147 | case "none": |
| 148 | fmt.Fprintln(w, " auth: none (changes and approvals still require the launch token)") |
| 149 | fmt.Fprintf(w, " approvals: http://%s/#token=<token> (token in %s)\n", address, opts.launchTokenPath) |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | func startServeBalanceDiagnostics(ctrl *control.Controller) { |
| 154 | go func() { |
| 155 | if balance, err := ctrl.Balance(context.Background()); err != nil { |
| 156 | fmt.Fprintf(os.Stderr, " balance: error — %v\n", err) |
| 157 | } else if balance == nil { |
| 158 | fmt.Fprintln(os.Stderr, " balance: not configured (no balance_url for this provider)") |
| 159 | } else { |
| 160 | fmt.Printf(" balance: %s\n", balance.Display()) |
| 161 | } |
| 162 | }() |
| 163 | } |
| 164 | |
| 165 | func serveFrontendLoop(ctrl *control.Controller, srv *serve.Server, resources *serveFrontendResources, opts serveFrontendOptions) int { |
| 166 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) |
| 167 | defer stop() |
| 168 | if resources.listener == nil { |
| 169 | if err := srv.RunGraceful(ctx, opts.address); err != nil { |
| 170 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 171 | return 1 |
| 172 | } |
| 173 | return 0 |
| 174 | } |
| 175 | var serveErr error |
| 176 | if opts.openBrowser { |
| 177 | sessionID := "" |
| 178 | if opts.hasSession { |
| 179 | sessionID = agent.BranchID(ctrl.SessionPath()) |
| 180 | } |
| 181 | serveErr = runServeListenerAfterReady(ctx, srv, resources.listener, resources.displayAddr, func() { |
| 182 | browserURL, err := launchWebBrowser(srv, resources.displayAddr, sessionID) |
| 183 | if err != nil { |
| 184 | fmt.Fprintf(os.Stderr, " browser: could not open %s — %v\n", withoutFragment(browserURL), err) |
| 185 | } else { |
| 186 | fmt.Printf(" browser: %s\n", withoutFragment(browserURL)) |
| 187 | } |
| 188 | }) |
| 189 | } else { |
| 190 | serveErr = srv.RunGracefulListener(ctx, resources.listener) |
| 191 | } |
| 192 | if serveErr != nil { |
| 193 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, serveErr) |
| 194 | return 1 |
| 195 | } |
| 196 | return 0 |
| 197 | } |
| 198 | |
| 199 | // withoutFragment drops a #token= fragment before a URL reaches the terminal. |
| 200 | func withoutFragment(raw string) string { |
| 201 | before, _, _ := strings.Cut(raw, "#") |
| 202 | return before |
| 203 | } |
| 204 |